Fixing Floating-Point Drift in Lease Amortization Schedules

Why binary float breaks penny-exact ASC 842 / IFRS 16 amortization schedules, and how decimal Decimal with quantize and a terminal adjustment restores a zero-close, re-derivable liability.

Problem Statement Link to this section

A lease amortization schedule built with Python float looks correct in a notebook and then fails the one test that matters: it does not close to exactly zero, and two runs on two machines do not agree to the cent. The reason is not a logic bug in the effective-interest recursion — it is that binary floating point cannot represent most decimal cent amounts exactly. The value 0.10 is stored as a binary fraction slightly larger than a tenth, 0.29 slightly smaller, and every interest accrual, principal split, and balance carry-forward compounds that representation error across dozens of periods. On a monthly lease over a five- or ten-year term the accumulated drift is routinely a few cents — invisible on screen, fatal to an audit that re-derives the schedule and expects the published figures back. This page shows exactly why float breaks a penny-exact schedule and how decimal.Decimal with per-period quantize and a terminal adjustment produces a liability that unwinds to zero and reproduces byte-for-byte. It is one failure mode in the broader amortization precision troubleshooting cluster.

Standard Anchor Link to this section

The requirement that forces exactness is the subsequent-measurement provision read together with the audit expectation of re-derivability. ASC 842-20-35-3 directs the lessee to increase the lease liability for interest and decrease it for payments using the interest method, and IFRS 16.36 requires the same, with interest producing a constant periodic rate on the remaining balance. Neither standard tabulates the numbers; both mandate a method. That is the key point for engineers: because the reported interest expense and closing liability are outputs of a method, they are only supported if the method re-runs deterministically and reproduces them. An auditor re-performing the calculation takes the same payments and the same locked rate and expects the same schedule, down to a terminal balance of exactly zero. A float schedule cannot guarantee that byte-for-byte reproduction, so even when its figures are "close enough" to look right, they are unsupported in the sense the standard and SOX Section 404 require. Exact decimal arithmetic is therefore not an optimization — it is what makes the figures defensible.

Algorithm Specification: Where the Error Accumulates Link to this section

Represent the terminal error as the sum of per-period representation errors. Under exact decimal arithmetic with cent quantization, each period contributes at most half a cent, so the worst-case residual after periods is bounded:

Under binary float, there is no comparable clean bound. Each stored cent already carries a representation error on the order of machine epsilon relative to its magnitude, , and those errors enter every multiplication and subtraction before any rounding is even attempted:

The fix has two parts. First, do the arithmetic in Decimal, which represents 0.01 exactly, so and the only error is the deliberate cent rounding bounded above. Second, drive that bounded residual to zero with a terminal adjustment: set the final principal equal to the prior closing balance and derive the final interest as payment minus principal, so exactly regardless of the accumulated .

Float drift versus a Decimal schedule that closes to zero Two period tracks run left to right across the lease term. The upper track, computed in binary float, shows a closing-balance error that wanders away from the zero line period by period and ends a few cents above zero, so the schedule fails re-derivation. The lower track, computed in decimal Decimal with per-period cent rounding, stays on the zero line within a bounded half-cent band and a terminal adjustment forces the final closing balance to exactly zero, so the schedule passes. float schedule zero line period t → closing 0.04 FAILS re-derivation Decimal schedule zero line ± half-cent band period t → closing 0.00 terminal adjustment
The float schedule's closing-balance error wanders and ends a few cents off zero, so it cannot be re-derived. The Decimal schedule stays within a bounded half-cent band and a terminal adjustment forces the final balance to exactly zero.

Annotated Python Snippet: Float Wrong, Decimal Correct Link to this section

The two functions below run the identical effective-interest recursion on the identical inputs. The first accumulates in float and leaves a residual; the second uses Decimal, quantizes each period to the cent, applies a terminal adjustment, and asserts a zero close.

from decimal import Decimal, ROUND_HALF_UP

# WRONG — float money drifts; cents have no exact binary representation
def schedule_float(ll0, rate, payment, n):
    balance, rows = ll0, []
    for t in range(1, n + 1):
        interest = round(balance * rate, 2)        # float * float, then round
        principal = round(payment - interest, 2)
        balance = round(balance - principal, 2)     # error compounds each period
        rows.append((t, interest, principal, balance))
    return rows                                     # rows[-1] balance != 0.0

