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.
- refi_calculator/__init__.py +9 -0
- refi_calculator/cli.py +64 -0
- refi_calculator/core/__init__.py +36 -0
- refi_calculator/core/calculations.py +713 -0
- refi_calculator/core/charts.py +77 -0
- refi_calculator/core/market/__init__.py +11 -0
- refi_calculator/core/market/constants.py +24 -0
- refi_calculator/core/market/fred.py +62 -0
- refi_calculator/core/models.py +131 -0
- refi_calculator/environment.py +124 -0
- refi_calculator/gui/__init__.py +13 -0
- refi_calculator/gui/app.py +1008 -0
- refi_calculator/gui/builders/__init__.py +9 -0
- refi_calculator/gui/builders/analysis_tab.py +92 -0
- refi_calculator/gui/builders/helpers.py +90 -0
- refi_calculator/gui/builders/info_tab.py +195 -0
- refi_calculator/gui/builders/main_tab.py +173 -0
- refi_calculator/gui/builders/market_tab.py +115 -0
- refi_calculator/gui/builders/options_tab.py +81 -0
- refi_calculator/gui/builders/visuals_tab.py +128 -0
- refi_calculator/gui/chart.py +459 -0
- refi_calculator/gui/market_chart.py +192 -0
- refi_calculator/web/__init__.py +11 -0
- refi_calculator/web/app.py +117 -0
- refi_calculator/web/calculator.py +317 -0
- refi_calculator/web/formatting.py +90 -0
- refi_calculator/web/info.py +226 -0
- refi_calculator/web/market.py +270 -0
- refi_calculator/web/results.py +455 -0
- refi_calculator/web/runner.py +22 -0
- refi_calculator-0.8.0.dist-info/METADATA +146 -0
- refi_calculator-0.8.0.dist-info/RECORD +35 -0
- refi_calculator-0.8.0.dist-info/WHEEL +4 -0
- refi_calculator-0.8.0.dist-info/entry_points.txt +4 -0
- 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
|
+
"""
|