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,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
+ """