Created
November 28, 2025 19:18
-
-
Save jt-hill/839e1a6c51d28cbcb8fc69e6143193eb to your computer and use it in GitHub Desktop.
Decimal vs float precisoin and speed tests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Decimal vs Float64: Does it matter for financial calculations? | |
| """ | |
| from decimal import Decimal | |
| import time | |
| # TEST 1: Single 30-year mortgage | |
| print("TEST 1: Single 30-year mortgage (360 payments)") | |
| print("-" * 50) | |
| principal = 300_000 | |
| rate = 0.06 | |
| periods = 360 | |
| # Decimal version | |
| p_d = Decimal("300000") | |
| r_d = Decimal("0.06") / 12 | |
| pmt_d = p_d * (r_d * (1 + r_d) ** 360) / ((1 + r_d) ** 360 - 1) | |
| bal_d = p_d | |
| for _ in range(360): | |
| bal_d = bal_d * (1 + r_d) - pmt_d | |
| # Float version | |
| r_f = 0.06 / 12 | |
| pmt_f = principal * (r_f * (1 + r_f) ** 360) / ((1 + r_f) ** 360 - 1) | |
| bal_f = float(principal) | |
| for _ in range(360): | |
| bal_f = bal_f * (1 + r_f) - pmt_f | |
| print(f"Final balance (Decimal): ${float(bal_d):.20f}") | |
| print(f"Final balance (Float64): ${bal_f:.20f}") | |
| print(f"Difference: ${abs(float(bal_d) - bal_f):.20f}") | |
| print(f"Match when rounded to cents: {round(float(bal_d), 2) == round(bal_f, 2)}") | |
| # TEST 2: Total interest paid across 1000 loan portfolio | |
| print("\nTEST 2: Total interest paid (1000 loans, full amortization)") | |
| print("-" * 50) | |
| import random | |
| random.seed(42) | |
| total_interest_d = Decimal("0") | |
| total_interest_f = 0.0 | |
| for _ in range(1000): | |
| principal = random.randint(100_000, 500_000) | |
| rate = random.uniform(0.04, 0.08) | |
| # Decimal | |
| p_d = Decimal(str(principal)) | |
| r_d = Decimal(str(rate)) / 12 | |
| pmt_d = p_d * (r_d * (1 + r_d) ** 360) / ((1 + r_d) ** 360 - 1) | |
| bal_d = p_d | |
| for _ in range(360): | |
| interest_d = bal_d * r_d | |
| total_interest_d += interest_d | |
| bal_d = bal_d + interest_d - pmt_d | |
| # Float | |
| r_f = rate / 12 | |
| pmt_f = principal * (r_f * (1 + r_f) ** 360) / ((1 + r_f) ** 360 - 1) | |
| bal_f = float(principal) | |
| for _ in range(360): | |
| interest_f = bal_f * r_f | |
| total_interest_f += interest_f | |
| bal_f = bal_f + interest_f - pmt_f | |
| print(f"Decimal total: ${float(total_interest_d):,.20f}") | |
| print(f"Float64 total: ${total_interest_f:,.20f}") | |
| print(f"Difference: ${abs(float(total_interest_d) - total_interest_f):20f}") | |
| print( | |
| f"Match to cent: {round(float(total_interest_d), 2) == round(total_interest_f, 2)}" | |
| ) | |
| # TEST 3: Daily interest accrual over 30 years | |
| print("\nTEST 3: Daily interest accrual (30 years = 10,950 days)") | |
| print("-" * 50) | |
| principal_d = Decimal("300000.00") | |
| daily_rate_d = Decimal("0.06") / 365 | |
| principal_f = 300000.0 | |
| daily_rate_f = 0.06 / 365 | |
| balance_d = principal_d | |
| balance_f = principal_f | |
| for _ in range(10_950): # 30 years of daily compounding | |
| balance_d = balance_d * (1 + daily_rate_d) | |
| balance_f = balance_f * (1 + daily_rate_f) | |
| print(f"Decimal final: ${float(balance_d):,.2f}") | |
| print(f"Float64 final: ${balance_f:,.2f}") | |
| print(f"Difference: ${abs(float(balance_d) - balance_f):.6f}") | |
| print(f"Match to cent: {round(float(balance_d), 2) == round(balance_f, 2)}") | |
| # TEST 4: Sum of small amounts | |
| print("\nTEST 4: Sum of 1,000,000 × $0.01") | |
| print("-" * 50) | |
| d_sum = sum(Decimal("0.01") for _ in range(1_000_000)) | |
| f_sum = sum(0.01 for _ in range(1_000_000)) | |
| print(f"Decimal: ${float(d_sum):,.2f}") | |
| print(f"Float64: ${f_sum:,.6f}") | |
| print(f"Difference: ${abs(float(d_sum) - f_sum):.10f}") | |
| print(f"Match to cent: {round(float(d_sum), 2) == round(f_sum, 2)}") | |
| # TEST 5: Performance | |
| print("\nTEST 5: Performance (1000 loan amortizations)") | |
| print("-" * 50) | |
| def amortize_decimal(): | |
| p = Decimal("300000") | |
| r = Decimal("0.06") / 12 | |
| pmt = p * (r * (1 + r) ** 360) / ((1 + r) ** 360 - 1) | |
| bal = p | |
| for _ in range(360): | |
| bal = bal * (1 + r) - pmt | |
| return bal | |
| def amortize_float(): | |
| p = 300000.0 | |
| r = 0.06 / 12 | |
| pmt = p * (r * (1 + r) ** 360) / ((1 + r) ** 360 - 1) | |
| bal = p | |
| for _ in range(360): | |
| bal = bal * (1 + r) - pmt | |
| return bal | |
| # Decimal | |
| start = time.perf_counter() | |
| for _ in range(1000): | |
| amortize_decimal() | |
| decimal_time = time.perf_counter() - start | |
| # Float | |
| start = time.perf_counter() | |
| for _ in range(1000): | |
| amortize_float() | |
| float_time = time.perf_counter() - start | |
| print(f"Decimal: {decimal_time * 1000:.0f}ms") | |
| print(f"Float64: {float_time * 1000:.0f}ms") | |
| print(f"Speedup: {decimal_time / float_time:.1f}x") | |
| # TEST 6: Pandas aggregation | |
| print("\nTEST 6: Pandas aggregation (100k rows)") | |
| print("-" * 50) | |
| import pandas as pd | |
| import numpy as np | |
| data = [Decimal(str(i * 0.01)) for i in range(100_000)] | |
| df_obj = pd.DataFrame({"amt": data}) # object dtype | |
| df_f64 = pd.DataFrame({"amt": np.array([float(d) for d in data])}) | |
| start = time.perf_counter() | |
| for _ in range(100): | |
| df_obj["amt"].sum() | |
| obj_time = time.perf_counter() - start | |
| start = time.perf_counter() | |
| for _ in range(100): | |
| df_f64["amt"].sum() | |
| f64_time = time.perf_counter() - start | |
| print(f"Decimal (object dtype): {obj_time * 1000:.0f}ms") | |
| print(f"Float64 (native dtype): {f64_time * 1000:.0f}ms") | |
| print(f"Speedup: {obj_time / f64_time:.0f}x") | |
| # TEST 7: scipy.optimize | |
| print("\nTEST 7: scipy.optimize compatibility") | |
| print("-" * 50) | |
| from scipy.optimize import newton | |
| cfs = [-100000, 30000, 35000, 40000, 45000] | |
| # Float works | |
| irr = newton(lambda r: sum(cf / (1 + r) ** i for i, cf in enumerate(cfs)), 0.1) | |
| print(f"Float64 IRR: {irr:.4%} ✓") | |
| # Decimal fails | |
| cfs_d = [Decimal(str(cf)) for cf in cfs] | |
| try: | |
| newton( | |
| lambda r: sum(cf / (1 + Decimal(str(r))) ** i for i, cf in enumerate(cfs_d)), | |
| Decimal("0.1"), | |
| ) | |
| print("Decimal IRR: worked (unexpected)") | |
| except TypeError as e: | |
| print(f"Decimal IRR: TypeError ✗") | |
| print(f" '{str(e)[:60]}...'") | |
| # TEST 8: Silent Decimal-to-float conversion (pyxirr and numpy-financial) | |
| print("\nTEST 8: Silent Decimal-to-float conversions") | |
| print("-" * 50) | |
| # PyXIRR | |
| from pyxirr import xirr | |
| from datetime import date | |
| dates = [date(2020, 1, 1), date(2021, 1, 1), date(2022, 1, 1)] | |
| amounts_decimal = [Decimal("-1000.00"), Decimal("500.00"), Decimal("600.00")] | |
| result = xirr(dates, amounts_decimal) | |
| print("pyxirr.xirr():") | |
| print(f" Input type: {type(amounts_decimal[0]).__name__}") | |
| print(f" Output type: {type(result).__name__}") | |
| # numpy-financial | |
| import numpy_financial as npf | |
| amounts_decimal = [Decimal("-1000.00"), Decimal("500.00"), Decimal("600.00")] | |
| result = npf.irr(amounts_decimal) | |
| print("numpy_financial.irr():") | |
| print(f" Input type: {type(amounts_decimal[0]).__name__}") | |
| print(f" Output type: {type(result).__name__}") | |
| print() | |
| print("Both accept Decimal but silently convert to float64 internally.") | |
| # TEST 9: Decimal constructor footgun | |
| print("\nTEST 9: Decimal constructor footgun") | |
| print("-" * 50) | |
| correct = Decimal("100.10") | |
| wrong = Decimal(100.10) | |
| print(f'Decimal("100.10") = {correct}') | |
| print(f"Decimal(100.10) = {wrong}") | |
| print() | |
| print("Miss the quotes and you've embedded the float error into your 'exact' Decimal.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment