Two deposit accounts close at the same 75600 minor units. One earns 441 in interest; the other earns 362. The difference is the balance path over the full period.
Average-Daily-Balance Interest makes that path explainable. An operator can show which days contributed, while an engineer can verify calendar coverage, rate changes and exact rounding. We will use fictional Cedar Teaching Bank's synthetic USD product calendar. Every amount, rate and receipt reference is a teaching input.
Start with every day
A period average needs a complete calendar. Days without transactions still contribute their end-of-day balance and increase the denominator. Averaging only the dates on which money moved gives those dates the wrong weight.
Event-Sourced Balance Fold reconstructs posted balance from complete committed evidence. Bitemporal Ledger Versioning separates economic dates from what evidence was available at a knowledge cutoff. This calculation consumes trusted daily receipts from that kind of adapter; the receipt strings do not authenticate themselves.
The US definition in CFPB Regulation DD §1030.2(d) uses the full daily principal sum and the period's day count. Cedar selects a UTC end-of-day, simple-interest, Actual/365 Fixed profile. Its boundaries are explicit; this tutorial does not claim to implement every US rule or another jurisdiction's product terms.
The synthetic product branch uses deposits_product_usd. It is separate from Ada's earlier correction example and actual database-test timestamps. The period is 1 October through 4 November 2026, with 5 November excluded. All 35 dates appear, including the quiet days.
Open the calendar calculation at full size.
The grouped arithmetic is independently specified:
| Dates | Days | EOD balance | Sum contribution |
|---|---|---|---|
| 1–21 October | 21 | 100000 | 2100000 |
| 22–23 October | 2 | 97600 | 195200 |
| 24–25 October | 2 | 85600 | 171200 |
| 26 October–4 November | 10 | 75600 | 756000 |
The total is 3222400 minor-unit days. Divide by all 35 days: 3222400/35 = 644480/7 minor units. Keep that fraction; rounding the average first changes the calculation.
At 50000 parts per million, or 5% annual nominal, ACT/365 Fixed interest is 32224/73 minor units. One final half-even rounding produces 441, with 31/73 carried forward. The full October subset has 31 days and exact interest 400. Independent arithmetic records these synthetic expectations before reference execution.
A flat 75600 on every one of the same 35 dates also closes at 75600. Its average is 75600 and its interest rounds to 362. Ending balance alone cannot explain what was available to earn interest throughout the period. A separate four-day deposit comparison makes the same point: [10000,10000,20000,20000] rounds to 8 at 5%; [20000,20000,20000,20000] rounds to 11 despite the identical close.
Keep changing rates with their days
With a single rate r, the exact average multiplied by actual days is the original balance sum. With changing rates, split the period into contiguous rate-version segments.
Let S_s be the segment's sum of daily balances, n_s its number of days, A_s=S_s/n_s its exact average, and r_s its annual nominal rate in ppm. Units are minor currency units and actual civil days:
interest_s = A_s × n_s × r_s / 365000000
earned_exact = sum(interest_s)
Suppose Cedar pays 5% through 23 October and 6% from 24 October. The first 23 dates sum to 2295200; the last 12 sum to 927200.
Open the rate-segment calculation at full size.
The two exact segment amounts are 22952/73 and 55632/365. Together they earn 170392/365, which rounds to 467 and leaves carry -63/365. Applying the final 6% rate to the whole-period average would instead round to 530. That shortcut pays the later rate on earlier days.
The full-period average still describes the path, but it does not retain the balance/rate pairing. Even equal overall averages can earn different amounts under the same changing-rate schedule: two days with balances [10000,20000] at 5% then 6% round to 5, while [20000,10000] at those same rates round to 4. Rate-version boundaries remain in the explanation even when adjacent versions have equal ppm.
Validate the calendar and rate history before calculating
The input has exact scope, currency, period, knowledge cutoff, day-count convention, incoming carry, rate versions and daily rows. A daily row includes date, signed canonical balance_minor, a balance receipt and its availability time, and applied rate-version ID. No numeric coercion, leading zeros, null values or unknown keys are accepted.
Every date in [period_start,period_end) must appear once, in order. The selected profile permits 1–366 days. A balance receipt must cover that day's UTC close and be available by the declared knowledge cutoff, which covers the whole period. A missing date rejects the complete result; it is never assumed zero or silently interpolated.
Each rate version has immutable identity, ppm, effective date and announcement time. It must be announced by the start of its effective day. Each row uses the latest applicable version already available by that day's start. A stale reference cannot bypass a known rate change. Duplicate versions, changed facts under one ID and ambiguous equal effective dates reject. Retroactive rate changes require a different policy.
Negative eligible interest principal routes to unsupported policy in this variant. The US interpretation in §1030.7 has a negative-balance zero treatment; Cedar does not claim that treatment is implemented. A signed fractional carry is a separate matter and remains valid. The later minimum-balance fee tutorial applies its own threshold policy to signed balances.
Preserve fractions until settlement
Daily Simple Interest Accrual supplies the underlying exact daily calculation. Average-balance segments agree with it because multiplying sum/count by that same count cancels exactly. No intermediate average or daily amount needs whole-minor rounding.
The TypeScript reference keeps an integer numerator and positive denominator:
const average = rational(sum, BigInt(count));
const product = mulInt(average, BigInt(count) * BigInt(rows[0].rate_ppm));
const interest = rational(product.n, product.d * 365000000n);
Using sum / BigInt(count) alone would truncate the average. The rational pair retains the remainder and is reduced by gcd. Python uses Fraction constructed only from validated integers; Python's documented rational arithmetic supports this exact representation and half-even rounding.
After summing the segments, add the opening fractional carry, round once and retain the signed difference:
settlement_exact = earned_exact + opening_carry
rounded_amount = half_even(settlement_exact)
closing_carry = settlement_exact - rounded_amount
Two 3650-balance days at 5% each earn half a minor unit. Together they earn one. Rounding each day half-even to zero would lose it. One 10950-balance day earns 3/2, rounds to two and carries -1/2. A negative half-minor carry with zero new earnings rounds to zero while retaining that carry; it is not a negative principal balance.
ACT/365 Fixed keeps denominator 365 on leap day. The supplied 2028 leap-calendar example earns exactly three minor units; a 366-denominator comparison earns 365/122 before rounding. These are declared variants, not an assertion that every product must use Cedar's convention.
An accrual proposal is a separate accounting step
The result reports the full exact average, day/segment traces, exact earned and settlement values, rounded amount and closing carry. A positive bounded amount proposes debit interest_expense_usd and credit explicit Stage 3 interest_payable_usd. Zero has no zero-value legs; an amount above the journal leg limit remains inspectable with a blocked amount-limit proposal.
That proposal is not an accepted journal. A host still supplies durable business identity and booking authority, then validates it through the posting control. Later customer credit debits payable and credits deposits_product_usd; it must not expense the same interest again. A changed as-of recomputation after a prior accepted accrual needs a controlled correction.
This is simple interest on supplied posted balances. It does not capitalize its own accrual, subtract pending holds, calculate annual percentage yield or choose a real customer's legal recognition policy. CFPB Appendix A provides a bounded comparison for the distinct yield concept.
Inspect the evidence and compare paths
The reference passes 101 cases: 31 accepted and 70 rejected, with strict TypeScript compilation and complete Python/TypeScript output parity. It independently compares every common accrual/proposal/day-trace fact against daily accumulation on the same inputs. Cases cover changing rates, same close/different path, same average/different rate association, missing and malformed dates, years 0001/0099/0100, leap day, carry ties, huge exact values and blocked proposals. Parity receipt, validation report.
Open the guided full-calendar lab. Its initial screen includes the full-period result and all 35 primary dates. Step the labeled prefix illustration to watch sum, count, average and segments grow; compare the flat path and changing rate. The missing-day scenario returns no complete amount. The lab bundles the actual TypeScript kernel and records its source hash. Prefix demonstrations use a shorter complete period at the same final knowledge cutoff; they do not pretend to reconstruct knowledge on each historical date.
Historical example: not useful. Synthetic complete calendars and explicit rate terms make the boundaries reproducible without invented real account histories. Continue to Minimum-Balance Fee, where the minimum daily balance, low-day count, grace and waiver answer a different product question.
Rendered from the canonical Mermaid sources linked by this article.
Preserve every day and its rate
Validate the complete as-of calendar before displaying a complete calculation.
The whole-period average describes the balance path. Rate segments determine interest when rates change. An exact calculation and a posting proposal are distinct from an accepted durable journal.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
- Accessed: 25 October 2026.
- Supports: source-specific method/context/runtime claims in the table below.
- Limitations: applicability is bounded per source; no real product tariff, universal legal rule or compliance endorsement.
All teaching amounts/accounts/rates are synthetic. Historical example: not useful. Current US regulator pages provide bounded comparisons, not a claim that Cedar implements every applicable rule.
| ID | Organization / source / version / jurisdiction | Exact source | Supports | Limitations |
|---|---|---|---|---|
| R01 | CFPB; official current Regulation DD; US | §1030.2(d) definitions | Daily principal sum divided by complete period count; nominal/APY terminology distinction | Does not define the selected UTC/rate/carry/accounting profile |
| R02 | CFPB; official current rule and interpretation; US | §1030.7 payment of interest | Balance methods, daily-rate/leap context, crediting/compounding and fee-minimum distinction | Negative interest balances have a US zero treatment; Cedar instead routes them unsupported |
| R03 | CFPB; official current appendix; US | Appendix A | APY is a yield concept involving interest and compounding | This is not an APY disclosure engine |
| R04 | CFPB; official current interpretation; US | Appendix A commentary | Precision and complete-period average context | Exact rational/half-even is our selected convention, not a quoted mandate |
| R05 | Python Software Foundation; official Python 3.11 docs; technical | fractions | Integer rational construction, reduced representation, exact half-even round | Local runtime 3.11.3; strict wire validation precedes library use; no permissive text/float construction |
| R06 | Ecma TC39; official living language spec; technical | BigInt type | Exact integer foundation | Rational denominator/gcd/half-even logic still required; not all integer division preserves a fraction |
The 35-day calendar is independently authored from declared grouped synthetic values. Rate-segment equivalence is author-derived algebra, not an empirical/provider observation. Validation outcomes describe the local reference and browser run only. Canonical input bounds, UTC EOD, causal latest-rate policy and half-even settlement are implementation choices. Source ownership, permission and completeness remain trusted adapter responsibilities; no credential, real customer or licensed provider dataset is included.
Full dependency-light reference implementations in both supported languages.
/** Exact segment-average deposit interest under the synthetic Cedar profile.
* The pinned local kernel validates calendars/rates and proposes posting legs.
* This module independently accumulates the segment-average interest method.
*/
import { InterestError, add, fractionJson, makePostingProposal, mulInt, rational, roundHalfEven, validateCalendar } from './interest_calendar.js';
const SCHEMA = 'd30-average-daily-balance-interest-v1';
const BASE_SCHEMA = 'd30-daily-simple-interest-v1';
const DENOMINATOR = 365000000n;
const DAY_MS = 86400000;
type RecordValue = Record<string, unknown>;
function calendarRequest(request: unknown): unknown {
if (request === null || typeof request !== 'object' || Array.isArray(request) || ![Object.prototype, null].includes(Object.getPrototypeOf(request))) return request;
const adapted = { ...request } as RecordValue;
if (Object.hasOwn(adapted, 'schema_version')) {
adapted.schema_version = adapted.schema_version === SCHEMA ? BASE_SCHEMA : 'unsupported-a03-input';
}
return adapted;
}
export function calculateAverageDailyBalanceInterest(request: unknown) {
try {
const validated = validateCalendar(calendarRequest(request));
const source = request as RecordValue;
const groups: typeof validated.rows[] = [];
for (const row of validated.rows) {
if (groups.length === 0 || groups.at(-1)![0].rate_version_id !== row.rate_version_id) groups.push([]);
groups.at(-1)!.push(row);
}
const segments = [];
let earned = rational(0n, 1n), totalBalance = 0n;
for (let index = 0; index < groups.length; index++) {
const rows = groups[index], count = rows.length;
const sum = rows.reduce((total, row) => total + BigInt(row.balance_minor), 0n);
const average = rational(sum, BigInt(count));
// Division keeps a rational pair; no monetary value becomes a Number.
const product = mulInt(average, BigInt(count) * BigInt(rows[0].rate_ppm));
const interest = rational(product.n, product.d * DENOMINATOR);
segments.push({
segment_index: index,
period_start: rows[0].date,
period_end: new Date(new Date(rows.at(-1)!.date + 'T00:00:00.000Z').getTime() + DAY_MS).toISOString().slice(0, 10),
day_count_value: String(count),
rate_version_id: rows[0].rate_version_id,
rate_ppm: rows[0].rate_ppm,
balance_sum_minor: sum.toString(),
average_balance_exact: fractionJson(average),
earned_exact: fractionJson(interest),
});
earned = add(earned, interest);
totalBalance += sum;
}
const trace = [];
let cumulative = rational(0n, 1n);
for (let index = 0; index < groups.length; index++) {
for (const row of groups[index]) {
const daily = rational(BigInt(row.balance_minor) * BigInt(row.rate_ppm), DENOMINATOR);
cumulative = add(cumulative, daily);
trace.push({
date: row.date,
balance_minor: row.balance_minor,
balance_receipt_ref: row.balance_receipt_ref,
balance_available_at: row.balance_available_at,
rate_version_id: row.rate_version_id,
rate_ppm: row.rate_ppm,
exact_daily_interest: fractionJson(daily),
cumulative_earned_exact: fractionJson(cumulative),
segment_index: index,
});
}
}
if (cumulative.n !== earned.n || cumulative.d !== earned.d) throw Error('Segment/day algebra mismatch');
const settlement = add(earned, validated.carry), rounded = roundHalfEven(settlement);
const accrual = {
ledger_id: source.ledger_id,
entity_id: source.entity_id,
account_id: source.account_id,
currency: source.currency,
period_start: source.period_start,
period_end: source.period_end,
day_count: source.day_count,
day_count_value: String(validated.dayCount),
balance_sum_minor: totalBalance.toString(),
average_balance_exact: fractionJson(rational(totalBalance, BigInt(validated.dayCount))),
opening_carry: fractionJson(validated.carry),
earned_exact: fractionJson(earned),
settlement_exact: fractionJson(settlement),
rounded_amount_minor: rounded.toString(),
closing_carry: fractionJson(add(settlement, rational(-rounded, 1n))),
knowledge_cutoff: source.knowledge_cutoff,
};
return { status: 'ok', code: 'AVERAGE_BALANCE_INTEREST_CALCULATED', accrual, trace, segments, posting_proposal: makePostingProposal(source, rounded) };
} catch (error) {
if (!(error instanceof InterestError)) throw error;
return { status: 'rejected', code: error.message, accrual: null, trace: [], segments: [], posting_proposal: null };
}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
