A Regulation Z Appendix J APR solver in Python, run against my three hand-computed APRs and the $545 fee estimate
- Status
- SUCCEEDED
- Started
- Finished
- Sessions
- 1
Goal
I have now published two APR guides with hand arithmetic only. I wrote that the next one would ship only after a solver had run, and this project keeps that promise. Question: do my hand results hold up when a program solves the Appendix J actuarial equation, with unit periods, odd-day fractions and fees treated as the rule specifies? The three results are the 6.563% fee loan ($97,500 financed, $1,110.21 x 120), the 45-day first period (I expect about 6.5024%), the 12-month 18% short loan, plus my first-order claim that about $545 of fee error moves a 10-year 6.5% APR by 1/8 point. Readers get a short, commented script they can run themselves, its real output to 4 decimals, and a table of each hand value, the solver value and the gap. Any hand value that is wrong gets a printed correction.
Plan
1. No download is needed. All inputs come from my two published posts: amount financed, payment, number of payments, first-period length, fee amounts. I will transcribe them into a single inputs.py file and quote each value's source post in a comment. The $162,000 CFPB H-25(B) sample is out of scope here unless I can find its full payment stream already quoted in my earlier post. The regulation text hosts are not on the Lab allowlist, so I will not reconstruct that sample from memory. 2. Write apr_appj.py, which follows the Appendix J general equation. Unit period = month. Odd days in the first period become a fraction of a unit period (days/30), and the period count is integer unit periods plus that fraction. The script solves for the periodic rate with scipy.optimize.brentq on a bracket [1e-9, 1] and annualizes it by multiplying by 12. To cross-check, it solves the same equation with Newton's method in mpmath/sympy at 50 digits. 3. Self-tests run before any real case: (a) a no-fee loan whose payment comes from a known note rate must return that rate to within 1e-10; (b) brentq and the 50-digit Newton must agree to within 1e-8 points; (c) a 30-day first period must reproduce the regular-period case exactly. If any self-test fails, I stop and write up the failure. 4. Run the three cases. Print APR to 4 decimals, my hand value, and the gap in percentage points as a text table. 5. Fee sensitivity: for a 10-year 6.5% loan at several amounts ($50k, $97.5k, $200k, $400k), use brentq to find the fee change in dollars that moves APR by exactly 0.125 points. Compare the result with my $545 first-order estimate and with @minh's $7.8 figure for the small fee loan, and state which loan each number belongs to. Output: a table and one matplotlib plot of fee-dollars-per-1/8-point against loan amount. 6. Odd-period sensitivity: sweep first-period length from 15 to 60 days and plot the APR change. This checks my claim that a 15-day odd period moves APR by about 0.06 points. 7. Success: every self-test passes, and the solver values for the three cases fall within 0.0005 points of my hand values. Failure, which I will publish as a correction: any gap above 0.0005 points, or a fee-per-1/8-point figure more than 10% from $545 for the loan I computed it on. Every tolerance in the post will carry its rule and scope (§ 1026.22 for closed-end APR accuracy, § 1026.38(o)(2) only for Closing Disclosure loans).
Summary
I wrote a Python solver for the Appendix J equation. It passed all three self-tests, and then I ran it against every hand value from my two APR posts. All five hand APRs held within 0.0005 points. The $545 fee estimate was within 0.5%, and @minh's $7.8 figure was within about 1%. One claim needs a printed correction: the 12-month 18% case. My hand figure of "about 2.0 points" should be 1.81 points (solver: -1.8072), and the post's wording "a first period 21 days long" should say "21 days longer".
Outputs

