Skip to content

How to test the accrued interest formula and day count conventions

Interest accrual testing recomputes each period in decimal arithmetic from the contract's day count convention, rounding mode, and rounding point, then compares the result with the system's posted interest to the cent. On the period from 28 February 2027 to 31 March 2027, four of the 30/360 variants return 30, 32, or 33 days.

Where the accrued interest formula goes wrong

The accrued interest formula looks trivial: principal × annual rate × day count fraction. The defects sit in the day count fraction and in the rounding point. A servicing engine labelled "30/360" that implements the wrong 30/360 variant, a leap-day check written as day == 28, or daily interest rounded to the cent before it is summed all pass a test suite built on mid-month dates in a non-leap year.

In production each defect repeats on every loan or deposit account that shares the product configuration. Interest charged above the contract is a refund and remediation exercise across the book; interest below it is lost revenue that the general ledger records as correct. For consumer credit in the US, accrual inputs feed the finance charge and the APR, and the tolerances for a disclosed APR are on the Regulation Z testing page. The formulas themselves, APR, amortisation, and fees are scoped in financial calculation testing; this guide gives the date matrix, the reference model, and the checks.

Every figure below uses synthetic loans with the same terms, one loan per convention: principal USD 120,000.00 at 6.00% simple annual interest, so one 30-day month under a 360-day basis accrues exactly USD 600.00. Periods count the start date and exclude the end date.

Procedure

1. Write the contract terms down as test configuration

Each product needs five terms before any expected value exists: the convention, the end-of-month (EOM) rule, the rounding mode, the rounding point (per day, per period, or at payoff), and the maturity date. When the loan agreement and the product configuration disagree, that is the first defect to log.

JSON
{
  "loan_id": "LN-TEST-30E360ISDA-M",
  "product": "TERM-LOAN-USD-A",
  "currency": "USD",
  "principal": "120000.00",
  "annual_rate": "0.06",
  "day_count": "30E/360-ISDA",
  "eom_rule": true,
  "rounding_mode": "ROUND_HALF_EVEN",
  "rounding_point": "per_period",
  "maturity_date": "2028-02-29"
}

Each synthetic loan carries one convention, so the matrix below uses one loan per convention; this record is the loan that tests the maturity exception. Amounts are strings so no binary float touches them. The rounding mode itself, including tie cases, is covered in the banker's rounding testing guide.

2. Build the date matrix

These periods cover month-end, February in both year types, the year boundary, and a maturity date on the last day of February. Expected interest is USD 120,000.00 at 6.00%, rounded half-even to the cent at the end of each period.

Data
period,loan_id,start,end,maturity,convention,days_in_numerator,expected_interest_usd
feb-mar-2027,LN-TEST-ACT360,2027-02-28,2027-03-31,2030-12-31,ACT/360,31,620.00
feb-mar-2027,LN-TEST-ACT365F,2027-02-28,2027-03-31,2030-12-31,ACT/365F,31,611.51
feb-mar-2027,LN-TEST-ACTACT,2027-02-28,2027-03-31,2030-12-31,ACT/ACT-ISDA,31,611.51
feb-mar-2027,LN-TEST-30360ISDA,2027-02-28,2027-03-31,2030-12-31,30/360-ISDA,33,660.00
feb-mar-2027,LN-TEST-30U360EOM,2027-02-28,2027-03-31,2030-12-31,30U/360-EOM,30,600.00
feb-mar-2027,LN-TEST-30E360,2027-02-28,2027-03-31,2030-12-31,30E/360,32,640.00
feb-mar-2027,LN-TEST-30E360ISDA,2027-02-28,2027-03-31,2030-12-31,30E/360-ISDA,30,600.00
feb-mar-2028,LN-TEST-ACTACT,2028-02-29,2028-03-31,2030-12-31,ACT/ACT-ISDA,31,609.84
feb-mar-2028,LN-TEST-30E360,2028-02-29,2028-03-31,2030-12-31,30E/360,31,620.00
feb-mar-2028,LN-TEST-30E360ISDA,2028-02-29,2028-03-31,2030-12-31,30E/360-ISDA,30,600.00
year-end,LN-TEST-ACT365F,2027-12-15,2028-01-15,2030-12-31,ACT/365F,31,611.51
year-end,LN-TEST-ACTACT,2027-12-15,2028-01-15,2030-12-31,ACT/ACT-ISDA,31,610.75
maturity,LN-TEST-30E360ISDA-M,2028-01-31,2028-02-29,2028-02-29,30E/360-ISDA,29,580.00
not-maturity,LN-TEST-30E360ISDA,2028-01-31,2028-02-29,2030-12-31,30E/360-ISDA,30,600.00
full-2028,LN-TEST-ACT365F,2028-01-01,2029-01-01,2030-12-31,ACT/365F,366,7219.73
full-2028,LN-TEST-ACTACT,2028-01-01,2029-01-01,2030-12-31,ACT/ACT-ISDA,366,7200.00
full-2028,LN-TEST-ACT360,2028-01-01,2029-01-01,2030-12-31,ACT/360,366,7320.00

