D30-F02-A01 / Released engineering topic

Daily Simple Interest Accrual: Keep Every Day, Preserve Fractional Cents, Round Once

A production-minded guide to Daily Simple Interest Accrual.

Daily Simple Interest Accrual: Keep Every Day, Preserve Fractional Cents, Round OnceD30 / D30-F02

Two days can earn a whole cent even when each day earns only half a cent. If you round each day independently, half-even rounding turns both halves into zero. The arithmetic looks tidy, but one earned cent disappears.

This tutorial builds an exact daily simple-interest calculator in Python and TypeScript. You will retain a complete calendar, apply causally available rates, carry fractional cents across periods and produce an explainable expense/payable proposal.

All data is synthetic. Cedar Teaching Bank's deposits_product_usd account is a separate product-calendar branch in entity CB-A and ledger CB-GENERAL. It is not Ada's earlier journal journey. Its balances, rates, receipts and product terms were created for teaching; no real tariff or customer history is represented.

Choose the convention before calculating

The selected convention uses the posted normal deposit balance at the end of each UTC civil day. The period is half-open: 1 October through 4 November 2026 means [2026-10-01,2026-11-05). That is 35 days, including every day with no transaction.

The annual input is a nominal rate in integer parts per million. A rate_ppm of 50000 means 50000/1000000=5%. We use Actual/365 Fixed: divide each day's annual interest by 365, including during leap years. Another contract may select a different denominator or balance basis.

The CFPB's US Regulation DD is a useful comparison for daily/average balance and separate compounding/crediting concepts. It does not define Cedar's UTC policy or half-even posting, and this calculator is not a compliance determination. Regulation DD section 1030.7

A nominal rate is also not an APY disclosure calculation; yield has its own compounding and disclosure context. CFPB Appendix A

Keep the quiet days

The product branch begins with 100000 cents. It has outgoing movements of 2400 on 22 October,12000 on 24 October and 10000 on 26 October. The eligible EOD calendar is:

DatesDaysBalance in centsBalance × days
1–21 October211000002100000
22–23 October297600195200
24–25 October285600171200
26 October–4 November1075600756000
Full period35—3222400

Those first 21 days all count. Averaging or accruing only on the four transaction dates would describe a different, incorrect calendar.

The complete 35-day EOD calendar retains all quiet days and four balance plateaus.

The input must contain exactly one row for each required date in ascending order. Missing days are errors, not zero balances. Duplicate and reordered days reject. A complete posted-history model such as Event-Sourced Balance Fold can supply daily economic projections, but the manually authored product rows here are not claimed as proof of that integration.

Accumulate exact fractions

Let B[d] be a day's eligible balance in integer cents and p[d] its annual nominal rate_ppm:

Plain text
daily interest = B[d] × p[d] /365000000
period earned = sum of every exact daily fraction

The denominator is 365×1000000. The result remains a fraction of one cent, not a dollar amount.

At a constant 50000 ppm, the complete weighted balance sum gives:

Plain text
earned =3222400/7300
       =32224/73 cents
       =441 +31/73 cents

With zero incoming carry, one half-even rounding step produces 441 cents, or$4.41. The remaining 31/73 of a cent is retained. Full October alone is a different period: its 31 daily balances sum to 2920000 and earn exactly 400 cents.

Now consider 3650 cents for two days at the same rate. Each day earns 3650/7300=1/2 cent; the exact total is 1 cent. Whole-cent rounding belongs at the selected period settlement boundary, not inside the daily loop.

Make the rate causal and immutable

Each daily row names a rate version. A version contains its immutable ID, rate_ppm, effective_from date and available_at timestamp. This teaching profile requires announcement no later than its own effective-day start.

For each day, select the latest effective version that was available by the day's start. A stale row reference cannot ignore a newer applicable version. Equal effective dates under different IDs are ambiguous. An exact duplicate version record rejects; changed facts under the same ID conflict.

This is a selected product policy. A rate announced after its own effective day routes RETROACTIVE_RATE_POLICY_UNSUPPORTED. The calculator does not silently reinterpret that announcement as permission to rewrite earlier terms.

The balance and rate clocks serve different jobs. Each EOD balance receipt is at or after that day's close and no later than the declared knowledge cutoff K. K must cover the full period through its exclusive end midnight. A later correction can affect an explicitly later as-of recomputation; it cannot enter a supposedly earlier evidence snapshot invisibly.

