refi-calculator 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of refi-calculator might be problematic. Click here for more details.

Files changed (35) hide show
  1. refi_calculator/__init__.py +9 -0
  2. refi_calculator/cli.py +64 -0
  3. refi_calculator/core/__init__.py +36 -0
  4. refi_calculator/core/calculations.py +713 -0
  5. refi_calculator/core/charts.py +77 -0
  6. refi_calculator/core/market/__init__.py +11 -0
  7. refi_calculator/core/market/constants.py +24 -0
  8. refi_calculator/core/market/fred.py +62 -0
  9. refi_calculator/core/models.py +131 -0
  10. refi_calculator/environment.py +124 -0
  11. refi_calculator/gui/__init__.py +13 -0
  12. refi_calculator/gui/app.py +1008 -0
  13. refi_calculator/gui/builders/__init__.py +9 -0
  14. refi_calculator/gui/builders/analysis_tab.py +92 -0
  15. refi_calculator/gui/builders/helpers.py +90 -0
  16. refi_calculator/gui/builders/info_tab.py +195 -0
  17. refi_calculator/gui/builders/main_tab.py +173 -0
  18. refi_calculator/gui/builders/market_tab.py +115 -0
  19. refi_calculator/gui/builders/options_tab.py +81 -0
  20. refi_calculator/gui/builders/visuals_tab.py +128 -0
  21. refi_calculator/gui/chart.py +459 -0
  22. refi_calculator/gui/market_chart.py +192 -0
  23. refi_calculator/web/__init__.py +11 -0
  24. refi_calculator/web/app.py +117 -0
  25. refi_calculator/web/calculator.py +317 -0
  26. refi_calculator/web/formatting.py +90 -0
  27. refi_calculator/web/info.py +226 -0
  28. refi_calculator/web/market.py +270 -0
  29. refi_calculator/web/results.py +455 -0
  30. refi_calculator/web/runner.py +22 -0
  31. refi_calculator-0.8.0.dist-info/METADATA +146 -0
  32. refi_calculator-0.8.0.dist-info/RECORD +35 -0
  33. refi_calculator-0.8.0.dist-info/WHEEL +4 -0
  34. refi_calculator-0.8.0.dist-info/entry_points.txt +4 -0
  35. refi_calculator-0.8.0.dist-info/licenses/LICENSE.txt +201 -0