Solver result: fee dollars that move a 10-year APR by +1/8 point, for 6% notes with a 2.5% fee. $543.31 for the $100k note vs my hand $545; about $5.43 per $1,000 of note. 
APR change vs first-period length (15 to 60 days): Appendix J solver vs my first-order duration rule, for the 10-year fee loan and the 12-month 18% loan. - Download Data behind the fee plot: note amount and the fee change in dollars that moves APR by +0.125 points (6% note, 2.5% fee, 120 months).
Resulting post
I Ran My Loan Math Through Code. Five Answers Held, One Was 12% Off.
I wrote a Python solver for the federal APR equation and ran it against every hand result in my two loan posts. Here is the script, its real output and one correction.
Step log
1. No download is needed. All inputs come from my two published posts: amount financed, payment, number of payments, first-period length, fee amounts. I will transcribe them into a single inputs.py file and quote each value's source post in a comment. The $162,000 CFPB H-25(B) sample is out of scope here unless I can find its full payment stream already quoted in my earlier post. The regulation text hosts are not on the Lab allowlist, so I will not reconstruct that sample from memory. 2. Write apr_appj.py, which follows the Appendix J general equation. Unit period = month. Odd days in the first period become a fraction of a unit period (days/30), and the period count is integer unit periods plus that fraction. The script solves for the periodic rate with scipy.optimize.brentq on a bracket [1e-9, 1] and annualizes it by multiplying by 12. To cross-check, it solves the same equation with Newton's method in mpmath/sympy at 50 digits. 3. Self-tests run before any real case: (a) a no-fee loan whose payment comes from a known note rate must return that rate to within 1e-10; (b) brentq and the 50-digit Newton must agree to within 1e-8 points; (c) a 30-day first period must reproduce the regular-period case exactly. If any self-test fails, I stop and write up the failure. 4. Run the three cases. Print APR to 4 decimals, my hand value, and the gap in percentage points as a text table. 5. Fee sensitivity: for a 10-year 6.5% loan at several amounts ($50k, $97.5k, $200k, $400k), use brentq to find the fee change in dollars that moves APR by exactly 0.125 points. Compare the result with my $545 first-order estimate and with @minh's $7.8 figure for the small fee loan, and state which loan each number belongs to. Output: a table and one matplotlib plot of fee-dollars-per-1/8-point against loan amount. 6. Odd-period sensitivity: sweep first-period length from 15 to 60 days and plot the APR change. This checks my claim that a 15-day odd period moves APR by about 0.06 points. 7. Success: every self-test passes, and the solver values for the three cases fall within 0.0005 points of my hand values. Failure, which I will publish as a correction: any gap above 0.0005 points, or a fee-per-1/8-point figure more than 10% from $545 for the loan I computed it on. Every tolerance in the post will carry its rule and scope (§ 1026.22 for closed-end APR accuracy, § 1026.38(o)(2) only for Closing Disclosure loans).
Step 1 decision: transcribed inputs by fetching my two published posts with WebFetch (summaries, then a second verbatim-quote fetch). Found: the 12-month 18% case in post 2 has no amount, payment or fee, only "D'≈6.23 months (a=10.9075, a'=-67.95 at 1.5%/month)" and "a first period 21 days long, f=0.7, moves APR by about 0.7×18/6.23≈2.0 points". Ambiguity: "21 days long" could mean a 21-day first period (shift -9 days) or 21 extra days (51-day period, shift +0.7 unit periods, which is what the formula uses). I will run both with $1,000 principal, no fee (APR is scale-free with no fee). Also adding the post-1 cases (19.49% fee loan, Fed loans 11.9995% and 13.257%) since they are hand values too.
$ cd /work && python - <<'EOF' import re s=open('apr_appj.py').read() s=s.replace('''def pv_gap(i, A, flows, f): """Present value of payments minus amount financed (with odd-day factor).""" v = 1.0 / (1.0 + i) s, d = 0.0, 1.0 for p in flows: d *= v s += p * d return s - A * (1.0 + f * i) def apr(A, pays, f=0.0): flows = expand(pays) i = brentq(pv_gap, 1e-9, 1.0, args=(A, flows, f), xtol=1e-15, rtol=1e-15, maxiter=500) return 1200.0 * i # percent ''','''def split_days(first_days): """First-period length in days -> (q whole unit periods, fraction f). Monthly unit period, 30 days per month for the odd remainder.""" q, r = divmod(first_days, 30) return int(q), r / 30.0 def pv_gap(i, A, flows, f, q=1): """Present value of payments minus amount financed (with odd-day factor). Payment k (k=0..n-1) sits at q+k whole periods plus fraction f.""" s = sum(p * (1.0 + i) ** -(q + k) for k, p in enumerate(flows)) return s - A * (1.0 + f * i)Show 55 more lines
def apr(A, pays, f=0.0, q=1): flows = expand(pays) i = brentq(pv_gap, 1e-9, 1.0, args=(A, flows, f, q), xtol=1e-15, rtol=1e-15, maxiter=500) return 1200.0 * i # percent def apr_days(A, pays, first_days): q, f = split_days(first_days) return apr(A, pays, f, q) ''') s=s.replace('''def apr_mp(A, pays, f=0.0, dps=50):''','''def apr_mp(A, pays, f=0.0, dps=50, q=1):''') s=s.replace("p / (1 + i) ** (k + 1)","p / (1 + i) ** (k + q)") s=s.replace('''sum_k P_k / (1+i)^k (k = 1..n)''','''sum_k P_k / (1+i)^(q+k-1) (k = 1..n) q = whole unit periods in the first period (1 for a regular month).''') open('apr_appj.py','w').write(s) EOF cat > selftest.py <<'EOF' """Self-tests; stop if any fails.""" from apr_appj import apr, apr_mp, apr_days, payment ok = True # (a) no-fee loan with exact payment from a note rate returns that rate for L, r, n in [(100_000, 6.0, 120), (1_000, 18.0, 12), (5_000, 12.0, 36), (250_000, 7.125, 360)]: P = payment(L, r, n) got = apr(L, [(P, n)]) err = abs(got - r) print(f"(a) L={L:>7} note={r:6.3f}% n={n:3d} APR={got:.12f} |err|={err:.2e}") ok &= err < 1e-10 # (b) brentq vs 50-digit Newton for A, pays, f in [(97_500, [(1110.21, 120)], 0.0), (97_500, [(1110.21, 120)], 0.5), (4_500, [(166.07, 36)], 0.0), (4_500, [(152.18, 35), (152.22, 1)], 0.0)]: b = apr(A, pays, f); m = float(apr_mp(A, pays, f)) print(f"(b) A={A:>6} f={f} brentq={b:.12f} mp50={m:.12f} diff={abs(b-m):.2e}") ok &= abs(b - m) < 1e-8 # (c) 30-day first period == regular period r1 = apr(97_500, [(1110.21, 120)]); r2 = apr_days(97_500, [(1110.21, 120)], 30) print(f"(c) regular={r1:.12f} 30-day={r2:.12f} diff={abs(r1-r2):.2e}") ok &= r1 == r2 print("ALL SELF-TESTS PASS" if ok else "SELF-TEST FAILURE") EOF python -c "import scipy,mpmath,numpy,sys;print(sys.version.split()[0],scipy.__version__,mpmath.__version__,numpy.__version__)" python selftest.py | tee out_selftest.txt exit 0 after 2.2 s 3.13.16 1.18.1 1.3.0 2.5.3 (a) L= 100000 note= 6.000% n=120 APR=6.000000000000 |err|=2.40e-13 (a) L= 1000 note=18.000% n= 12 APR=18.000000000001 |err|=1.25e-12 (a) L= 5000 note=12.000% n= 36 APR=12.000000000000 |err|=0.00e+00 (a) L= 250000 note= 7.125% n=360 APR=7.125000000000 |err|=4.71e-14 (b) A= 97500 f=0.0 brentq=6.562785249454 mp50=6.562785249454 diff=6.22e-14 (b) A= 97500 f=0.5 brentq=6.502367175490 mp50=6.502367175490 diff=4.53e-14 (b) A= 4500 f=0.0 brentq=19.490412646584 mp50=19.490412646584 diff=4.26e-14 (b) A= 4500 f=0.0 brentq=13.257223161212 mp50=13.257223161212 diff=8.35e-14 (c) regular=6.562785249454 30-day=6.562785249454 diff=0.00e+00 ALL SELF-TESTS PASS$ cd /work && python run_cases.py | tee out_cases.txt exit 0 after 1.1 s case solver hand gap(pts) pass<=0.0005 P2 fee loan, regular period 6.5628 6.5628 -0.0000 True P2 fee loan, 45-day 1st period 6.5024 6.5024 -0.0000 True P1 $500-fee loan 19.4904 19.4900 +0.0004 True P1 Fed loan 1 11.9994 11.9995 -0.0001 True P1 Fed loan 2 13.2572 13.2570 +0.0002 True P2 45-day shift: solver -0.0604 pts, hand -0.061 12-month 18%: payment 91.6800, regular APR 18.0000 51-day first period (21 extra days): APR 16.1928, shift -1.8072 pts 21-day first period (9 days short): APR 18.8991, shift +0.8991 pts hand claim: about 2.0 pts
$ cd /work && python fee_sens.py | tee out_fee.txt exit 0 after 2.6 s Loans built like the P2 example: 6% note, fee 2.5% of note, 120 monthly payments note payment APR +1/8 fee $ -1/8 fee $ per $1k note 50000 555.10 6.5626 271.66 273.83 5.433 100000 1110.21 6.5628 543.31 547.65 5.433 200000 2220.41 6.5627 1086.62 1095.30 5.433 400000 4440.82 6.5627 2173.25 2190.60 5.433 P2 loan ($97,500, 120 x $1,110.21): +1/8 needs $543.31 more fee, -1/8 needs $547.65 less; hand $545 -> gap -0.31% / +0.49% a(i)=87.82122 a'(i)=-4716.71 (hand: 87.82122, -4716.6) P1 loan ($4,500, 36 x $166.07): +1/8 needs $7.73, -1/8 needs $7.75; @minh: $7.8 12m 18%: a=10.9075 a'=-67.95 (hand 10.9075, -67.95)$ cd /work && python odd_sweep.py | tee out_odd.txt exit 0 after 2.2 s P2 fee loan, 10 yr ($97,500, 120 x $1,110.21) 15 days: solver +0.0615 pts first-order rule +0.0611 pts rule error -0.0003 21 days: solver +0.0367 pts first-order rule +0.0367 pts rule error -0.0000 45 days: solver -0.0604 pts first-order rule -0.0611 pts rule error -0.0007 51 days: solver -0.0842 pts first-order rule -0.0856 pts rule error -0.0013 60 days: solver -0.1195 pts first-order rule -0.1222 pts rule error -0.0027 12-month 18% loan, no fee 15 days: solver +1.5534 pts first-order rule +1.4446 pts rule error -0.1088 21 days: solver +0.8991 pts first-order rule +0.8668 pts rule error -0.0323 45 days: solver -1.3312 pts first-order rule -1.4446 pts rule error -0.1134 51 days: solver -1.8072 pts first-order rule -2.0225 pts rule error -0.2152 60 days: solver -2.4699 pts first-order rule -2.8892 pts rule error -0.4194
$ cd /work && ls -la && python -c " import matplotlib.image as m;print(m.imread('odd_period.png').shape, m.imread('fee_per_eighth.png').shape)" exit 0 after 1.0 s total 260 drwxr-x--- 2 sandbox sandbox 4096 Oct 3 18:16 . drwxr-xr-x 1 root root 4096 Oct 3 18:16 .. -rw-r--r-- 1 sandbox sandbox 1998 Oct 3 18:15 apr_appj.py -rw-r--r-- 1 sandbox sandbox 575 Oct 3 18:16 fee_per_eighth.csv -rw-r--r-- 1 sandbox sandbox 80570 Oct 3 18:16 fee_per_eighth.png -rw-r--r-- 1 sandbox sandbox 2944 Oct 3 18:16 fee_sens.py -rw-r--r-- 1 sandbox sandbox 1987 Oct 3 18:15 inputs.py -rw-r--r-- 1 sandbox sandbox 129140 Oct 3 18:16 odd_period.png -rw-r--r-- 1 sandbox sandbox 1478 Oct 3 18:16 odd_sweep.py -rw-r--r-- 1 sandbox sandbox 738 Oct 3 18:15 out_cases.txt -rw-r--r-- 1 sandbox sandbox 733 Oct 3 18:16 out_fee.txt -rw-r--r-- 1 sandbox sandbox 892 Oct 3 18:16 out_odd.txt -rw-r--r-- 1 sandbox sandbox 679 Oct 3 18:15 out_selftest.txt -rw-r--r-- 1 sandbox sandbox 1379 Oct 3 18:15 run_cases.py -rw-r--r-- 1 sandbox sandbox 1102 Oct 3 18:15 selftest.py (675, 1650, 4) (675, 1050, 4)Results (all from real runs, Python 3.13.16, scipy 1.18.1, mpmath 1.3.0, numpy 2.5.3). Self-tests pass: no-fee loans recover note rate within 1.25e-12; brentq vs 50-digit Newton within 8.4e-14 points; 30-day first period equals regular case exactly. Hand APRs hold: 6.5628 (gap 0.0000), 45-day 6.5024 (0.0000), $500-fee loan 19.4904 vs 19.49, Fed loans 11.9994 vs 11.9995 and 13.2572 vs 13.257. $545 hand estimate: solver $543.31 (+1/8) and $547.65 (-1/8), within 0.5%. @minh's $7.8 for the $4,500 loan: solver $7.73/$7.75. The 15 extra days: solver -0.0604 vs hand -0.061. CORRECTION NEEDED: 12-month 18% case. Hand claim "about 2.0 points" for f=0.7; solver gives -1.8072 points for a 51-day first period (21 days longer), so hand overstates by about 0.22 points (12%); the first-order duration rule is too coarse at 18% and 0.7 periods. Also the post's wording "a first period 21 days long" is wrong: a 21-day first period moves APR UP by 0.8991 points. The formula only matches "21 days longer".
attached image /media/2026/10/4e208c7635ac3606ff68d9dc9047fa431ca21c660ade9e4a6df259b8f171469d.png (78.7 KB): Solver result: fee dollars that move a 10-year APR by +1/8 point, for 6% notes with a 2.5% fee. $543.31 for the $100k note vs my hand $545; about $5.43 per $1,000 of note.
attached image /media/2026/10/441d8f3c96d4a68ebc89d4553afcde761cf3a9c499e340cd6bd70232c8140ae4.png (126.1 KB): APR change vs first-period length (15 to 60 days): Appendix J solver vs my first-order duration rule, for the 10-year fee loan and the 12-month 18% loan.
attached data /media/2026/10/e281dae4e28d750232096ded4bb38231658cd226efa820dac1764b8706277081.csv (0.6 KB): Data behind the fee plot: note amount and the fee change in dollars that moves APR by +0.125 points (6% note, 2.5% fee, 120 months).
Session 1 ended: succeeded Summary: I wrote a Python solver for the Appendix J equation. It passed all three self-tests, and then I ran it against every hand value from my two APR posts. All five hand APRs held within 0.0005 points. The $545 fee estimate was within 0.5%, and @minh's $7.8 figure was within about 1%. One claim needs a printed correction: the 12-month 18% case. My hand figure of "about 2.0 points" should be 1.81 points (solver: -1.8072), and the post's wording "a first period 21 days long" should say "21 days longer". Findings: Tested with: Python 3.13.16, scipy 1.18.1, mpmath 1.3.0, numpy 2.5.3 (Lab sandbox, 2026-10-03). ## Setup - `inputs.py` holds every input, transcribed from my two published posts by fetching them on 2026-10-03. A comment on each value names its source post. - `apr_appj.py` solves $$A(1+f\,i)=\sum_k P_k(1+i)^{-(q+k-1)}$$ with a monthly unit period. Here q is the number of whole periods in the first period and f is the leftover days divided by 30. APR is 12i. The main solver is scipy `brentq` on [1e-9, 1]. As a cross-check, the same equation is also solved by mpmath Newton at 50 digits. - Simplification: the solver turns days into a fraction with days/30. Appendix J counts whole calendar months back from the payment date first. For the cases here, which are stated in days, I used 30-day months. ## Self-tests (all pass) - (a) No-fee loans built from a note rate give that rate back to within 1.25e-12 points (4 loans, from 12 to 360 months). - (b) brentq and the 50-digit Newton agree to within 8.4e-14 points (4 cases). - (c) A 30-day first period gives exactly the regular-period APR (difference 0). ## Solver vs hand values | case | solver | hand | gap (pts) | |---|---|---|---| | Fee loan, $97,500, 120 x $1,110.21 | 6.5628 | 6.5628 | -0.0000 | | Same, 45-day first period (f=0.5) | 6.5024 | 6.5024 | -0.0000 | | $500-fee loan, $4,500, 36 x $166.07 | 19.4904 | 19.49 | +0.0004 | | Fed loan 1, $5,000, 36 x $166.07 | 11.9994 | 11.9995 | -0.0001 | | Fed loan 2, $4,500, 35 x $152.18 + $152.22 | 13.2572 | 13.257 | +0.0002 | My hand slope checks out: a(i)=87.82122 (hand 87.82122) and a'(i)=-4716.71 (hand -4716.6). ## Fee sensitivity (fee error equal to 1/8 point) - The $97,500 loan from post 2: **$543.31** of extra fee raises APR by 0.125, and **$547.65** less fee lowers it by 0.125. My hand estimate was $545, so the gaps are -0.31% and +0.49%. It holds.Show 38 more lines
- The $4,500 loan from post 1: $7.73 up and $7.75 down. @minh's $7.8 holds. That figure belongs to the small loan only. - The amount scales with loan size, at about $5.43 per $1,000 of note for 6% notes with a 2.5% fee over 10 years. That gives $271.66 at $50k, $1,086.62 at $200k and $2,173.25 at $400k.  [Data behind the fee plot: note amount and the fee change in dollars that moves APR by +0.125 points (6% note, 2.5% fee, 120 months).](/media/2026/10/e281dae4e28d750232096ded4bb38231658cd226efa820dac1764b8706277081.csv) ## Odd first period - 10-year fee loan, with my rule ΔAPR ≈ shift × APR / D' and D'=53.7: - 45 days: solver -0.0604, rule -0.0611. - 15 days: solver +0.0615, rule +0.0611. - 60 days: solver -0.1195, rule -0.1222. - The rule's error stays at or below 0.003 points across 15 to 60 days. - 12-month 18% loan, no fee (payment $91.68 per $1,000). My hand inputs check out (a=10.9075, a'=-67.95). - **Correction:** a first period 21 days *longer* (51 days) moves APR by **-1.8072 points**, not "about 2.0". The first-order rule overstates the move by 0.22 points (12%) at this rate and shift. - **Wording correction:** a first period 21 days *long* (9 days short of a month) moves APR by **+0.8991 points**, in the opposite direction. - The rule's error grows to 0.42 points at 60 days.  ## Verdict against the success criteria - All self-tests pass. - All three named cases, and the two extra cases, fall within 0.0005 points. - The $545 figure is within 10% (it is within 0.5%). - The one failure is the 12-month 18% claim of "about 2.0 points". It was a first-order estimate, and the solver gives 1.81 points. I will print it as a correction. Tolerance labels for the write-up: - 1/8 point (regular) and 1/4 point (irregular), under § 1026.22, which covers closed-end APR accuracy. - § 1026.38(o)(2), which applies only to loans that get a Closing Disclosure. I did not re-read the primary text of either section in this session. I must read it before publishing. ## Files in /work inputs.py, apr_appj.py, selftest.py, run_cases.py, fee_sens.py, odd_sweep.py, out_*.txt (raw outputs), fee_per_eighth.png/.csv, odd_period.png Next steps: The project goal is met. Before publishing the post: 1. Read the primary text of § 1026.22(a)(2) and (a)(3), and of § 1026.38(o)(2). Label each tolerance with its scope. 2. Print the 12-month 18% correction on post 2: 1.81 points, not 2.0, and "21 days longer", not "21 days long".