The source host must establish schedule completeness, account ownership and receipt authenticity. JSON field validation checks the declared facts, not whether the host is truthful.

Compare a changing rate without using the final rate everywhere

The focused 20–28 October window contains nine balances:

Plain text
100000,100000,97600,97600,85600,85600,75600,75600,75600

It is a focus window, not the full 35-day calculation. Its sum is 793200 cents. At 50000 ppm, earned interest is 7932/73 cents; rounding gives 109 cents and carry−25/73.

If the final five days use 60000 ppm, split the weighted principal by the applicable rate:

Plain text
first 4 days: balance sum 395200 at 5%
last 5 days:  balance sum 398000 at 6%
earned =(395200×0.05 +398000×0.06)/365
       =8728/73 cents
rounded =120 cents; carry =−32/73 cent

Multiplying the entire period by the final rate would reprice the earlier days. Average-Daily-Balance Interest reaches the same result when its averages are split into the same rate segments.

Retain signed carry

Separate three quantities:

Plain text
settlement exact = earned exact + opening carry
rounded proposal = half_even(settlement exact)
closing carry = settlement exact − rounded proposal

Half-even rounds an exact tie to the nearest even integer. Thus 1/2 rounds 0 while 3/2 rounds 2. In the latter case, carry is−1/2 of a cent: the proposal rounded up beyond exact earned interest.

That negative carry is not negative principal. It is a rounding residual. If the next period earns 1/2 cent, adding the prior−1/2 yields settlement 0, with a zero proposal and zero carry. Across both periods, exact earned interest and rounded proposals reconcile.

The accepted incoming carry is a reduced fraction between−1/2 and+1/2 minor unit, inclusive. Zero is canonically 0/1. Negative eligible deposit balances are a separate unsupported-policy case in this calculator. It does not implement the US negative-balance zero-floor treatment or infer a credit/debit-interest product from a negative number.

Exact earned plus opening carry equals rounded proposal plus closing carry; accrual expense/payable and later customer credit are separate steps.

Accruing is different from crediting

For the full 35-day result, the proposal is:

Plain text
Dr interest_expense_usd 441
Cr interest_payable_usd 441

A later actual credit to the product deposit is:

Plain text
Dr interest_payable_usd 441
Cr deposits_product_usd 441

Do not debit expense again during customer crediting. The first step recognizes the selected accrual proposal; the second settles the payable into the customer deposit. Neither has been durably posted by this pure calculator.

A proposal with amount 0 contains no zero-valued journal legs. If the exact rounded amount exceeds the Stage 1 per-leg bound, the calculation still returns its exact result, but proposal status is amount-limit and legs are empty. A host must supply chart validation, posting clocks, booking policy and stable business/journal identity before submitting a supported proposal. An updated as-of calculation is not permission to expense the same period twice.

Validate the full calendar and causal rates, accumulate exact fractions, add carry and round once into a proposal plus residual.

Full-size workflow. The editable Mermaid source expresses the same boundary order:

Run the exact reference

Python
import json
from implementations.python.daily_simple import accrue_daily_simple

with open("datasets/interest-cases.json", encoding="utf-8") as handle:
    case = json.load(handle)["cases"][0]
result = accrue_daily_simple(case["request"])
assert result["accrual"]["earned_exact"] == {
    "numerator":"32224", "denominator":"73"
}
assert result["posting_proposal"]["amount_minor"] == "441"

Python uses Fraction from validated integers. TypeScript uses gcd-reduced BigInt rationals and an explicit signed half-even rule. Outputs use canonical integer strings for money and numerator/denominator objects for fractions. No binary floating-point money or approximate fraction conversion enters the calculation. Python's exact rational and rounding API is documented in its version 3.11 Fraction reference.

The public functions return status/code/accrual/trace/posting_proposal. Every accepted daily row appears in the trace with its applied version, daily fraction and cumulative earned fraction. Invalid/incomplete inputs return typed rejection, null accrual/proposal and an empty trace. Negative-principal policy is checked after full structural/calendar validation.

Step the daily trace

Open the guided accrual lab. Its canonical preview uses all 35 days. Begin replay to see each daily fraction enter the accumulator; use the focus view to inspect 20–28 October without discarding the other days from computation. Compare constant/changing rates, equal closing balances with different paths, two half-cent days, signed carry, leap day, missing coverage and retroactive-rate rejection.