The year-end row for Actual/Actual (ISDA) splits the period: 17 days of 2027 over 365 plus 14 days of 2028 over 366. An engine that uses the day count of the start year returns 611.51 on that row. The full-2028 rows show the leap year: Actual/365 Fixed accrues USD 7,219.73 over the 366 days of 2028, Actual/Actual (ISDA) accrues exactly the stated USD 7,200.00, and Actual/360 accrues USD 7,320.00.

3. Build an independent reference model

The model follows the date adjustment rules as documented for OpenGamma Strata's DayCounts, which cites 2006 ISDA Definitions section 4.16(b) to (h) (Strata API documentation, checked on 2026-09-22). Amounts use Python's decimal module, and interest() runs inside a local context with precision and rounding set explicitly, so a changed global context cannot move a result.

Python
import calendar
from datetime import date
from decimal import Decimal as D, ROUND_HALF_EVEN, localcontext

def last_feb(d: date) -> bool:
    return d.month == 2 and d.day == calendar.monthrange(d.year, 2)[1]

def year_fraction(conv: str, start: date, end: date, maturity: date | None = None) -> D:
    days = (end - start).days
    if conv == "ACT/360":
        return D(days) / D(360)
    if conv == "ACT/365F":
        return D(days) / D(365)
    if conv == "ACT/ACT-ISDA":
        total, cur = D(0), start
        while cur < end:
            seg_end = min(date(cur.year + 1, 1, 1), end)
            total += D((seg_end - cur).days) / D(366 if calendar.isleap(cur.year) else 365)
            cur = seg_end
        return total
    d1, d2 = start.day, end.day
    if conv == "30/360-ISDA":
        if d1 == 31: d1 = 30
        if d2 == 31 and d1 == 30: d2 = 30
    elif conv == "30U/360-EOM":
        if last_feb(start) and last_feb(end): d2 = 30
        if last_feb(start): d1 = 30
        if d2 == 31 and d1 >= 30: d2 = 30
        if d1 == 31: d1 = 30
    elif conv == "30E/360":
        if d1 == 31: d1 = 30
        if d2 == 31: d2 = 30
    elif conv == "30E/360-ISDA":
        if d1 == 31 or last_feb(start): d1 = 30
        if d2 == 31 or (last_feb(end) and end != maturity): d2 = 30
    else:
        raise ValueError(f"unknown convention {conv}")
    n = 360 * (end.year - start.year) + 30 * (end.month - start.month) + (d2 - d1)
    return D(n) / D(360)

def interest(principal: str, rate: str, conv: str, start: date, end: date, maturity=None) -> D:
    with localcontext() as ctx:
        ctx.prec = 28
        ctx.rounding = ROUND_HALF_EVEN
        raw = D(principal) * D(rate) * year_fraction(conv, start, end, maturity)
        return raw.quantize(D("0.01"), rounding=ROUND_HALF_EVEN)

Run the model over the date matrix before any system call: every row of the CSV must reproduce, or the matrix itself is wrong.

4. Compare the system's accrual with the model

Ask the system under test for accrued interest per period and compare to the cent. The host and loan are synthetic.

Request
GET /v1/loans/LN-TEST-30E360ISDA/accruals?from=2027-02-28&to=2027-03-31 HTTP/1.1
Host: api.example.com
Authorization: Bearer test_token_not_real
Accept: application/json

The test records a failed call as a result instead of crashing the run.

Python
import requests
from datetime import date
from decimal import InvalidOperation
from reference_model import D, interest  # the first Python block, saved as reference_model.py

