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,1008 @@
|
|
|
1
|
+
"""Refinance breakeven GUI components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
import tkinter as tk
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
from tkinter import filedialog, messagebox, ttk
|
|
11
|
+
|
|
12
|
+
from ..core.calculations import (
|
|
13
|
+
analyze_refinance,
|
|
14
|
+
generate_amortization_schedule_pair,
|
|
15
|
+
generate_comparison_schedule,
|
|
16
|
+
run_holding_period_analysis,
|
|
17
|
+
run_sensitivity,
|
|
18
|
+
)
|
|
19
|
+
from ..core.market.constants import MARKET_DEFAULT_PERIOD, MARKET_SERIES
|
|
20
|
+
from ..core.market.fred import fetch_fred_series
|
|
21
|
+
from ..core.models import RefinanceAnalysis
|
|
22
|
+
from ..environment import load_dotenv
|
|
23
|
+
from .builders.analysis_tab import build_holding_period_tab, build_sensitivity_tab
|
|
24
|
+
from .builders.info_tab import build_background_tab, build_help_tab
|
|
25
|
+
from .builders.main_tab import build_main_tab
|
|
26
|
+
from .builders.market_tab import build_market_tab
|
|
27
|
+
from .builders.options_tab import build_options_tab
|
|
28
|
+
from .builders.visuals_tab import build_amortization_tab, build_chart_tab
|
|
29
|
+
from .chart import AmortizationChart, SavingsChart
|
|
30
|
+
from .market_chart import MarketChart
|
|
31
|
+
|
|
32
|
+
logger = getLogger(__name__)
|
|
33
|
+
load_dotenv()
|
|
34
|
+
|
|
35
|
+
# ruff: noqa: PLR0915, PLR0912
|
|
36
|
+
|
|
37
|
+
CALCULATOR_TAB_INDEX = 0
|
|
38
|
+
ANALYSIS_TAB_INDEX = 1
|
|
39
|
+
VISUALS_TAB_INDEX = 2
|
|
40
|
+
MARKET_TAB_INDEX = 3
|
|
41
|
+
OPTIONS_TAB_INDEX = 4
|
|
42
|
+
INFO_TAB_INDEX = 5
|
|
43
|
+
|
|
44
|
+
MARKET_CACHE_TTL = timedelta(minutes=15)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RefinanceCalculatorApp:
|
|
48
|
+
"""Refinance Calculator Application.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
root: Root Tkinter window
|
|
52
|
+
current_analysis: Current refinance analysis results
|
|
53
|
+
sensitivity_data: Sensitivity analysis data
|
|
54
|
+
holding_period_data: Holding period analysis data
|
|
55
|
+
amortization_data: Amortization comparison data
|
|
56
|
+
amortization_balance_chart: Amortization chart comparing loan balances
|
|
57
|
+
current_amortization_schedule: Monthly schedule for the current loan
|
|
58
|
+
new_amortization_schedule: Monthly schedule for the new loan
|
|
59
|
+
current_balance: Current loan balance input
|
|
60
|
+
current_rate: Current loan interest rate input
|
|
61
|
+
current_remaining: Current loan remaining term input
|
|
62
|
+
new_rate: New loan interest rate input
|
|
63
|
+
new_term: New loan term input
|
|
64
|
+
closing_costs: Closing costs input
|
|
65
|
+
cash_out: Cash-out amount input
|
|
66
|
+
opportunity_rate: Opportunity cost rate input
|
|
67
|
+
marginal_tax_rate: Marginal tax rate input
|
|
68
|
+
npv_window_years: NPV calculation window input
|
|
69
|
+
chart_horizon_years: Chart horizon years input
|
|
70
|
+
sensitivity_max_reduction: Sensitivity max rate reduction input
|
|
71
|
+
sensitivity_step: Sensitivity rate step input
|
|
72
|
+
maintain_payment: Maintain current payment option
|
|
73
|
+
fred_api_key: FRED API key for market data (if available)
|
|
74
|
+
market_series_data: Historical rate observations keyed by series id
|
|
75
|
+
market_series_errors: Load errors keyed by series id
|
|
76
|
+
market_cache_timestamps: Cache timestamps keyed by series id
|
|
77
|
+
market_period_var: Selected history window (months)
|
|
78
|
+
market_chart: Chart widget displaying all series
|
|
79
|
+
market_tree: Table showing side-by-side tenor values
|
|
80
|
+
_market_status_label: Label describing market data status
|
|
81
|
+
_market_cache_indicator: Cache freshness badge
|
|
82
|
+
_calc_canvas: Canvas for the calculator tab
|
|
83
|
+
sens_tree: Treeview for sensitivity analysis
|
|
84
|
+
holding_tree: Treeview for holding period analysis
|
|
85
|
+
amort_tree: Treeview for amortization comparison
|
|
86
|
+
_background_canvas: Canvas for background info tab
|
|
87
|
+
_help_canvas: Canvas for help info tab
|
|
88
|
+
chart: Savings chart component
|
|
89
|
+
pay_frame: Frame for payment results
|
|
90
|
+
balance_frame: Frame for balance results
|
|
91
|
+
current_pmt_label: Label for current payment result
|
|
92
|
+
new_pmt_label: Label for new payment result
|
|
93
|
+
savings_label: Label for monthly savings result
|
|
94
|
+
new_balance_label: Label for new loan balance result
|
|
95
|
+
cash_out_label: Label for cash-out amount result
|
|
96
|
+
simple_be_label: Label for simple breakeven result
|
|
97
|
+
npv_be_label: Label for NPV breakeven result
|
|
98
|
+
curr_int_label: Label for current total interest result
|
|
99
|
+
new_int_label: Label for new total interest result
|
|
100
|
+
int_delta_label: Label for interest delta result
|
|
101
|
+
tax_section_label: Label for after-tax section title
|
|
102
|
+
at_current_pmt_label: Label for after-tax current payment result
|
|
103
|
+
at_new_pmt_label: Label for after-tax new payment result
|
|
104
|
+
at_savings_label: Label for after-tax monthly savings result
|
|
105
|
+
at_simple_be_label: Label for after-tax simple breakeven result
|
|
106
|
+
at_npv_be_label: Label for after-tax NPV breakeven result
|
|
107
|
+
at_int_delta_label: Label for after-tax interest delta result
|
|
108
|
+
npv_title_label: Label for NPV title
|
|
109
|
+
five_yr_npv_label: Label for 5-year NPV result
|
|
110
|
+
accel_section_frame: Frame for accelerated payoff section
|
|
111
|
+
accel_section_label: Label for accelerated payoff section title
|
|
112
|
+
accel_months_label: Label for accelerated months result
|
|
113
|
+
accel_time_saved_label: Label for accelerated time saved result
|
|
114
|
+
accel_interest_saved_label: Label for accelerated interest saved result
|
|
115
|
+
current_cost_npv_label: Label for current total cost NPV result
|
|
116
|
+
new_cost_npv_label: Label for new total cost NPV result
|
|
117
|
+
cost_npv_advantage_label: Label for total cost NPV advantage result
|
|
118
|
+
amort_curr_total_int: Label for amortization current total interest
|
|
119
|
+
amort_new_total_int: Label for amortization new total interest
|
|
120
|
+
amort_int_savings: Label for amortization interest savings
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
root: tk.Tk
|
|
124
|
+
current_analysis: RefinanceAnalysis | None
|
|
125
|
+
sensitivity_data: list[dict]
|
|
126
|
+
holding_period_data: list[dict]
|
|
127
|
+
amortization_data: list[dict]
|
|
128
|
+
amortization_balance_chart: AmortizationChart | None
|
|
129
|
+
current_amortization_schedule: list[dict]
|
|
130
|
+
new_amortization_schedule: list[dict]
|
|
131
|
+
current_balance: tk.StringVar
|
|
132
|
+
current_rate: tk.StringVar
|
|
133
|
+
current_remaining: tk.StringVar
|
|
134
|
+
new_rate: tk.StringVar
|
|
135
|
+
new_term: tk.StringVar
|
|
136
|
+
closing_costs: tk.StringVar
|
|
137
|
+
cash_out: tk.StringVar
|
|
138
|
+
opportunity_rate: tk.StringVar
|
|
139
|
+
marginal_tax_rate: tk.StringVar
|
|
140
|
+
npv_window_years: tk.StringVar
|
|
141
|
+
chart_horizon_years: tk.StringVar
|
|
142
|
+
sensitivity_max_reduction: tk.StringVar
|
|
143
|
+
sensitivity_step: tk.StringVar
|
|
144
|
+
maintain_payment: tk.BooleanVar
|
|
145
|
+
fred_api_key: str | None
|
|
146
|
+
market_series_data: dict[str, list[tuple[datetime, float]]]
|
|
147
|
+
market_series_errors: dict[str, str | None]
|
|
148
|
+
market_cache_timestamps: dict[str, datetime | None]
|
|
149
|
+
market_period_var: tk.StringVar
|
|
150
|
+
market_chart: MarketChart | None
|
|
151
|
+
market_tree: ttk.Treeview | None
|
|
152
|
+
_market_status_label: ttk.Label | None
|
|
153
|
+
_market_cache_indicator: ttk.Label | None
|
|
154
|
+
_calc_canvas: tk.Canvas
|
|
155
|
+
sens_tree: ttk.Treeview
|
|
156
|
+
holding_tree: ttk.Treeview
|
|
157
|
+
amort_tree: ttk.Treeview
|
|
158
|
+
_background_canvas: tk.Canvas
|
|
159
|
+
_help_canvas: tk.Canvas
|
|
160
|
+
chart: SavingsChart
|
|
161
|
+
pay_frame: ttk.Frame
|
|
162
|
+
balance_frame: ttk.Frame
|
|
163
|
+
current_pmt_label: ttk.Label
|
|
164
|
+
new_pmt_label: ttk.Label
|
|
165
|
+
savings_label: ttk.Label
|
|
166
|
+
new_balance_label: ttk.Label
|
|
167
|
+
cash_out_label: ttk.Label
|
|
168
|
+
simple_be_label: ttk.Label
|
|
169
|
+
npv_be_label: ttk.Label
|
|
170
|
+
curr_int_label: ttk.Label
|
|
171
|
+
new_int_label: ttk.Label
|
|
172
|
+
int_delta_label: ttk.Label
|
|
173
|
+
tax_section_label: ttk.Label
|
|
174
|
+
at_current_pmt_label: ttk.Label
|
|
175
|
+
at_new_pmt_label: ttk.Label
|
|
176
|
+
at_savings_label: ttk.Label
|
|
177
|
+
at_simple_be_label: ttk.Label
|
|
178
|
+
at_npv_be_label: ttk.Label
|
|
179
|
+
at_int_delta_label: ttk.Label
|
|
180
|
+
npv_title_label: ttk.Label
|
|
181
|
+
five_yr_npv_label: ttk.Label
|
|
182
|
+
accel_section_frame: ttk.Frame
|
|
183
|
+
accel_section_label: ttk.Label
|
|
184
|
+
accel_months_label: ttk.Label
|
|
185
|
+
accel_time_saved_label: ttk.Label
|
|
186
|
+
accel_interest_saved_label: ttk.Label
|
|
187
|
+
current_cost_npv_label: ttk.Label
|
|
188
|
+
new_cost_npv_label: ttk.Label
|
|
189
|
+
cost_npv_advantage_label: ttk.Label
|
|
190
|
+
amort_curr_total_int: ttk.Label
|
|
191
|
+
amort_new_total_int: ttk.Label
|
|
192
|
+
amort_int_savings: ttk.Label
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
root: tk.Tk,
|
|
197
|
+
):
|
|
198
|
+
"""Initialize RefinanceCalculatorApp.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
root: Root Tkinter window
|
|
202
|
+
"""
|
|
203
|
+
self.root = root
|
|
204
|
+
self.root.title("Refinance Breakeven Calculator")
|
|
205
|
+
self.root.configure(bg="#f5f5f5")
|
|
206
|
+
|
|
207
|
+
self.current_analysis: RefinanceAnalysis | None = None
|
|
208
|
+
self.sensitivity_data: list[dict] = []
|
|
209
|
+
self.holding_period_data: list[dict] = []
|
|
210
|
+
self.amortization_data: list[dict] = []
|
|
211
|
+
self.current_amortization_schedule: list[dict] = []
|
|
212
|
+
self.new_amortization_schedule: list[dict] = []
|
|
213
|
+
self.amortization_balance_chart: AmortizationChart | None = None
|
|
214
|
+
|
|
215
|
+
self.current_balance = tk.StringVar(value="400000")
|
|
216
|
+
self.current_rate = tk.StringVar(value="6.5")
|
|
217
|
+
self.current_remaining = tk.StringVar(value="25")
|
|
218
|
+
self.new_rate = tk.StringVar(value="5.75")
|
|
219
|
+
self.new_term = tk.StringVar(value="30")
|
|
220
|
+
self.closing_costs = tk.StringVar(value="8000")
|
|
221
|
+
self.cash_out = tk.StringVar(value="0")
|
|
222
|
+
self.opportunity_rate = tk.StringVar(value="5.0")
|
|
223
|
+
self.marginal_tax_rate = tk.StringVar(value="0")
|
|
224
|
+
|
|
225
|
+
self.npv_window_years = tk.StringVar(value="5")
|
|
226
|
+
self.chart_horizon_years = tk.StringVar(value="10")
|
|
227
|
+
self.sensitivity_max_reduction = tk.StringVar(value="2.5")
|
|
228
|
+
self.sensitivity_step = tk.StringVar(value="0.125")
|
|
229
|
+
self.maintain_payment = tk.BooleanVar(value=False)
|
|
230
|
+
|
|
231
|
+
self.fred_api_key = os.getenv("FRED_API_KEY")
|
|
232
|
+
self.market_series_data: dict[str, list[tuple[datetime, float]]] = {
|
|
233
|
+
series_id: [] for _, series_id in MARKET_SERIES
|
|
234
|
+
}
|
|
235
|
+
self.market_series_errors: dict[str, str | None] = {
|
|
236
|
+
series_id: None for _, series_id in MARKET_SERIES
|
|
237
|
+
}
|
|
238
|
+
self.market_cache_timestamps: dict[str, datetime | None] = {
|
|
239
|
+
series_id: None for _, series_id in MARKET_SERIES
|
|
240
|
+
}
|
|
241
|
+
self.market_chart: MarketChart | None = None
|
|
242
|
+
self.market_tree: ttk.Treeview | None = None
|
|
243
|
+
self._market_status_label: ttk.Label | None = None
|
|
244
|
+
self._market_cache_indicator: ttk.Label | None = None
|
|
245
|
+
self.market_period_var = tk.StringVar(value=MARKET_DEFAULT_PERIOD)
|
|
246
|
+
|
|
247
|
+
self._load_all_market_data(force=True)
|
|
248
|
+
self._build_ui()
|
|
249
|
+
self._calculate()
|
|
250
|
+
|
|
251
|
+
def _build_ui(self):
|
|
252
|
+
"""Build the main UI components."""
|
|
253
|
+
# Style notebook tabs so the active one stands out
|
|
254
|
+
style = ttk.Style()
|
|
255
|
+
style.configure("TNotebook.Tab", padding=(12, 6))
|
|
256
|
+
style.map("TNotebook.Tab", font=[("selected", ("Segoe UI", 9, "bold"))])
|
|
257
|
+
|
|
258
|
+
self.notebook = ttk.Notebook(self.root)
|
|
259
|
+
self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
260
|
+
|
|
261
|
+
# Main calculator (scrollable)
|
|
262
|
+
main_tab = ttk.Frame(self.notebook, padding=0)
|
|
263
|
+
self.notebook.add(main_tab, text="Calculator")
|
|
264
|
+
|
|
265
|
+
self._calc_canvas = tk.Canvas(main_tab, highlightthickness=0)
|
|
266
|
+
calc_scrollbar = ttk.Scrollbar(main_tab, orient="vertical", command=self._calc_canvas.yview)
|
|
267
|
+
calc_scroll_frame = ttk.Frame(self._calc_canvas, padding=10)
|
|
268
|
+
|
|
269
|
+
calc_scroll_frame.bind(
|
|
270
|
+
"<Configure>",
|
|
271
|
+
lambda e: self._calc_canvas.configure(scrollregion=self._calc_canvas.bbox("all")),
|
|
272
|
+
)
|
|
273
|
+
calc_canvas_window = self._calc_canvas.create_window(
|
|
274
|
+
(0, 0),
|
|
275
|
+
window=calc_scroll_frame,
|
|
276
|
+
anchor="nw",
|
|
277
|
+
)
|
|
278
|
+
self._calc_canvas.configure(yscrollcommand=calc_scrollbar.set)
|
|
279
|
+
|
|
280
|
+
def on_calc_canvas_configure(event):
|
|
281
|
+
self._calc_canvas.itemconfig(calc_canvas_window, width=event.width)
|
|
282
|
+
|
|
283
|
+
self._calc_canvas.bind("<Configure>", on_calc_canvas_configure)
|
|
284
|
+
|
|
285
|
+
self._calc_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
286
|
+
calc_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
287
|
+
|
|
288
|
+
# Analysis group: sensitivity + holding period
|
|
289
|
+
analysis_tab = ttk.Frame(self.notebook, padding=10)
|
|
290
|
+
self.notebook.add(analysis_tab, text="Analysis")
|
|
291
|
+
self.analysis_notebook = ttk.Notebook(analysis_tab)
|
|
292
|
+
self.analysis_notebook.pack(fill=tk.BOTH, expand=True)
|
|
293
|
+
sens_tab = ttk.Frame(self.analysis_notebook, padding=10)
|
|
294
|
+
holding_tab = ttk.Frame(self.analysis_notebook, padding=10)
|
|
295
|
+
self.analysis_notebook.add(sens_tab, text="Rate Sensitivity")
|
|
296
|
+
self.analysis_notebook.add(holding_tab, text="Holding Period")
|
|
297
|
+
|
|
298
|
+
# Visuals group: amortization + chart
|
|
299
|
+
visuals_tab = ttk.Frame(self.notebook, padding=10)
|
|
300
|
+
self.notebook.add(visuals_tab, text="Loan Visualizations")
|
|
301
|
+
self.visuals_notebook = ttk.Notebook(visuals_tab)
|
|
302
|
+
self.visuals_notebook.pack(fill=tk.BOTH, expand=True)
|
|
303
|
+
amort_tab = ttk.Frame(self.visuals_notebook, padding=10)
|
|
304
|
+
chart_tab = ttk.Frame(self.visuals_notebook, padding=10)
|
|
305
|
+
self.visuals_notebook.add(amort_tab, text="Amortization")
|
|
306
|
+
self.visuals_notebook.add(chart_tab, text="Chart")
|
|
307
|
+
|
|
308
|
+
# Market data tab
|
|
309
|
+
market_tab = ttk.Frame(self.notebook, padding=10)
|
|
310
|
+
self.notebook.add(market_tab, text="Market")
|
|
311
|
+
build_market_tab(self, market_tab)
|
|
312
|
+
self._populate_market_tab()
|
|
313
|
+
|
|
314
|
+
# Options remain a top-level tab
|
|
315
|
+
options_tab = ttk.Frame(self.notebook, padding=10)
|
|
316
|
+
self.notebook.add(options_tab, text="Options")
|
|
317
|
+
|
|
318
|
+
# Info group: background + help
|
|
319
|
+
info_tab = ttk.Frame(self.notebook, padding=10)
|
|
320
|
+
self.notebook.add(info_tab, text="Info")
|
|
321
|
+
self.info_notebook = ttk.Notebook(info_tab)
|
|
322
|
+
self.info_notebook.pack(fill=tk.BOTH, expand=True)
|
|
323
|
+
background_tab = ttk.Frame(self.info_notebook, padding=10)
|
|
324
|
+
help_tab = ttk.Frame(self.info_notebook, padding=10)
|
|
325
|
+
self.info_notebook.add(background_tab, text="Background")
|
|
326
|
+
self.info_notebook.add(help_tab, text="Help")
|
|
327
|
+
|
|
328
|
+
build_main_tab(self, calc_scroll_frame)
|
|
329
|
+
build_sensitivity_tab(self, sens_tab)
|
|
330
|
+
build_holding_period_tab(self, holding_tab)
|
|
331
|
+
build_amortization_tab(self, amort_tab)
|
|
332
|
+
build_chart_tab(self, chart_tab)
|
|
333
|
+
build_options_tab(self, options_tab)
|
|
334
|
+
build_background_tab(self, background_tab)
|
|
335
|
+
build_help_tab(self, help_tab)
|
|
336
|
+
|
|
337
|
+
# Global mouse wheel handler that routes scrolling based on active tab
|
|
338
|
+
def on_mousewheel(event):
|
|
339
|
+
delta = int(-1 * (event.delta / 120))
|
|
340
|
+
top_index = self.notebook.index(self.notebook.select())
|
|
341
|
+
|
|
342
|
+
# Calculator tab
|
|
343
|
+
if top_index == CALCULATOR_TAB_INDEX and hasattr(self, "_calc_canvas"):
|
|
344
|
+
self._calc_canvas.yview_scroll(delta, "units")
|
|
345
|
+
elif top_index == ANALYSIS_TAB_INDEX and hasattr(self, "analysis_notebook"):
|
|
346
|
+
# Analysis tab
|
|
347
|
+
sub_index = self.analysis_notebook.index(self.analysis_notebook.select())
|
|
348
|
+
if sub_index == 0 and hasattr(self, "sens_tree"):
|
|
349
|
+
self.sens_tree.yview_scroll(delta, "units")
|
|
350
|
+
elif sub_index == 1 and hasattr(self, "holding_tree"):
|
|
351
|
+
self.holding_tree.yview_scroll(delta, "units")
|
|
352
|
+
elif top_index == VISUALS_TAB_INDEX and hasattr(self, "visuals_notebook"):
|
|
353
|
+
# Visuals tab
|
|
354
|
+
sub_index = self.visuals_notebook.index(self.visuals_notebook.select())
|
|
355
|
+
if sub_index == 0 and hasattr(self, "amort_tree"):
|
|
356
|
+
self.amort_tree.yview_scroll(delta, "units")
|
|
357
|
+
# Chart tab has no vertical scroll
|
|
358
|
+
elif top_index == MARKET_TAB_INDEX and self.market_tree:
|
|
359
|
+
self.market_tree.yview_scroll(delta, "units")
|
|
360
|
+
elif top_index == INFO_TAB_INDEX and hasattr(self, "info_notebook"):
|
|
361
|
+
# Info tab
|
|
362
|
+
sub_index = self.info_notebook.index(self.info_notebook.select())
|
|
363
|
+
if sub_index == 0 and hasattr(self, "_background_canvas"):
|
|
364
|
+
self._background_canvas.yview_scroll(delta, "units")
|
|
365
|
+
elif sub_index == 1 and hasattr(self, "_help_canvas"):
|
|
366
|
+
self._help_canvas.yview_scroll(delta, "units")
|
|
367
|
+
|
|
368
|
+
self.root.bind_all("<MouseWheel>", on_mousewheel)
|
|
369
|
+
|
|
370
|
+
def _load_market_series(self, series_id: str, *, force: bool = False) -> None:
|
|
371
|
+
"""Fetch a named FRED series, reusing cache if it is still fresh."""
|
|
372
|
+
now = datetime.now()
|
|
373
|
+
cache_timestamp = self.market_cache_timestamps.get(series_id)
|
|
374
|
+
cached_values = self.market_series_data.get(series_id)
|
|
375
|
+
if (
|
|
376
|
+
not force
|
|
377
|
+
and cached_values
|
|
378
|
+
and cache_timestamp
|
|
379
|
+
and now - cache_timestamp < MARKET_CACHE_TTL
|
|
380
|
+
):
|
|
381
|
+
logger.debug("Using cached market observation data for %s", series_id)
|
|
382
|
+
self.market_series_errors[series_id] = None
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if not self.fred_api_key:
|
|
386
|
+
self.market_series_errors[series_id] = (
|
|
387
|
+
"FRED_API_KEY is not configured; market history is disabled."
|
|
388
|
+
)
|
|
389
|
+
logger.info(
|
|
390
|
+
"Skipping market fetch for %s: %s",
|
|
391
|
+
series_id,
|
|
392
|
+
self.market_series_errors[series_id],
|
|
393
|
+
)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
observations = fetch_fred_series(series_id, self.fred_api_key, limit=600)
|
|
398
|
+
except RuntimeError as exc:
|
|
399
|
+
logger.exception("Failed to fetch market rates from FRED for %s", series_id)
|
|
400
|
+
self.market_series_errors[series_id] = f"Unable to load mortgage rate data: {exc}"
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if not observations:
|
|
404
|
+
self.market_series_errors[series_id] = (
|
|
405
|
+
"FRED returned no observations for the selected series."
|
|
406
|
+
)
|
|
407
|
+
logger.warning(self.market_series_errors[series_id])
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
processed: list[tuple[datetime, float]] = []
|
|
411
|
+
for date_str, value in observations:
|
|
412
|
+
try:
|
|
413
|
+
parsed = datetime.strptime(date_str, "%Y-%m-%d")
|
|
414
|
+
except ValueError:
|
|
415
|
+
continue
|
|
416
|
+
processed.append((parsed, value))
|
|
417
|
+
|
|
418
|
+
self.market_series_data[series_id] = processed
|
|
419
|
+
self.market_cache_timestamps[series_id] = now
|
|
420
|
+
self.market_series_errors[series_id] = None
|
|
421
|
+
|
|
422
|
+
if series_id == "MORTGAGE30US" and processed:
|
|
423
|
+
latest_rate = processed[0][1]
|
|
424
|
+
self.new_rate.set(f"{latest_rate:.3f}")
|
|
425
|
+
|
|
426
|
+
def _load_all_market_data(self, *, force: bool = False) -> None:
|
|
427
|
+
"""Ensure every configured series has fresh data."""
|
|
428
|
+
for _, series_id in MARKET_SERIES:
|
|
429
|
+
self._load_market_series(series_id, force=force)
|
|
430
|
+
|
|
431
|
+
def _refresh_market_data(self) -> None:
|
|
432
|
+
"""Refresh market rates and update the corresponding tab."""
|
|
433
|
+
self._load_all_market_data(force=True)
|
|
434
|
+
self._populate_market_tab()
|
|
435
|
+
if self.market_series_data.get("MORTGAGE30US"):
|
|
436
|
+
self._calculate()
|
|
437
|
+
|
|
438
|
+
def _populate_market_tab(self) -> None:
|
|
439
|
+
"""Update the market tab tree with the latest observations."""
|
|
440
|
+
if not self._market_status_label:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
if self.market_tree:
|
|
444
|
+
for row in self.market_tree.get_children():
|
|
445
|
+
self.market_tree.delete(row)
|
|
446
|
+
|
|
447
|
+
merged = self._merged_market_rows()
|
|
448
|
+
for row in merged:
|
|
449
|
+
self.market_tree.insert("", tk.END, values=row)
|
|
450
|
+
|
|
451
|
+
if self.market_chart:
|
|
452
|
+
chart_data = {
|
|
453
|
+
label: self._filtered_series_data(series_id) for label, series_id in MARKET_SERIES
|
|
454
|
+
}
|
|
455
|
+
self.market_chart.plot(chart_data)
|
|
456
|
+
|
|
457
|
+
self._update_market_status_display()
|
|
458
|
+
|
|
459
|
+
def _market_period_months(self) -> int | None:
|
|
460
|
+
"""Return the selected period in months, using None for 'All'."""
|
|
461
|
+
value = self.market_period_var.get()
|
|
462
|
+
try:
|
|
463
|
+
months = int(value)
|
|
464
|
+
except (TypeError, ValueError):
|
|
465
|
+
return None
|
|
466
|
+
return None if months <= 0 else months
|
|
467
|
+
|
|
468
|
+
def _filtered_series_data(self, series_id: str) -> list[tuple[datetime, float]]:
|
|
469
|
+
"""Return the rate observations truncated to the selected period."""
|
|
470
|
+
rows = self.market_series_data.get(series_id, [])
|
|
471
|
+
months = self._market_period_months()
|
|
472
|
+
if not rows or months is None:
|
|
473
|
+
return rows
|
|
474
|
+
|
|
475
|
+
latest = rows[0][0]
|
|
476
|
+
threshold = latest - timedelta(days=months * 30)
|
|
477
|
+
return [row for row in rows if row[0] >= threshold]
|
|
478
|
+
|
|
479
|
+
def _merged_market_rows(self) -> list[tuple[str, ...]]:
|
|
480
|
+
"""Combine each series into a table-ready row."""
|
|
481
|
+
filtered_map = {
|
|
482
|
+
series_id: self._filtered_series_data(series_id) for _, series_id in MARKET_SERIES
|
|
483
|
+
}
|
|
484
|
+
series_value_map: dict[str, dict[datetime, float]] = {
|
|
485
|
+
series_id: {dt: rate for dt, rate in rows} for series_id, rows in filtered_map.items()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
all_dates = sorted(
|
|
489
|
+
{dt for rates in series_value_map.values() for dt in rates},
|
|
490
|
+
reverse=True,
|
|
491
|
+
)
|
|
492
|
+
result: list[tuple[str, ...]] = []
|
|
493
|
+
for dt in all_dates:
|
|
494
|
+
row = [dt.strftime("%Y-%m-%d")]
|
|
495
|
+
for _, series_id in MARKET_SERIES:
|
|
496
|
+
rate = series_value_map.get(series_id, {}).get(dt)
|
|
497
|
+
row.append(f"{rate:.3f}%" if rate is not None else "—")
|
|
498
|
+
result.append(tuple(row))
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
def _update_market_status_display(self) -> None:
|
|
502
|
+
"""Refresh the market status text and cache indicator for the selected series."""
|
|
503
|
+
if not self._market_status_label:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
parts: list[str] = []
|
|
507
|
+
timestamps: list[datetime] = []
|
|
508
|
+
for label, series_id in MARKET_SERIES:
|
|
509
|
+
rows = self._filtered_series_data(series_id)
|
|
510
|
+
error = self.market_series_errors.get(series_id)
|
|
511
|
+
|
|
512
|
+
if not rows:
|
|
513
|
+
parts.append(f"{label}: {error or 'unavailable'}")
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
latest_date, latest_rate = rows[0]
|
|
517
|
+
parts.append(f"{label}: {latest_rate:.3f}% ({latest_date:%Y-%m-%d})")
|
|
518
|
+
timestamp = self.market_cache_timestamps.get(series_id)
|
|
519
|
+
if timestamp:
|
|
520
|
+
timestamps.append(timestamp)
|
|
521
|
+
|
|
522
|
+
status_text = " | ".join(parts) if parts else "Market data is not available."
|
|
523
|
+
if timestamps:
|
|
524
|
+
latest_ts = max(timestamps)
|
|
525
|
+
status_text += f" - refreshed {latest_ts:%Y-%m-%d %H:%M}"
|
|
526
|
+
|
|
527
|
+
self._market_status_label.config(
|
|
528
|
+
text=status_text,
|
|
529
|
+
foreground="black" if parts else "red",
|
|
530
|
+
)
|
|
531
|
+
self._update_market_cache_indicator(max(timestamps) if timestamps else None)
|
|
532
|
+
|
|
533
|
+
def _update_market_cache_indicator(self, timestamp: datetime | None = None) -> None:
|
|
534
|
+
"""Update the cache status indicator label below the Market tab header."""
|
|
535
|
+
if not self._market_cache_indicator:
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
if timestamp is None:
|
|
539
|
+
timestamps = [ts for ts in self.market_cache_timestamps.values() if ts is not None]
|
|
540
|
+
timestamp = max(timestamps) if timestamps else None
|
|
541
|
+
if not timestamp:
|
|
542
|
+
self._market_cache_indicator.config(
|
|
543
|
+
text="Cache: not populated",
|
|
544
|
+
foreground="#666",
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
age = datetime.now() - timestamp
|
|
549
|
+
status = "fresh" if age < MARKET_CACHE_TTL else "stale"
|
|
550
|
+
minutes = int(age.total_seconds() / 60)
|
|
551
|
+
suffix = "just now" if minutes == 0 else f"{minutes} min ago"
|
|
552
|
+
color = "green" if status == "fresh" else "orange"
|
|
553
|
+
self._market_cache_indicator.config(
|
|
554
|
+
text=f"Cache ({status}): {suffix}",
|
|
555
|
+
foreground=color,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _calculate(self) -> None:
|
|
559
|
+
"""Perform refinance analysis and update all results and charts."""
|
|
560
|
+
try:
|
|
561
|
+
npv_years = int(float(self.npv_window_years.get() or 5))
|
|
562
|
+
chart_years = int(float(self.chart_horizon_years.get() or 10))
|
|
563
|
+
sens_max = float(self.sensitivity_max_reduction.get() or 2.0)
|
|
564
|
+
sens_step = float(self.sensitivity_step.get() or 0.25)
|
|
565
|
+
|
|
566
|
+
params = {
|
|
567
|
+
"current_balance": float(self.current_balance.get()),
|
|
568
|
+
"current_rate": float(self.current_rate.get()) / 100,
|
|
569
|
+
"current_remaining_years": float(self.current_remaining.get()),
|
|
570
|
+
"new_rate": float(self.new_rate.get()) / 100,
|
|
571
|
+
"new_term_years": float(self.new_term.get()),
|
|
572
|
+
"closing_costs": float(self.closing_costs.get()),
|
|
573
|
+
"cash_out": float(self.cash_out.get() or 0),
|
|
574
|
+
"opportunity_rate": float(self.opportunity_rate.get()) / 100,
|
|
575
|
+
"npv_window_years": npv_years,
|
|
576
|
+
"chart_horizon_years": chart_years,
|
|
577
|
+
"marginal_tax_rate": float(self.marginal_tax_rate.get() or 0) / 100,
|
|
578
|
+
"maintain_payment": self.maintain_payment.get(),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
self.current_analysis = analyze_refinance(**params)
|
|
582
|
+
self._update_results(self.current_analysis, npv_years)
|
|
583
|
+
|
|
584
|
+
current_rate_pct = float(self.current_rate.get())
|
|
585
|
+
rate_steps = []
|
|
586
|
+
r = sens_step
|
|
587
|
+
max_scenarios = 20
|
|
588
|
+
while r <= sens_max + 0.001 and len(rate_steps) < max_scenarios:
|
|
589
|
+
new_rate = current_rate_pct - r
|
|
590
|
+
if new_rate > 0:
|
|
591
|
+
rate_steps.append(new_rate / 100)
|
|
592
|
+
r += sens_step
|
|
593
|
+
|
|
594
|
+
self.sensitivity_data = run_sensitivity(
|
|
595
|
+
params["current_balance"],
|
|
596
|
+
params["current_rate"],
|
|
597
|
+
params["current_remaining_years"],
|
|
598
|
+
params["new_term_years"],
|
|
599
|
+
params["closing_costs"],
|
|
600
|
+
params["opportunity_rate"],
|
|
601
|
+
rate_steps,
|
|
602
|
+
npv_years,
|
|
603
|
+
)
|
|
604
|
+
self._update_sensitivity(npv_years)
|
|
605
|
+
|
|
606
|
+
holding_periods = [1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 15, 20]
|
|
607
|
+
self.holding_period_data = run_holding_period_analysis(
|
|
608
|
+
params["current_balance"],
|
|
609
|
+
params["current_rate"],
|
|
610
|
+
params["current_remaining_years"],
|
|
611
|
+
params["new_rate"],
|
|
612
|
+
params["new_term_years"],
|
|
613
|
+
params["closing_costs"],
|
|
614
|
+
params["opportunity_rate"],
|
|
615
|
+
params["marginal_tax_rate"],
|
|
616
|
+
holding_periods,
|
|
617
|
+
params["cash_out"],
|
|
618
|
+
)
|
|
619
|
+
self._update_holding_period()
|
|
620
|
+
|
|
621
|
+
(
|
|
622
|
+
current_schedule,
|
|
623
|
+
new_schedule,
|
|
624
|
+
) = generate_amortization_schedule_pair(
|
|
625
|
+
current_balance=params["current_balance"],
|
|
626
|
+
current_rate=params["current_rate"],
|
|
627
|
+
current_remaining_years=params["current_remaining_years"],
|
|
628
|
+
new_rate=params["new_rate"],
|
|
629
|
+
new_term_years=params["new_term_years"],
|
|
630
|
+
closing_costs=params["closing_costs"],
|
|
631
|
+
cash_out=params["cash_out"],
|
|
632
|
+
maintain_payment=params["maintain_payment"],
|
|
633
|
+
)
|
|
634
|
+
self.current_amortization_schedule = current_schedule
|
|
635
|
+
self.new_amortization_schedule = new_schedule
|
|
636
|
+
|
|
637
|
+
self.amortization_data = generate_comparison_schedule(
|
|
638
|
+
current_balance=params["current_balance"],
|
|
639
|
+
current_rate=params["current_rate"],
|
|
640
|
+
current_remaining_years=params["current_remaining_years"],
|
|
641
|
+
new_rate=params["new_rate"],
|
|
642
|
+
new_term_years=params["new_term_years"],
|
|
643
|
+
closing_costs=params["closing_costs"],
|
|
644
|
+
cash_out=params["cash_out"],
|
|
645
|
+
maintain_payment=params["maintain_payment"],
|
|
646
|
+
)
|
|
647
|
+
self._update_amortization()
|
|
648
|
+
self._update_amortization_balance_chart()
|
|
649
|
+
|
|
650
|
+
self.chart.plot(
|
|
651
|
+
self.current_analysis.cumulative_savings,
|
|
652
|
+
self.current_analysis.npv_breakeven_months,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
except ValueError:
|
|
656
|
+
pass
|
|
657
|
+
|
|
658
|
+
def _update_results(
|
|
659
|
+
self,
|
|
660
|
+
a: RefinanceAnalysis,
|
|
661
|
+
npv_years: int = 5,
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Update result labels based on the given analysis.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
a: RefinanceAnalysis object with calculation results
|
|
667
|
+
npv_years: NPV time horizon in years
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
def fmt(v: float) -> str:
|
|
671
|
+
return f"${v:,.0f}"
|
|
672
|
+
|
|
673
|
+
def fmt_months(m: float | None) -> str:
|
|
674
|
+
if m is None:
|
|
675
|
+
return "N/A"
|
|
676
|
+
return f"{m:.0f} mo ({m / 12:.1f} yr)"
|
|
677
|
+
|
|
678
|
+
self.current_pmt_label.config(text=fmt(a.current_payment))
|
|
679
|
+
self.new_pmt_label.config(text=fmt(a.new_payment))
|
|
680
|
+
|
|
681
|
+
savings_text = fmt(abs(a.monthly_savings))
|
|
682
|
+
if a.monthly_savings >= 0:
|
|
683
|
+
self.savings_label.config(text=f"-{savings_text}", foreground="green")
|
|
684
|
+
else:
|
|
685
|
+
self.savings_label.config(text=f"+{savings_text}", foreground="red")
|
|
686
|
+
|
|
687
|
+
if a.cash_out_amount > 0:
|
|
688
|
+
self.balance_frame.pack(fill=tk.X, pady=(0, 8), after=self.pay_frame)
|
|
689
|
+
self.new_balance_label.config(text=fmt(a.new_loan_balance))
|
|
690
|
+
self.cash_out_label.config(text=fmt(a.cash_out_amount), foreground="blue")
|
|
691
|
+
else:
|
|
692
|
+
self.balance_frame.pack_forget()
|
|
693
|
+
|
|
694
|
+
self.simple_be_label.config(text=fmt_months(a.simple_breakeven_months))
|
|
695
|
+
self.npv_be_label.config(text=fmt_months(a.npv_breakeven_months))
|
|
696
|
+
|
|
697
|
+
self.curr_int_label.config(text=fmt(a.current_total_interest))
|
|
698
|
+
self.new_int_label.config(text=fmt(a.new_total_interest))
|
|
699
|
+
|
|
700
|
+
delta_text = fmt(abs(a.interest_delta))
|
|
701
|
+
if a.interest_delta < 0:
|
|
702
|
+
self.int_delta_label.config(text=f"-{delta_text}", foreground="green")
|
|
703
|
+
else:
|
|
704
|
+
self.int_delta_label.config(text=f"+{delta_text}", foreground="red")
|
|
705
|
+
|
|
706
|
+
self.npv_title_label.config(text=f"{npv_years}-Year NPV of Refinancing")
|
|
707
|
+
|
|
708
|
+
tax_rate_pct = float(self.marginal_tax_rate.get() or 0)
|
|
709
|
+
self.tax_section_label.config(
|
|
710
|
+
text=f"After-Tax Analysis ({tax_rate_pct:.0f}% marginal rate)",
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
self.at_current_pmt_label.config(text=fmt(a.current_after_tax_payment))
|
|
714
|
+
self.at_new_pmt_label.config(text=fmt(a.new_after_tax_payment))
|
|
715
|
+
|
|
716
|
+
at_savings_text = fmt(abs(a.after_tax_monthly_savings))
|
|
717
|
+
if a.after_tax_monthly_savings >= 0:
|
|
718
|
+
self.at_savings_label.config(text=f"-{at_savings_text}", foreground="green")
|
|
719
|
+
else:
|
|
720
|
+
self.at_savings_label.config(text=f"+{at_savings_text}", foreground="red")
|
|
721
|
+
|
|
722
|
+
self.at_simple_be_label.config(text=fmt_months(a.after_tax_simple_breakeven_months))
|
|
723
|
+
self.at_npv_be_label.config(text=fmt_months(a.after_tax_npv_breakeven_months))
|
|
724
|
+
|
|
725
|
+
at_int_delta_text = fmt(abs(a.after_tax_interest_delta))
|
|
726
|
+
if a.after_tax_interest_delta < 0:
|
|
727
|
+
self.at_int_delta_label.config(text=f"-{at_int_delta_text}", foreground="green")
|
|
728
|
+
else:
|
|
729
|
+
self.at_int_delta_label.config(text=f"+{at_int_delta_text}", foreground="red")
|
|
730
|
+
|
|
731
|
+
npv_text = fmt(abs(a.five_year_npv))
|
|
732
|
+
if a.five_year_npv >= 0:
|
|
733
|
+
self.five_yr_npv_label.config(text=f"+{npv_text}", foreground="green")
|
|
734
|
+
else:
|
|
735
|
+
self.five_yr_npv_label.config(text=f"-{npv_text}", foreground="red")
|
|
736
|
+
|
|
737
|
+
# Accelerated payoff section
|
|
738
|
+
if self.maintain_payment.get() and a.accelerated_months:
|
|
739
|
+
self.accel_section_frame.pack(
|
|
740
|
+
fill=tk.X,
|
|
741
|
+
pady=(0, 8),
|
|
742
|
+
before=self.npv_title_label.master,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
years = a.accelerated_months / 12
|
|
746
|
+
self.accel_months_label.config(text=f"{a.accelerated_months} mo ({years:.1f} yr)")
|
|
747
|
+
|
|
748
|
+
if a.accelerated_time_savings_months:
|
|
749
|
+
saved_years = a.accelerated_time_savings_months / 12
|
|
750
|
+
self.accel_time_saved_label.config(
|
|
751
|
+
text=f"{a.accelerated_time_savings_months} mo ({saved_years:.1f} yr)",
|
|
752
|
+
foreground="green",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if a.accelerated_interest_savings:
|
|
756
|
+
self.accel_interest_saved_label.config(
|
|
757
|
+
text=fmt(a.accelerated_interest_savings),
|
|
758
|
+
foreground="green",
|
|
759
|
+
)
|
|
760
|
+
else:
|
|
761
|
+
self.accel_section_frame.pack_forget()
|
|
762
|
+
|
|
763
|
+
# Total Cost NPV
|
|
764
|
+
self.current_cost_npv_label.config(text=fmt(a.current_total_cost_npv))
|
|
765
|
+
self.new_cost_npv_label.config(text=fmt(a.new_total_cost_npv))
|
|
766
|
+
|
|
767
|
+
adv_text = fmt(abs(a.total_cost_npv_advantage))
|
|
768
|
+
if a.total_cost_npv_advantage >= 0:
|
|
769
|
+
self.cost_npv_advantage_label.config(text=f"+{adv_text}", foreground="green")
|
|
770
|
+
else:
|
|
771
|
+
self.cost_npv_advantage_label.config(text=f"-{adv_text}", foreground="red")
|
|
772
|
+
|
|
773
|
+
def _update_sensitivity(
|
|
774
|
+
self,
|
|
775
|
+
npv_years: int = 5,
|
|
776
|
+
) -> None:
|
|
777
|
+
"""Update sensitivity analysis table.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
npv_years: NPV time horizon in years
|
|
781
|
+
"""
|
|
782
|
+
self.sens_tree.heading("npv_5yr", text=f"{npv_years}-Yr NPV")
|
|
783
|
+
|
|
784
|
+
for row in self.sens_tree.get_children():
|
|
785
|
+
self.sens_tree.delete(row)
|
|
786
|
+
|
|
787
|
+
for row in self.sensitivity_data:
|
|
788
|
+
simple = f"{row['simple_be']:.0f} mo" if row["simple_be"] else "N/A"
|
|
789
|
+
npv = f"{row['npv_be']} mo" if row["npv_be"] else "N/A"
|
|
790
|
+
self.sens_tree.insert(
|
|
791
|
+
"",
|
|
792
|
+
tk.END,
|
|
793
|
+
values=(
|
|
794
|
+
f"{row['new_rate']:.2f}%",
|
|
795
|
+
f"${row['monthly_savings']:,.0f}",
|
|
796
|
+
simple,
|
|
797
|
+
npv,
|
|
798
|
+
f"${row['five_yr_npv']:,.0f}",
|
|
799
|
+
),
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
def _update_holding_period(self) -> None:
|
|
803
|
+
"""Update holding period analysis table."""
|
|
804
|
+
for row in self.holding_tree.get_children():
|
|
805
|
+
self.holding_tree.delete(row)
|
|
806
|
+
|
|
807
|
+
for row in self.holding_period_data:
|
|
808
|
+
tag = row["recommendation"].lower().replace(" ", "_")
|
|
809
|
+
self.holding_tree.insert(
|
|
810
|
+
"",
|
|
811
|
+
tk.END,
|
|
812
|
+
values=(
|
|
813
|
+
f"{row['years']} yr",
|
|
814
|
+
f"${row['nominal_savings']:,.0f}",
|
|
815
|
+
f"${row['npv']:,.0f}",
|
|
816
|
+
f"${row['npv_after_tax']:,.0f}",
|
|
817
|
+
row["recommendation"],
|
|
818
|
+
),
|
|
819
|
+
tags=(tag,),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
self.holding_tree.tag_configure("strong_yes", foreground="green")
|
|
823
|
+
self.holding_tree.tag_configure("yes", foreground="darkgreen")
|
|
824
|
+
self.holding_tree.tag_configure("marginal", foreground="orange")
|
|
825
|
+
self.holding_tree.tag_configure("no", foreground="red")
|
|
826
|
+
|
|
827
|
+
def _update_amortization(self) -> None:
|
|
828
|
+
"""Update amortization comparison table."""
|
|
829
|
+
for row in self.amort_tree.get_children():
|
|
830
|
+
self.amort_tree.delete(row)
|
|
831
|
+
|
|
832
|
+
cumulative_curr_interest = 0
|
|
833
|
+
cumulative_new_interest = 0
|
|
834
|
+
cumulative_interest_diff = 0
|
|
835
|
+
|
|
836
|
+
for row in self.amortization_data:
|
|
837
|
+
cumulative_curr_interest += row["current_interest"]
|
|
838
|
+
cumulative_new_interest += row["new_interest"]
|
|
839
|
+
|
|
840
|
+
int_diff = row["interest_diff"]
|
|
841
|
+
cumulative_interest_diff += int_diff
|
|
842
|
+
tag = "savings" if int_diff < 0 else "cost"
|
|
843
|
+
|
|
844
|
+
self.amort_tree.insert(
|
|
845
|
+
"",
|
|
846
|
+
tk.END,
|
|
847
|
+
values=(
|
|
848
|
+
row["year"],
|
|
849
|
+
f"${row['current_principal']:,.0f}",
|
|
850
|
+
f"${row['current_interest']:,.0f}",
|
|
851
|
+
f"${row['current_balance']:,.0f}",
|
|
852
|
+
f"${row['new_principal']:,.0f}",
|
|
853
|
+
f"${row['new_interest']:,.0f}",
|
|
854
|
+
f"${row['new_balance']:,.0f}",
|
|
855
|
+
f"${int_diff:+,.0f}",
|
|
856
|
+
f"${cumulative_interest_diff:+,.0f}",
|
|
857
|
+
),
|
|
858
|
+
tags=(tag,),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
self.amort_tree.tag_configure("savings", foreground="green")
|
|
862
|
+
self.amort_tree.tag_configure("cost", foreground="red")
|
|
863
|
+
|
|
864
|
+
total_savings = cumulative_curr_interest - cumulative_new_interest
|
|
865
|
+
|
|
866
|
+
self.amort_curr_total_int.config(text=f"${cumulative_curr_interest:,.0f}")
|
|
867
|
+
self.amort_new_total_int.config(text=f"${cumulative_new_interest:,.0f}")
|
|
868
|
+
|
|
869
|
+
if total_savings >= 0:
|
|
870
|
+
self.amort_int_savings.config(text=f"${total_savings:,.0f}", foreground="green")
|
|
871
|
+
else:
|
|
872
|
+
self.amort_int_savings.config(text=f"-${abs(total_savings):,.0f}", foreground="red")
|
|
873
|
+
|
|
874
|
+
def _update_amortization_balance_chart(self) -> None:
|
|
875
|
+
"""Update loan balance comparison chart."""
|
|
876
|
+
if not self.amortization_balance_chart:
|
|
877
|
+
return
|
|
878
|
+
self.amortization_balance_chart.plot(
|
|
879
|
+
self.current_amortization_schedule,
|
|
880
|
+
self.new_amortization_schedule,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def _export_csv(self) -> None:
|
|
884
|
+
"""Export main analysis data to CSV file."""
|
|
885
|
+
if not self.current_analysis:
|
|
886
|
+
messagebox.showwarning("No Data", "Run a calculation first.")
|
|
887
|
+
return
|
|
888
|
+
|
|
889
|
+
filepath = filedialog.asksaveasfilename(
|
|
890
|
+
defaultextension=".csv",
|
|
891
|
+
filetypes=[("CSV files", "*.csv")],
|
|
892
|
+
initialfile=f"refi_analysis_{datetime.now():%Y%m%d_%H%M%S}.csv",
|
|
893
|
+
)
|
|
894
|
+
if not filepath:
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
with open(filepath, "w", newline="") as f:
|
|
898
|
+
w = csv.DictWriter(
|
|
899
|
+
f,
|
|
900
|
+
fieldnames=["new_rate", "monthly_savings", "simple_be", "npv_be", "five_yr_npv"],
|
|
901
|
+
)
|
|
902
|
+
w.writeheader()
|
|
903
|
+
w.writerows(self.sensitivity_data)
|
|
904
|
+
|
|
905
|
+
messagebox.showinfo("Exported", f"Saved to {filepath}")
|
|
906
|
+
|
|
907
|
+
def _export_sensitivity_csv(self) -> None:
|
|
908
|
+
"""Export sensitivity analysis data to CSV file."""
|
|
909
|
+
if not self.sensitivity_data:
|
|
910
|
+
messagebox.showwarning("No Data", "Run a calculation first.")
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
filepath = filedialog.asksaveasfilename(
|
|
914
|
+
defaultextension=".csv",
|
|
915
|
+
filetypes=[("CSV files", "*.csv")],
|
|
916
|
+
initialfile=f"refi_sensitivity_{datetime.now():%Y%m%d_%H%M%S}.csv",
|
|
917
|
+
)
|
|
918
|
+
if not filepath:
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
with open(filepath, "w", newline="") as f:
|
|
922
|
+
w = csv.DictWriter(
|
|
923
|
+
f,
|
|
924
|
+
fieldnames=["new_rate", "monthly_savings", "simple_be", "npv_be", "five_yr_npv"],
|
|
925
|
+
)
|
|
926
|
+
w.writeheader()
|
|
927
|
+
w.writerows(self.sensitivity_data)
|
|
928
|
+
|
|
929
|
+
messagebox.showinfo("Exported", f"Saved to {filepath}")
|
|
930
|
+
|
|
931
|
+
def _export_holding_csv(self) -> None:
|
|
932
|
+
"""Export holding period analysis data to CSV file."""
|
|
933
|
+
if not self.holding_period_data:
|
|
934
|
+
messagebox.showwarning("No Data", "Run a calculation first.")
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
filepath = filedialog.asksaveasfilename(
|
|
938
|
+
defaultextension=".csv",
|
|
939
|
+
filetypes=[("CSV files", "*.csv")],
|
|
940
|
+
initialfile=f"refi_holding_period_{datetime.now():%Y%m%d_%H%M%S}.csv",
|
|
941
|
+
)
|
|
942
|
+
if not filepath:
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
with open(filepath, "w", newline="") as f:
|
|
946
|
+
w = csv.DictWriter(
|
|
947
|
+
f,
|
|
948
|
+
fieldnames=["years", "nominal_savings", "npv", "npv_after_tax", "recommendation"],
|
|
949
|
+
)
|
|
950
|
+
w.writeheader()
|
|
951
|
+
w.writerows(self.holding_period_data)
|
|
952
|
+
|
|
953
|
+
messagebox.showinfo("Exported", f"Saved to {filepath}")
|
|
954
|
+
|
|
955
|
+
def _export_amortization_csv(self) -> None:
|
|
956
|
+
"""Export amortization comparison data to CSV file."""
|
|
957
|
+
if not self.amortization_data:
|
|
958
|
+
messagebox.showwarning("No Data", "Run a calculation first.")
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
filepath = filedialog.asksaveasfilename(
|
|
962
|
+
defaultextension=".csv",
|
|
963
|
+
filetypes=[("CSV files", "*.csv")],
|
|
964
|
+
initialfile=f"refi_amortization_{datetime.now():%Y%m%d_%H%M%S}.csv",
|
|
965
|
+
)
|
|
966
|
+
if not filepath:
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
with open(filepath, "w", newline="") as f:
|
|
970
|
+
w = csv.DictWriter(
|
|
971
|
+
f,
|
|
972
|
+
fieldnames=[
|
|
973
|
+
"year",
|
|
974
|
+
"current_principal",
|
|
975
|
+
"current_interest",
|
|
976
|
+
"current_balance",
|
|
977
|
+
"new_principal",
|
|
978
|
+
"new_interest",
|
|
979
|
+
"new_balance",
|
|
980
|
+
"principal_diff",
|
|
981
|
+
"interest_diff",
|
|
982
|
+
"balance_diff",
|
|
983
|
+
],
|
|
984
|
+
)
|
|
985
|
+
w.writeheader()
|
|
986
|
+
w.writerows(self.amortization_data)
|
|
987
|
+
|
|
988
|
+
messagebox.showinfo("Exported", f"Saved to {filepath}")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def main() -> None:
|
|
992
|
+
"""Main driver function to run the refinance calculator app."""
|
|
993
|
+
root = tk.Tk()
|
|
994
|
+
root.geometry("1100x1040")
|
|
995
|
+
root.resizable(True, True)
|
|
996
|
+
root.minsize(940, 900)
|
|
997
|
+
RefinanceCalculatorApp(root)
|
|
998
|
+
root.mainloop()
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
__all__ = [
|
|
1002
|
+
"RefinanceCalculatorApp",
|
|
1003
|
+
"main",
|
|
1004
|
+
]
|
|
1005
|
+
|
|
1006
|
+
__description__ = """
|
|
1007
|
+
Tkinter UI for the refinance calculator application.
|
|
1008
|
+
"""
|