Six Python suites and 93 full-output parity cases check exact period values, every daily fraction, signed carry, nulls, rejection codes and the complete proposal. They include genuine years 0001–0099,366 days, large integer balances, scope syntax and amount-limit behavior. Validation evidence

The model is a contract-specific calculation, not a forecast, APY/EIR engine, legal-entitlement decision or durable posting system. Historical-example decision: not useful without a real product's complete applicable terms, source chronology and reusable evidence. Data contract · sources.

Continue to Compound Interest Accrual to change the interest-bearing base at explicit capitalization events. Use Idempotent Journal Posting for the separate accepted-posting boundary.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Accessed:25 October 2026. Synthetic bank/product/calendar only; no customer data or proprietary tariff.

IDPrimary source / versionSupportsLimitations
I01CFPB, Regulation DD section 1030.7, current version accessed 2026-10-25US daily/average-balance comparison and distinction of compounding/crediting policyDoes not establish Cedar's agreement, UTC cutoff, half-even or compliance
I02CFPB, Appendix A and official interpretation, currentYield terminology and disclosure contextA nominal-rate accrual is not an APY disclosure engine
I03CFPB, section 1030.2, currentDefinitions of yield and daily/average balanceUS jurisdiction; no universal contract requirement implied
I04Python, Fraction documentation, major 3.11 referenceExact integer-ratio arithmetic and ties-to-even Fraction roundingStrict wire grammar and financial policy are additional author choices
I05Root-frozen Stage 3 financial/API contract,2026-10-25Cedar calendar, rates, carry, proposal and unsupported routesFictional teaching profile; trusted host provenance remains assumed

Supports: Only the specific source-role claims above. Every balance, rate, account, version ID and example date is synthetic. Arithmetic was independently specified before reference execution.

Limitations: No real tariff, regulatory-compliance opinion, performance benchmark, EIR valuation or actual accepted journal is established. The US negative-balance zero treatment is not implemented by this reference: negative principal explicitly routes unsupported policy. Historical-example decision: not useful without a complete applicable contract and source chronology.

Source/version notes and claim classifications live in research/SOURCE-LEDGER.md and research/CLAIM-LEDGER.md. Public downloads mirror references and the frozen data contract under examples; no restricted source document is redistributed.

daily-simple.ts
/** Exact daily simple interest. All JSON money/fractions remain strings. */
type Obj = Record<string, any>;
export type Rational = {n: bigint; d: bigint};
const REQUEST = ['schema_version','calculation_id','ledger_id','entity_id','account_id','currency','period_start','period_end','knowledge_cutoff','day_count','opening_carry','rate_versions','days'];
const DAY = ['date','balance_minor','balance_available_at','balance_receipt_ref','rate_version_id'];
const VERSION = ['rate_version_id','rate_ppm','effective_from','available_at'];
const IDENT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
const STAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
const SIGNED = /^(0|-?[1-9][0-9]{0,29})$/;
const UNSIGNED = /^(0|[1-9][0-9]{0,29})$/;
const POSITIVE = /^[1-9][0-9]{0,29}$/;
const MAX_LEG = 10n ** 30n - 1n;
const DENOMINATOR = 365000000n;
export class InterestError extends Error {}
function need(condition: any, code: string): asserts condition {
    if (!condition) throw new InterestError(code);
}
const object = (v: any): v is Obj => v !== null && typeof v === 'object' &&
    !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
const exact = (v: any, fields: string[]): v is Obj => object(v) &&
    Object.keys(v).length === fields.length && fields.every(k => Object.hasOwn(v, k));
