A friend sent me a screenshot last month: a savings account advertising "4.50% APY" and, in smaller print, "4.40% interest rate". She wanted to know which number was the real one and why a bank would show two. Both are real. They measure different things, and the gap between them is just compounding. It's a nice little problem to model in Python because the math is short and the edge cases are where people get confused.
The two numbers
- APR (or "interest rate", "nominal rate") is the yearly rate before compounding. If the APR is 4.40% and interest is paid monthly, each month you earn 4.40% / 12 = 0.3667% of your balance.
- APY (annual percentage yield) is what you actually end up with after a year once each month's interest starts earning interest too.
The conversion is one line:
def apr_to_apy(apr: float, periods_per_year: int) -> float:
"""apr and the return value are decimals, e.g. 0.044 for 4.40%."""
return (1 + apr / periods_per_year) ** periods_per_year - 1
print(f"{apr_to_apy(0.044, 12):.4%}") # 4.4898%
print(f"{apr_to_apy(0.044, 365):.4%}") # 4.4980%
So 4.40% compounded monthly is roughly 4.49% APY, and daily compounding gets you to about 4.50%. That's exactly the pair of numbers on my friend's screenshot. The bank compounds daily and credits monthly.
Going the other way
Banks in the US are required to advertise APY, but loan documents and some brokerage cash sweeps quote APR. To compare them you need the inverse:
def apy_to_apr(apy: float, periods_per_year: int) -> float:
return periods_per_year * ((1 + apy) ** (1 / periods_per_year) - 1)
print(f"{apy_to_apr(0.045, 12):.4%}") # 4.4098%
A quick round-trip test catches sign and exponent mistakes:
import math
for apr in (0.001, 0.02, 0.05, 0.12):
for n in (1, 4, 12, 52, 365):
assert math.isclose(apy_to_apr(apr_to_apy(apr, n), n), apr, rel_tol=1e-12)
Continuous compounding is the ceiling
As the number of periods goes to infinity, (1 + r/n) ** n approaches e ** r. That gives the maximum APY any compounding schedule can squeeze out of a given APR:
def apr_to_apy_continuous(apr: float) -> float:
return math.expm1(apr) # e**apr - 1, accurate for small apr
print(f"{apr_to_apy_continuous(0.044):.4%}") # 4.4982%
Notice how small the difference between daily (4.4980%) and continuous (4.4982%) is. Once a bank compounds daily, the schedule stops mattering. The jump from annual to monthly is where most of the gain is.
I use math.expm1 instead of math.exp(apr) - 1 out of habit. For normal savings rates the difference is invisible, but for tiny rates exp(r) - 1 loses precision because you're subtracting two nearly equal numbers.
Checking a real balance month by month
Formulas are great until someone asks "but what will my balance be in March?" For that I just simulate:
from decimal import Decimal, ROUND_HALF_EVEN
def simulate(balance: str, apr: str, months: int, deposit: str = "0"):
bal = Decimal(balance)
monthly = Decimal(apr) / 12
rows = []
for m in range(1, months + 1):
interest = (bal * monthly).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
bal += interest + Decimal(deposit)
rows.append((m, interest, bal))
return rows
for m, i, b in simulate("10000", "0.044", 12, deposit="200"):
print(f"{m:>2} interest {i:>7} balance {b:>10}")
Two choices here are deliberate:
-
Decimal, notfloat. Banks round interest to the cent every period. With floats you get balances like10036.666666667, and after a year of deposits the cents drift away from your statement. - Rounding each month. Real interest is rounded when it's credited, so the simulated balance can differ from the closed-form formula by a few cents. That's expected, not a bug.
If you just want to check a number quickly without running code, I compare my output against this APY calculator, which shows the formula next to the result. It's handy for confirming that a script and a bank statement agree before you start debugging the wrong thing.
Where people get tripped up
Mixing percent and decimal. apr_to_apy(4.4, 12) returns a meaningless huge number. I accept decimals in the functions and only format to percent at the edges. If you take user input, convert once at the boundary.
Assuming APY is guaranteed. On a variable-rate savings account the APY is the yield if the rate stays the same for a full year. If the bank cuts the rate in month four, your realized yield will be lower. For a CD the rate is locked, so APY really is what you get (unless you withdraw early and pay a penalty).
Comparing APY to a stock or fund return. A fund's "annual return" is already a compounded number, so it's closer to APY than APR. Comparing a fund's 7% to a loan's 7% APR is not apples to apples.
Forgetting fees and taxes. Interest is taxable income in most countries. A 4.5% APY at a 24% marginal tax rate is about 3.42% after tax:
def after_tax(apy: float, tax_rate: float) -> float:
return apy * (1 - tax_rate)
print(f"{after_tax(0.045, 0.24):.2%}") # 3.42%
A tiny comparison helper
To close, here's the function I actually keep around. It normalizes offers quoted in different ways so they can be sorted:
from dataclasses import dataclass
@dataclass
class Offer:
name: str
rate: float # decimal
kind: str # "apr" or "apy"
periods: int = 12
@property
def apy(self) -> float:
return self.rate if self.kind == "apy" else apr_to_apy(self.rate, self.periods)
offers = [
Offer("Bank A savings", 0.045, "apy"),
Offer("Broker cash sweep", 0.0440, "apr", 365),
Offer("Credit union CD", 0.0435, "apr", 4),
]
for o in sorted(offers, key=lambda o: o.apy, reverse=True):
print(f"{o.name:<20} {o.apy:.3%}")
Output:
Bank A savings 4.500%
Broker cash sweep 4.498%
Credit union CD 4.421%
The three offers look different on paper and turn out to be within eight basis points of each other. Which is the whole point: once everything is on the same basis, a lot of "better rate" marketing disappears.
If you've got a messier case, like tiered rates or a promo rate that expires after three months, the simulate function above is the easiest place to extend: change monthly inside the loop based on m or on the balance, and the rest stays the same.
Top comments (0)