@@ -0,0 +1,713 @@
1
+ """Financial calculations for refinance analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import LoanParams, RefinanceAnalysis
6
+
7
+
8
+ def calculate_accelerated_payoff(
9
+ balance: float,
10
+ rate: float,
11
+ payment: float,
12
+ ) -> tuple[int | None, float | None]:
13
+ """Calculate months to payoff and total interest when paying more than minimum.
14
+
15
+ Args:
16
+ balance: Loan balance
17
+ rate: Annual interest rate (as a decimal)
18
+ payment: Monthly payment amount
19
+
20
+ Returns:
21
+ Tuple of (months to payoff, total interest paid)
22
+ """
23
+ if rate == 0:
24
+ months = int(balance / payment) + 1
25
+ return months, 0.0
26
+
27
+ monthly_rate = rate / 12
28
+ months = 0
29
+ total_interest = 0.0
30
+ remaining = balance
31
+
32
+ min_term_in_months = 0
33
+ max_term_in_months = 50 * 12 # Cap at 50 years
34
+ while remaining > min_term_in_months and months < max_term_in_months:
35
+ interest = remaining * monthly_rate
36
+ principal = min(payment - interest, remaining)
37
+ if principal <= 0: # Payment doesn't cover interest - would never pay off
38
+ return None, None
39
+ remaining -= principal
40
+ total_interest += interest
41
+ months += 1
42
+
43
+ return months, total_interest
44
+
45
+
46
+ def calculate_total_cost_npv(
47
+ balance: float,
48
+ rate: float,
49
+ term_years: float,
50
+ opportunity_rate: float,
51
+ payment_override: float | None = None,
52
+ ) -> float:
53
+ """Calculate NPV of total loan cost (all payments discounted to present).
54
+
55
+ Args:
56
+ balance: Loan balance
57
+ rate: Annual interest rate (as a decimal)
58
+ term_years: Loan term in years
59
+ opportunity_rate: Annual opportunity cost rate (as a decimal)
60
+ payment_override: Optional monthly payment to use instead of standard
61
+
62
+ Returns:
63
+ NPV of total loan cost
64
+ """
65
+ loan = LoanParams(
66
+ balance=balance,
67
+ rate=rate,
68
+ term_years=term_years,
69
+ )
70
+ payment = payment_override if payment_override else loan.monthly_payment
71
+ monthly_opp_rate = opportunity_rate / 12
72
+
73
+ if payment_override and payment_override > loan.monthly_payment:
74
+ # Accelerated payoff
75
+ months, _ = calculate_accelerated_payoff(
76
+ balance=balance,
77
+ rate=rate,
78
+ payment=payment_override,
79
+ )
80
+ if months is None:
81
+ months = loan.num_payments
82
+ else:
83
+ months = loan.num_payments
84
+
85
+ npv = 0.0
86
+ remaining = balance
87
+ monthly_rate = rate / 12
88
+
89
+ for month in range(1, months + 1):
90
+ if remaining <= 0:
91
+ break
92
+ interest = remaining * monthly_rate
93
+ actual_payment = min(payment, remaining + interest)
94
+ principal = actual_payment - interest
95
+ remaining -= principal
96
+ npv += actual_payment / ((1 + monthly_opp_rate) ** month)
97
+
98
+ return npv
99
+
100
+
101
+ def _build_cumulative_savings(
102
+ monthly_savings: float,
103
+ closing_costs: float,
104
+ monthly_opp_rate: float,
105
+ chart_months: int,
106
+ schedule_months: int,
107
+ ) -> tuple[list[tuple[int, float, float]], int | None]:
108
+ """Build cumulative savings timeline for charting.
109
+
110
+ Args:
111
+ monthly_savings: Monthly savings amount
112
+ closing_costs: Closing costs for refinance
113
+ monthly_opp_rate: Monthly opportunity cost rate (as a decimal)
114
+ chart_months: Number of months to show on chart
115
+ schedule_months: Total months in loan schedule
116
+
117
+ Returns:
118
+ Tuple of (list of (month, nominal savings, NPV savings), NPV breakeven month)
119
+ """
120
+ cumulative_savings: list[tuple[int, float, float]] = [(0, -closing_costs, -closing_costs)]
121
+ cum_pv = 0.0
122
+ cum_nominal = -closing_costs
123
+ npv_breakeven: int | None = None
124
+
125
+ for month in range(1, min(chart_months, schedule_months) + 1):
126
+ cum_pv += monthly_savings / ((1 + monthly_opp_rate) ** month)
127
+ cum_nominal += monthly_savings
128
+ cumulative_savings.append((month, cum_nominal, cum_pv - closing_costs))
129
+ if npv_breakeven is None and cum_pv >= closing_costs:
130
+ npv_breakeven = month
131
+
132
+ return cumulative_savings, npv_breakeven
133
+
134
+
135
+ def _calculate_npv_window(
136
+ monthly_savings: float,
137
+ monthly_opp_rate: float,
138
+ closing_costs: float,
139
+ window_months: int,
140
+ ) -> float:
141
+ """Calculate NPV over a fixed window.
142
+
143
+ Args:
144
+ monthly_savings: Monthly savings amount
145
+ monthly_opp_rate: Monthly opportunity cost rate (as a decimal)
146
+ closing_costs: Closing costs for refinance
147
+ window_months: Number of months in NPV window
148
+
149
+ Returns:
150
+ NPV over the specified window
151
+ """
152
+ npv = -closing_costs
153
+ for month in range(1, window_months + 1):
154
+ npv += monthly_savings / ((1 + monthly_opp_rate) ** month)
155
+ return npv
156
+
157
+
158
+ def _find_npv_breakeven(
159
+ monthly_savings: float,
160
+ monthly_opp_rate: float,
161
+ closing_costs: float,
162
+ schedule_months: int,
163
+ ) -> int | None:
164
+ """Find NPV breakeven month within a schedule.
165
+
166
+ Args:
167
+ monthly_savings: Monthly savings amount
168
+ monthly_opp_rate: Monthly opportunity cost rate (as a decimal)
169
+ closing_costs: Closing costs for refinance
170
+ schedule_months: Total months in loan schedule
171
+
172
+ Returns:
173
+ NPV breakeven month, or None if not reached within schedule
174
+ """
175
+ cum_pv = 0.0
176
+ for month in range(1, schedule_months + 1):
177
+ cum_pv += monthly_savings / ((1 + monthly_opp_rate) ** month)
178
+ if cum_pv >= closing_costs:
179
+ return month
180
+ return None
181
+
182
+
183
+ def analyze_refinance(
184
+ current_balance: float,
185
+ current_rate: float,
186
+ current_remaining_years: float,
187
+ new_rate: float,
188
+ new_term_years: float,
189
+ closing_costs: float,
190
+ opportunity_rate: float = 0.05,
191
+ npv_window_years: int = 5,
192
+ chart_horizon_years: int = 10,
193
+ marginal_tax_rate: float = 0.0,
194
+ cash_out: float = 0.0,
195
+ maintain_payment: bool = False,
196
+ ) -> RefinanceAnalysis:
197
+ """Analyze refinance scenario.
198
+
199
+ Args:
200
+ current_balance: Current loan balance
201
+ current_rate: Current loan interest rate (as a decimal)
202
+ current_remaining_years: Current loan remaining term in years
203
+ new_rate: New loan interest rate (as a decimal)
204
+ new_term_years: New loan term in years
205
+ closing_costs: Closing costs for refinance
206
+ opportunity_rate: Opportunity cost rate (as a decimal). Default is 5%.
207
+ npv_window_years: Years to calculate NPV over. Default is 5 years.
208
+ chart_horizon_years: Years to show in cumulative savings chart. Default is 10 years.
209
+ marginal_tax_rate: Marginal tax rate (as a decimal). Default is 0%.
210
+ cash_out: Cash out amount from refinance. Default is 0.
211
+ maintain_payment: Whether to maintain current payment amount. Default is False.
212
+
213
+ Returns:
214
+ RefinanceAnalysis object with results
215
+ """
216
+ current_loan = LoanParams(
217
+ balance=current_balance,
218
+ rate=current_rate,
219
+ term_years=current_remaining_years,
220
+ )
221
+
222
+ new_balance = current_balance + closing_costs + cash_out
223
+ new_loan = LoanParams(
224
+ balance=new_balance,
225
+ rate=new_rate,
226
+ term_years=new_term_years,
227
+ )
228
+
229
+ monthly_savings = current_loan.monthly_payment - new_loan.monthly_payment
230
+
231
+ simple_breakeven: float | None = None
232
+ if monthly_savings > 0:
233
+ simple_breakeven = closing_costs / monthly_savings
234
+
235
+ monthly_opp_rate = opportunity_rate / 12
236
+ chart_months = chart_horizon_years * 12
237
+ cumulative_savings, npv_breakeven = _build_cumulative_savings(
238
+ monthly_savings,
239
+ closing_costs,
240
+ monthly_opp_rate,
241
+ chart_months,
242
+ new_loan.num_payments,
243
+ )
244
+
245
+ npv_window_months = npv_window_years * 12
246
+ window_npv = _calculate_npv_window(
247
+ monthly_savings,
248
+ monthly_opp_rate,
249
+ closing_costs,
250
+ npv_window_months,
251
+ )
252
+
253
+ current_avg_monthly_interest = current_loan.total_interest / current_loan.num_payments
254
+ new_avg_monthly_interest = new_loan.total_interest / new_loan.num_payments
255
+ current_monthly_tax_benefit = current_avg_monthly_interest * marginal_tax_rate
256
+ new_monthly_tax_benefit = new_avg_monthly_interest * marginal_tax_rate
257
+
258
+ current_after_tax_payment = current_loan.monthly_payment - current_monthly_tax_benefit
259
+ new_after_tax_payment = new_loan.monthly_payment - new_monthly_tax_benefit
260
+ after_tax_monthly_savings = current_after_tax_payment - new_after_tax_payment
261
+
262
+ after_tax_simple_breakeven = None
263
+ if after_tax_monthly_savings > 0:
264
+ after_tax_simple_breakeven = closing_costs / after_tax_monthly_savings
265
+
266
+ after_tax_npv_breakeven = _find_npv_breakeven(
267
+ after_tax_monthly_savings,
268
+ monthly_opp_rate,
269
+ closing_costs,
270
+ new_loan.num_payments,
271
+ )
272
+
273
+ after_tax_npv = _calculate_npv_window(
274
+ after_tax_monthly_savings,
275
+ monthly_opp_rate,
276
+ closing_costs,
277
+ npv_window_months,
278
+ )
279
+
280
+ current_after_tax_total_interest = current_loan.total_interest * (1 - marginal_tax_rate)
281
+ new_after_tax_total_interest = new_loan.total_interest * (1 - marginal_tax_rate)
282
+
283
+ accelerated_months: int | None = None
284
+ accelerated_total_interest: float | None = None
285
+ accelerated_interest_savings: float | None = None
286
+ accelerated_time_savings_months: int | None = None
287
+
288
+ # Accelerated payoff calculations (if maintaining current payment)
289
+ if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
290
+ # User wants to keep paying the old (higher) amount
291
+ acc_months, acc_interest = calculate_accelerated_payoff(
292
+ new_balance,
293
+ new_rate,
294
+ current_loan.monthly_payment,
295
+ )
296
+ if acc_months:
297
+ accelerated_months = acc_months
298
+ accelerated_total_interest = acc_interest
299
+ if acc_interest is not None:
300
+ accelerated_interest_savings = new_loan.total_interest - acc_interest
301
+ accelerated_time_savings_months = new_loan.num_payments - acc_months
302
+
303
+ # Total cost NPV calculations
304
+ current_total_cost_npv = (
305
+ calculate_total_cost_npv(
306
+ current_balance,
307
+ current_rate,
308
+ current_remaining_years,
309
+ opportunity_rate,
310
+ )
311
+ + closing_costs
312
+ ) # Include closing costs in current scenario as sunk cost comparison
313
+
314
+ if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
315
+ new_total_cost_npv = calculate_total_cost_npv(
316
+ new_balance,
317
+ new_rate,
318
+ new_term_years,
319
+ opportunity_rate,
320
+ payment_override=current_loan.monthly_payment,
321
+ )
322
+ else:
323
+ new_total_cost_npv = calculate_total_cost_npv(
324
+ new_balance,
325
+ new_rate,
326
+ new_term_years,
327
+ opportunity_rate,
328
+ )
329
+
330
+ # Positive = refinancing is cheaper in NPV terms
331
+ total_cost_npv_advantage = current_total_cost_npv - new_total_cost_npv - closing_costs
332
+
333
+ return RefinanceAnalysis(
334
+ current_payment=current_loan.monthly_payment,
335
+ new_payment=new_loan.monthly_payment,
336
+ monthly_savings=monthly_savings,
337
+ simple_breakeven_months=simple_breakeven,
338
+ npv_breakeven_months=npv_breakeven,
339
+ current_total_interest=current_loan.total_interest,
340
+ new_total_interest=new_loan.total_interest,
341
+ interest_delta=new_loan.total_interest - current_loan.total_interest,
342
+ five_year_npv=window_npv,
343
+ cumulative_savings=cumulative_savings,
344
+ current_after_tax_payment=current_after_tax_payment,
345
+ new_after_tax_payment=new_after_tax_payment,
346
+ after_tax_monthly_savings=after_tax_monthly_savings,
347
+ after_tax_simple_breakeven_months=after_tax_simple_breakeven,
348
+ after_tax_npv_breakeven_months=after_tax_npv_breakeven,
349
+ after_tax_npv=after_tax_npv,
350
+ current_after_tax_total_interest=current_after_tax_total_interest,
351
+ new_after_tax_total_interest=new_after_tax_total_interest,
352
+ after_tax_interest_delta=new_after_tax_total_interest - current_after_tax_total_interest,
353
+ new_loan_balance=new_balance,
354
+ cash_out_amount=cash_out,
355
+ accelerated_months=accelerated_months,
356
+ accelerated_total_interest=accelerated_total_interest,
357
+ accelerated_interest_savings=accelerated_interest_savings,
358
+ accelerated_time_savings_months=accelerated_time_savings_months,
359
+ current_total_cost_npv=current_total_cost_npv,
360
+ new_total_cost_npv=new_total_cost_npv,
361
+ total_cost_npv_advantage=total_cost_npv_advantage,
362
+ )
363
+
364
+
365
+ def generate_amortization_schedule(
366
+ loan: LoanParams,
367
+ label: str,
368
+ ) -> list[dict]:
369
+ """Generate Amortization Schedule.
370
+
371
+ Args:
372
+ loan: LoanParams object
373
+ label: Label for the loan (e.g., "Current" or "New")
374
+
375
+ Returns:
376
+ List of dictionaries with amortization schedule details. Each dictionary contains:
377
+ - loan: Loan label
378
+ - month: Month number
379
+ - year: Year number
380
+ - payment: Monthly payment amount
381
+ - principal: Principal portion of payment
382
+ - interest: Interest portion of payment
383
+ - balance: Remaining balance after payment
384
+ """
385
+ schedule = []
386
+ balance = loan.balance
387
+ monthly_payment = loan.monthly_payment
388
+ monthly_rate = loan.monthly_rate
389
+
390
+ for month in range(1, loan.num_payments + 1):
391
+ interest_payment = balance * monthly_rate
392
+ principal_payment = monthly_payment - interest_payment
393
+ balance -= principal_payment
394
+ if balance < 0:
395
+ principal_payment += balance
396
+ balance = 0
397
+ schedule.append(
398
+ {
399
+ "loan": label,
400
+ "month": month,
401
+ "year": (month - 1) // 12 + 1,
402
+ "payment": monthly_payment,
403
+ "principal": principal_payment,
404
+ "interest": interest_payment,
405
+ "balance": max(0, balance),
406
+ },
407
+ )
408
+ return schedule
409
+
410
+
411
+ def generate_amortization_schedule_pair(
412
+ current_balance: float,
413
+ current_rate: float,
414
+ current_remaining_years: float,
415
+ new_rate: float,
416
+ new_term_years: float,
417
+ closing_costs: float,
418
+ cash_out: float = 0.0,
419
+ maintain_payment: bool = False,
420
+ ) -> tuple[list[dict], list[dict]]:
421
+ """Produce monthly amortization schedules for the current and new loans.
422
+
423
+ Args:
424
+ current_balance: Current loan balance.
425
+ current_rate: Current loan interest rate (as a decimal).
426
+ current_remaining_years: Remaining term of the current loan.
427
+ new_rate: Proposed refinance rate (as a decimal).
428
+ new_term_years: Term for the new loan.
429
+ closing_costs: Closing cost amount for the refinance.
430
+ cash_out: Cash out amount applied to the refinance.
431
+ maintain_payment: Whether the new loan should use the current payment.
432
+
433
+ Returns:
434
+ Tuple of (current_schedule, new_schedule), where each schedule is a list of
435
+ monthly dictionaries matching the structure produced by ``generate_amortization_schedule``.
436
+ """
437
+ current_loan = LoanParams(current_balance, current_rate, current_remaining_years)
438
+ new_balance = current_balance + closing_costs + cash_out
439
+ new_loan = LoanParams(new_balance, new_rate, new_term_years)
440
+
441
+ current_schedule = generate_amortization_schedule(current_loan, "Current")
442
+
443
+ if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
444
+ schedule: list[dict] = []
445
+ balance = new_balance
446
+ monthly_payment = current_loan.monthly_payment
447
+ monthly_rate = new_rate / 12
448
+
449
+ month = 0
450
+ max_months = 600
451
+ while balance > 0 and month < max_months:
452
+ month += 1
453
+ interest_payment = balance * monthly_rate
454
+ principal_payment = monthly_payment - interest_payment
455
+ balance -= principal_payment
456
+ if balance < 0:
457
+ principal_payment += balance
458
+ balance = 0
459
+ schedule.append(
460
+ {
461
+ "loan": "New",
462
+ "month": month,
463
+ "year": (month - 1) // 12 + 1,
464
+ "payment": monthly_payment,
465
+ "principal": principal_payment,
466
+ "interest": interest_payment,
467
+ "balance": max(0, balance),
468
+ },
469
+ )
470
+ new_schedule = schedule
471
+ else:
472
+ new_schedule = generate_amortization_schedule(new_loan, "New")
473
+
474
+ return current_schedule, new_schedule
475
+
476
+
477
+ def generate_comparison_schedule(
478
+ current_balance: float,
479
+ current_rate: float,
480
+ current_remaining_years: float,
481
+ new_rate: float,
482
+ new_term_years: float,
483
+ closing_costs: float,
484
+ cash_out: float = 0.0,
485
+ maintain_payment: bool = False,
486
+ ) -> list[dict]:
487
+ """Generate Comparison Amortization Schedule between current and new loan.
488
+
489
+ Args:
490
+ current_balance: Current loan balance
491
+ current_rate: Current loan interest rate (as a decimal)
492
+ current_remaining_years: Current loan remaining term in years
493
+ new_rate: New loan interest rate (as a decimal)
494
+ new_term_years: New loan term in years
495
+ closing_costs: Closing costs for refinance
496
+ cash_out: Cash out amount from refinance. Default is 0.
497
+ maintain_payment: Whether to maintain current payment amount. Default is False.
498
+
499
+ Returns:
500
+ List of dictionaries comparing current and new loan amortization schedules by year.
501
+ Each dictionary contains:
502
+ - year: Year number
503
+ - current_principal: Total principal paid in current loan that year
504
+ - current_interest: Total interest paid in current loan that year
505
+ - current_balance: Remaining balance on current loan at year end
506
+ - new_principal: Total principal paid in new loan that year
507
+ - new_interest: Total interest paid in new loan that year
508
+ - new_balance: Remaining balance on new loan at year end
509
+ - principal_diff: Difference in principal paid (new - current)
510
+ - interest_diff: Difference in interest paid (new - current)
511
+ - balance_diff: Difference in balance (new - current)
512
+ """
513
+ current_schedule, new_schedule = generate_amortization_schedule_pair(
514
+ current_balance=current_balance,
515
+ current_rate=current_rate,
516
+ current_remaining_years=current_remaining_years,
517
+ new_rate=new_rate,
518
+ new_term_years=new_term_years,
519
+ closing_costs=closing_costs,
520
+ cash_out=cash_out,
521
+ maintain_payment=maintain_payment,
522
+ )
523
+
524
+ # Determine the number of years to show based on the actual schedules
525
+ max_years = max(
526
+ max((s["year"] for s in current_schedule), default=0),
527
+ max((s["year"] for s in new_schedule), default=0),
528
+ )
529
+ comparison = []
530
+
531
+ for year in range(1, max_years + 1):
532
+ current_year_data = [s for s in current_schedule if s["year"] == year]
533
+ new_year_data = [s for s in new_schedule if s["year"] == year]
534
+
535
+ current_principal = sum(s["principal"] for s in current_year_data)
536
+ current_interest = sum(s["interest"] for s in current_year_data)
537
+ current_end_balance = current_year_data[-1]["balance"] if current_year_data else 0
538
+
539
+ new_principal = sum(s["principal"] for s in new_year_data)
540
+ new_interest = sum(s["interest"] for s in new_year_data)
541
+ new_end_balance = new_year_data[-1]["balance"] if new_year_data else 0
542
+
543
+ comparison.append(
544
+ {
545
+ "year": year,
546
+ "current_principal": current_principal,
547
+ "current_interest": current_interest,
548
+ "current_balance": current_end_balance,
549
+ "new_principal": new_principal,
550
+ "new_interest": new_interest,
551
+ "new_balance": new_end_balance,
552
+ "principal_diff": new_principal - current_principal,
553
+ "interest_diff": new_interest - current_interest,
554
+ "balance_diff": new_end_balance - current_end_balance,
555
+ },
556
+ )
557
+ return comparison
558
+
559
+
560
+ def run_holding_period_analysis(
561
+ current_balance: float,
562
+ current_rate: float,
563
+ current_remaining_years: float,
564
+ new_rate: float,
565
+ new_term_years: float,
566
+ closing_costs: float,
567
+ opportunity_rate: float,
568
+ marginal_tax_rate: float,
569
+ holding_periods: list[int],
570
+ cash_out: float = 0.0,
571
+ ) -> list[dict]:
572
+ """Run holding period analysis for various time frames.
573
+
574
+ Args:
575
+ current_balance: Current loan balance
576
+ current_rate: Current loan interest rate (as a decimal)
577
+ current_remaining_years: Current loan remaining term in years
578
+ new_rate: New loan interest rate (as a decimal)
579
+ new_term_years: New loan term in years
580
+ closing_costs: Closing costs for refinance
581
+ opportunity_rate: Opportunity cost rate (as a decimal)
582
+ marginal_tax_rate: Marginal tax rate (as a decimal)
583
+ holding_periods: List of holding periods in years to analyze
584
+ cash_out: Cash out amount from refinance. Default is 0.
585
+
586
+ Returns:
587
+ List of dictionaries with analysis results for each holding period.
588
+ Each dictionary contains:
589
+ - years: Holding period in years
590
+ - nominal_savings: Nominal savings over holding period
591
+ - npv: NPV of savings over holding period
592
+ - npv_after_tax: NPV of savings after tax adjustment
593
+ - recommendation: Recommendation string based on NPV
594
+ """
595
+ results = []
596
+ current_loan = LoanParams(current_balance, current_rate, current_remaining_years)
597
+ new_balance = current_balance + closing_costs + cash_out
598
+ new_loan = LoanParams(new_balance, new_rate, new_term_years)
599
+
600
+ monthly_savings = current_loan.monthly_payment - new_loan.monthly_payment
601
+ monthly_opp_rate = opportunity_rate / 12
602
+
603
+ current_avg_monthly_interest = current_loan.total_interest / current_loan.num_payments
604
+ new_avg_monthly_interest = new_loan.total_interest / new_loan.num_payments
605
+ current_monthly_tax_benefit = current_avg_monthly_interest * marginal_tax_rate
606
+ new_monthly_tax_benefit = new_avg_monthly_interest * marginal_tax_rate
607
+ after_tax_monthly_savings = (current_loan.monthly_payment - current_monthly_tax_benefit) - (
608
+ new_loan.monthly_payment - new_monthly_tax_benefit
609
+ )
610
+
611
+ for years in holding_periods:
612
+ months = years * 12
613
+ nominal_savings = (monthly_savings * months) - closing_costs
614
+ npv = -closing_costs
615
+ for m in range(1, months + 1):
616
+ npv += monthly_savings / ((1 + monthly_opp_rate) ** m)
617
+ npv_after_tax = -closing_costs
618
+ for m in range(1, months + 1):
619
+ npv_after_tax += after_tax_monthly_savings / ((1 + monthly_opp_rate) ** m)
620
+
621
+ strong_yes_threshold = 5000
622
+ yes_threshold = 0
623
+ marginal_threshold = -2000
624
+ if npv > strong_yes_threshold:
625
+ recommendation = "Strong Yes"
626
+ elif npv > yes_threshold:
627
+ recommendation = "Yes"
628
+ elif npv > -marginal_threshold:
629
+ recommendation = "Marginal"
630
+ else:
631
+ recommendation = "No"
632
+
633
+ results.append(
634
+ {
635
+ "years": years,
636
+ "nominal_savings": nominal_savings,
637
+ "npv": npv,
638
+ "npv_after_tax": npv_after_tax,
639
+ "recommendation": recommendation,
640
+ },
641
+ )
642
+ return results
643
+
644
+
645
+ def run_sensitivity(
646
+ current_balance: float,
647
+ current_rate: float,
648
+ current_remaining_years: float,
649
+ new_term_years: float,
650
+ closing_costs: float,
651
+ opportunity_rate: float,
652
+ rate_steps: list[float],
653
+ npv_window_years: int = 5,
654
+ ) -> list[dict]:
655
+ """Run sensitivity analysis over a range of new interest rates.
656
+
657
+ Args:
658
+ current_balance: Current loan balance
659
+ current_rate: Current loan interest rate (as a decimal)
660
+ current_remaining_years: Current loan remaining term in years
661
+ new_term_years: New loan term in years
662
+ closing_costs: Closing costs for refinance
663
+ opportunity_rate: Opportunity cost rate (as a decimal)
664
+ rate_steps: List of new interest rates (as decimals) to analyze
665
+ npv_window_years: Years to calculate NPV over. Default is 5 years.
666
+
667
+ Returns:
668
+ List of dictionaries with sensitivity analysis results for each new rate.
669
+ Each dictionary contains:
670
+ - new_rate: New interest rate (as a percentage)
671
+ - monthly_savings: Monthly savings from refinancing
672
+ - simple_be: Months to simple breakeven
673
+ - npv_be: Months to NPV breakeven
674
+ - five_yr_npv: NPV of savings over 5 years
675
+ """
676
+ results = []
677
+ for new_rate in rate_steps:
678
+ a = analyze_refinance(
679
+ current_balance,
680
+ current_rate,
681
+ current_remaining_years,
682
+ new_rate,
683
+ new_term_years,
684
+ closing_costs,
685
+ opportunity_rate,
686
+ npv_window_years=npv_window_years,
687
+ )
688
+ results.append(
689
+ {
690
+ "new_rate": new_rate * 100,
691
+ "monthly_savings": a.monthly_savings,
692
+ "simple_be": a.simple_breakeven_months,
693
+ "npv_be": a.npv_breakeven_months,
694
+ "five_yr_npv": a.five_year_npv,
695
+ },
696
+ )
697
+ return results
698
+
699
+
700
+ __all__ = [
701
+ "calculate_accelerated_payoff",
702
+ "calculate_total_cost_npv",
703
+ "analyze_refinance",
704
+ "generate_amortization_schedule",
705
+ "generate_amortization_schedule_pair",
706
+ "generate_comparison_schedule",
707
+ "run_holding_period_analysis",
708
+ "run_sensitivity",
709
+ ]
710
+
711
+ __description__ = """
712
+ Reusable calculations for the refinance calculator app.
713
+ """