const full = (pattern: RegExp, value: string) => pattern.exec(value)?.[0] === value;
const identifier = (v: any) => typeof v === 'string' && full(IDENT, v);
function civil(v: any): number | null {
    if (typeof v !== 'string' || !full(DATE, v) || v.slice(0, 4) === '0000') return null;
    // ISO construction preserves years0001–0099; Date.UTC numeric years would not.
    const d = new Date(v + 'T00:00:00.000Z');
    return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === v ? d.getTime() : null;
}
function timestamp(v: any): boolean {
    if (typeof v !== 'string' || !full(STAMP, v) || v.slice(0, 4) === '0000') return false;
    const d = new Date(v);
    return Number.isFinite(d.getTime()) && d.toISOString() === v;
}
function abs(n: bigint) { return n < 0n ? -n : n; }
function gcd(a: bigint, b: bigint): bigint {
    while (b !== 0n) [a, b] = [b, a % b];
    return a;
}
export function rational(n: bigint, d = 1n): Rational {
    if (d <= 0n) throw new Error('Positive rational denominator required');
    const g = gcd(abs(n), d); return {n: n / g, d: d / g};
}
export const add = (a: Rational, b: Rational) => rational(a.n * b.d + b.n * a.d, a.d * b.d);
export const mulInt = (a: Rational, n: bigint) => rational(a.n * n, a.d);
export const fractionJson = (a: Rational) => ({numerator: a.n.toString(), denominator: a.d.toString()});
export function roundHalfEven(a: Rational): bigint {
    const sign = a.n < 0n ? -1n : 1n, n = abs(a.n), remainder = n % a.d;
    let whole = n / a.d;
    if (2n * remainder > a.d || (2n * remainder === a.d && whole % 2n === 1n)) whole++;
    return sign * whole;
}
function parseCarry(value: any): Rational {
    need(exact(value, ['numerator','denominator']), 'INVALID_CARRY');
    need(typeof value.numerator === 'string' && full(SIGNED, value.numerator), 'INVALID_CARRY');
    need(typeof value.denominator === 'string' && full(POSITIVE, value.denominator), 'INVALID_CARRY');
    const n = BigInt(value.numerator), d = BigInt(value.denominator);
    need(gcd(abs(n), d) === 1n && abs(2n * n) <= d, 'INVALID_CARRY');
    return rational(n, d);
}
export function validateCalendar(request: any): {rows: Obj[]; carry: Rational; dayCount: number} {
    need(exact(request, REQUEST), 'INVALID_SCHEMA');
    need(request.schema_version === 'd30-daily-simple-interest-v1', 'UNSUPPORTED_SCHEMA');
    need(['calculation_id','ledger_id','entity_id','account_id'].every(k => identifier(request[k])), 'INVALID_IDENTIFIER');
    need(request.currency === 'USD', 'UNSUPPORTED_CURRENCY');
    need(request.day_count === 'ACT_365_FIXED', 'UNSUPPORTED_DAY_COUNT');
    const start = civil(request.period_start), end = civil(request.period_end);
    need(start !== null && end !== null, 'INVALID_DATE');
    const count = (end - start) / 86400000;
    need(count >= 1 && count <= 366, 'INVALID_PERIOD');
    need(timestamp(request.knowledge_cutoff), 'INVALID_TIMESTAMP');
    need(request.knowledge_cutoff >= request.period_end + 'T00:00:00.000Z', 'KNOWLEDGE_BEFORE_PERIOD_END');
    const carry = parseCarry(request.opening_carry), versions = request.rate_versions;
    need(Array.isArray(versions) && versions.length <= 512, 'INVALID_RATE_VERSIONS');
    const byId = new Map<string, Obj>(), byEffective = new Set<string>();
    for (const version of versions) {
        need(exact(version, VERSION), 'INVALID_SCHEMA');
        need(identifier(version.rate_version_id), 'INVALID_IDENTIFIER');
        need(typeof version.rate_ppm === 'string' && full(UNSIGNED, version.rate_ppm), 'INVALID_RATE');
        need(BigInt(version.rate_ppm) <= 1000000n, 'UNSUPPORTED_RATE');
        need(civil(version.effective_from) !== null, 'INVALID_DATE');
        need(timestamp(version.available_at), 'INVALID_TIMESTAMP');
        need(version.available_at <= version.effective_from + 'T00:00:00.000Z', 'RETROACTIVE_RATE_POLICY_UNSUPPORTED');
        if (byId.has(version.rate_version_id)) {
            const saved = byId.get(version.rate_version_id)!;
            need(!VERSION.every(k => saved[k] === version[k]), 'DUPLICATE_RATE_VERSION');
            throw new InterestError('RATE_VERSION_CONFLICT');
        }
        need(!byEffective.has(version.effective_from), 'AMBIGUOUS_RATE_SCHEDULE');
        byId.set(version.rate_version_id, version); byEffective.add(version.effective_from);
    }
    const days = request.days;
    need(Array.isArray(days), 'INVALID_CALENDAR');
    need(days.length === count, 'INCOMPLETE_CALENDAR');
    const rows: Obj[] = [], seen = new Set<string>(); let negative = false;
    for (const [index, row] of days.entries()) {
        need(exact(row, DAY), 'INVALID_SCHEMA');
        const current = civil(row.date); need(current !== null, 'INVALID_DATE');
        need(current >= start && current < end, 'DAY_OUTSIDE_PERIOD');
        need(!seen.has(row.date), 'DUPLICATE_DAY');
        need(current === start + index * 86400000, 'OUT_OF_ORDER_DAY'); seen.add(row.date);
        need(typeof row.balance_minor === 'string' && full(SIGNED, row.balance_minor), 'INVALID_BALANCE');
        negative = negative || BigInt(row.balance_minor) < 0n;
        need(timestamp(row.balance_available_at), 'INVALID_TIMESTAMP');
        need(row.balance_available_at >= row.date + 'T23:59:59.999Z', 'BALANCE_BEFORE_DAY_CLOSE');
        need(row.balance_available_at <= request.knowledge_cutoff, 'BALANCE_BEYOND_KNOWLEDGE');
        need(identifier(row.balance_receipt_ref) && identifier(row.rate_version_id), 'INVALID_IDENTIFIER');
        const applicable = versions.filter(v => v.effective_from <= row.date && v.available_at <= row.date + 'T00:00:00.000Z');
        need(applicable.length > 0, 'NO_APPLICABLE_RATE');
        need(byId.has(row.rate_version_id), 'UNKNOWN_RATE_VERSION');
        const applied = byId.get(row.rate_version_id)!;
        need(applied.effective_from <= row.date, 'RATE_NOT_EFFECTIVE');
        need(applied.available_at <= row.date + 'T00:00:00.000Z', 'RATE_NOT_AVAILABLE');
        const latest = applicable.reduce((a, b) => a.effective_from > b.effective_from ? a : b);
        need(applied.rate_version_id === latest.rate_version_id, 'STALE_RATE_VERSION');
        rows.push({...row, rate_ppm: applied.rate_ppm});
    }
    need(!negative, 'NEGATIVE_BALANCE_POLICY_UNSUPPORTED');
    return {rows, carry, dayCount: count};
}
export function makePostingProposal(request: Obj, rounded: bigint): Obj {
    const status = rounded === 0n ? 'zero' : rounded > MAX_LEG ? 'amount-limit' : 'ready';
    const legs = status !== 'ready' ? [] : [
        {account_id:'interest_expense_usd', side:'debit', amount_minor:rounded.toString()},
        {account_id:'interest_payable_usd', side:'credit', amount_minor:rounded.toString()}
    ];
    return {schema_version:'d30-interest-accrual-proposal-v1', calculation_id:request.calculation_id,
        ledger_id:request.ledger_id, entity_id:request.entity_id, account_id:request.account_id,
        currency:request.currency, period_start:request.period_start, period_end:request.period_end,
        knowledge_cutoff:request.knowledge_cutoff, status, amount_minor:rounded.toString(), legs};
}
export function accrueDailySimple(request: any): Obj {
    try {
        const validated = validateCalendar(request); let earned = rational(0n), balanceSum = 0n;
        const trace: Obj[] = [];
        for (const row of validated.rows) {
            const balance = BigInt(row.balance_minor);
            const daily = rational(balance * BigInt(row.rate_ppm), DENOMINATOR);
            earned = add(earned, daily); balanceSum += balance;
            trace.push({...row, exact_daily_interest:fractionJson(daily), cumulative_earned_exact:fractionJson(earned)});
        }
        const settlement = add(earned, validated.carry), rounded = roundHalfEven(settlement);
        const accrual: Obj = {};
        for (const k of ['ledger_id','entity_id','account_id','currency','period_start','period_end','day_count','knowledge_cutoff']) accrual[k] = request[k];
        Object.assign(accrual, {day_count_value:String(validated.dayCount), balance_sum_minor:balanceSum.toString(),
            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)))});
        return {status:'ok', code:'INTEREST_ACCRUED', accrual, trace, posting_proposal:makePostingProposal(request, rounded)};
    } catch (error) {
        if (error instanceof InterestError) return {status:'rejected', code:error.message, accrual:null, trace:[], posting_proposal:null};
        throw error;
    }
}

Full-height labdaily interest labOpen full screen
Written by

Fintech engineer building market-data and financial systems, and the author of every article, glossary record, and reference implementation on The Fintech Builder.