# CORRECT — Decimal money is exact; residual absorbed in the final period
def schedule_decimal(ll0: Decimal, rate: Decimal, payment: Decimal, n: int):
    cent = Decimal("0.01")
    balance, rows = ll0, []
    for t in range(1, n + 1):
        interest = (balance * rate).quantize(cent, ROUND_HALF_UP)
        if t == n:                                  # terminal adjustment
            principal = balance                     # close the balance exactly
            interest = (payment - principal).quantize(cent, ROUND_HALF_UP)
        else:
            principal = (payment - interest).quantize(cent, ROUND_HALF_UP)
        balance = (balance - principal).quantize(cent, ROUND_HALF_UP)
        rows.append((t, interest, principal, balance))
    return rows

bad = schedule_float(100000.00, 0.005, 1933.28, 60)
good = schedule_decimal(Decimal("100000.00"), Decimal("0.005"),
                        Decimal("1933.28"), 60)

# The float schedule leaves a residual; the Decimal schedule closes to zero.
assert bad[-1][3] != 0.0                            # float drifts off zero
assert good[-1][3] == Decimal("0.00"), "Decimal schedule must close to zero"

The final assertion is the guardrail an auditor's re-derivation would apply. Keep it inside the function that publishes the schedule so it runs on every lease in CI, not just in an ad-hoc check. Note that Decimal("0.005") is built from a string; writing Decimal(0.005) from a float literal would import the very drift the rewrite is meant to remove.

Correct vs. Incorrect Link to this section

Aspect float schedule Decimal schedule
Cent representation 0.01 stored as an inexact binary fraction 0.01 represented exactly
Per-period error Nonzero before any rounding, then compounds Bounded to half a cent by deliberate quantize
Terminal balance Drifts a few cents off zero over a long term Forced to exactly zero by the terminal adjustment
Reproducibility May differ across machines and library versions Byte-for-byte identical from the same inputs
Audit re-derivation Fails; figures unsupported Passes; figures re-derive
Correct use None for money All money, balances, rates, and factors

Gotcha: Decimal Built From a Float Literal Link to this section

The most common way a rewrite still drifts is constructing a Decimal from a float instead of a str. Decimal(0.06) captures the binary approximation of 0.06 and carries roughly seventeen junk digits into the context; Decimal("0.06") is exactly six hundredths.

from decimal import Decimal

Decimal(0.06)      # Decimal('0.059999999999999997779553950749686919152736663818359375')
Decimal("0.06")    # Decimal('0.06')  <- always build money from a string

The failure hides because a single contaminated rate looks fine for a few periods and only fails the terminal assert after drift accumulates. Guard against it at the engine boundary: reject float inputs so contamination raises a TypeError at ingestion rather than surfacing as a broken schedule weeks later. Upstream, a json.load yields float and a pandas column defaults to float64, so cast to Decimal(str(value)) at the seam where external data enters the engine.

Frequently Asked Questions Link to this section

Isn't rounding each period to the cent enough to keep a float schedule exact?

No. Rounding with round(x, 2) in float still stores the result as a binary fraction that is only approximately the rounded cent, so the next multiplication and subtraction reintroduce error. Rounding controls how large each step's error is but cannot make 0.01 representable in binary, so a long schedule still drifts. Only exact decimal arithmetic removes the representation error at its source; rounding then bounds the deliberate cent loss on top of an already-exact value.

Why not just compare the terminal balance against a small tolerance instead of zero?

Because a tolerance hides the very defect it is meant to tolerate. An auditor re-deriving the schedule expects the published figures exactly, not within a band, and a tolerance would also mask a genuine logic error — an interest accrued on the wrong balance, say — as long as the drift stayed under the threshold. The terminal adjustment is cheap and deterministic, so there is no reason to accept a nonzero close; assert equality with Decimal("0.00") and keep the check as a hard gate.

Does the terminal adjustment distort the final period's interest expense?

Only by the accumulated rounding residual, which is bounded by half a cent times the number of periods and is immaterial by construction. The adjustment reassigns at most a few cents from interest to principal (or the reverse) in the final row so the balance lands on zero; the periodic rate and every prior period are untouched. This is the standard, expected treatment — absorbing residual rounding into the last period is how the effective-interest method is made to close exactly.