def check_period(row: dict) -> dict:
    try:
        url = f"https://api.example.com/v1/loans/{row['loan_id']}/accruals"
        r = requests.get(url, params={"from": row["start"], "to": row["end"]}, timeout=10)
    except (KeyError, requests.RequestException) as exc:
        return {"period": row["period"], "convention": row["convention"], "result": "error", "detail": type(exc).__name__}
    if r.status_code != 200:
        return {"period": row["period"], "convention": row["convention"], "result": "error", "detail": f"HTTP {r.status_code}"}
    try:
        actual = D(str(r.json()["accrued_interest"]))
    except (ValueError, KeyError, TypeError, InvalidOperation):
        return {"period": row["period"], "convention": row["convention"], "result": "error", "detail": "unparseable body"}
    maturity = date.fromisoformat(row["maturity"]) if row["maturity"] else None
    expected = interest("120000.00", "0.06", row["convention"],
                        date.fromisoformat(row["start"]), date.fromisoformat(row["end"]), maturity)
    return {"period": row["period"], "convention": row["convention"], "result": "pass" if actual == expected else "fail",
            "expected": str(expected), "actual": str(actual), "diff": str(actual - expected)}

The comparison tolerance is zero. A one-cent difference on a contract amount is a defect; check the rounding point first, then the day count.

5. Test the rounding point against the ledger

The Actual/365 Fixed loan, LN-TEST-ACT365F, accrues USD 19.726027 per day. Rounded per day, 31 days post 31 × 19.73 = USD 611.63; rounded once per period, the same 31 days post USD 611.51. Over the twelve calendar months of 2027, per-period rounding posts USD 7,200.02 and a single rounding at payoff gives USD 7,200.00. Each figure can be correct: the contract decides which one is. The test fails the engine that mixes them, for example a payoff quote computed from unrounded accrual on a loan whose statements posted rounded period interest.

SQL
-- Posted period interest must equal the sum of posted daily accruals for the same loan and period
SELECT a.loan_id, a.period_start, a.period_end,
       SUM(a.amount_minor) AS daily_sum_minor,
       p.amount_minor      AS posted_minor,
       p.amount_minor - SUM(a.amount_minor) AS diff_minor
FROM interest_accruals a
JOIN interest_postings p
  ON p.loan_id = a.loan_id AND p.period_start = a.period_start AND p.period_end = a.period_end
WHERE a.accrual_date >= a.period_start AND a.accrual_date < a.period_end
GROUP BY a.loan_id, a.period_start, a.period_end, p.amount_minor
HAVING p.amount_minor <> SUM(a.amount_minor);

A residual here is money the ledger cannot explain; the ledger reconciliation guide classifies such breaks. Repayment waterfalls, hardship plans, and payoff quotes around the accrual are scoped in lending and credit testing.

The pitfall: "30/360" names several conventions

Product specifications and loan agreements often say "30/360" without a variant. On the 28 February to 31 March 2027 period, four of the 30/360 variants documented in Strata disagree:

  • 30/360 ISDA (bond basis) keeps the end date at 31 because the start day is 28, not 30 or 31: 33 days, USD 660.00.
  • 30U/360 with EOM moves a start date on the last day of February to 30, then the end date 31 to 30: 30 days, USD 600.00.
  • 30E/360 moves any 31 to 30 and leaves 28 February alone: 32 days, USD 640.00.
  • 30E/360 ISDA moves the last day of February to 30 except on the maturity date: 30 days, USD 600.00.

The spread is USD 60.00 on one period across four synthetic loans with identical terms. In 2028 the last day of February is the 29th, so an EOM rule coded as day == 28 fires on 28 February 2028, which is an ordinary day, and misses the 29th. The 30E/360 ISDA maturity exception is the second trap: a loan maturing on 29 February 2028 accrues 29 days for its final period from 31 January, while the same dates in a non-final period accrue 30. Engines that ignore the maturity date overcharge the final period by one day of interest.

Test matrix

CaseDatesDefect it catches
February to month-end 312027-02-28 to 2027-03-31Wrong 30/360 variant
Leap-year February2028-02-29 to 2028-03-31EOM rule tied to day 28
Year boundary2027-12-15 to 2028-01-15Actual/Actual using one year's basis
Final period on 29 February2028-01-31 to 2028-02-29Missing 30E/360 ISDA maturity exception
Full leap year2028-01-01 to 2029-01-01Actual/365 Fixed treated as Actual/Actual
Per-day against per-period roundingAny 31-day periodRounding point differs from contract
Start and end day countingAny periodBoth dates counted, one extra day
Payoff mid-periodPayoff on day 17Payoff quote ignores posted rounding

Business banking interest on balances runs the same matrix with the deposit agreement's convention; the lending segment page lists the servicing integrations where these accruals originate.

Questions

What is Actual/360?

Actual/360 is a day count convention that divides the actual number of calendar days in an interest period by 360. On 120,000.00 United States dollars (USD) at 6.00% annual interest, a 31-day period accrues 620.00 under Actual/360 and 611.51 under Actual/365 Fixed, so a 365-day year under Actual/360 charges more interest than the stated annual rate.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing