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,92 @@
|
|
|
1
|
+
"""Analysis tab builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..app import RefinanceCalculatorApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_sensitivity_tab(
|
|
14
|
+
app: RefinanceCalculatorApp,
|
|
15
|
+
parent: ttk.Frame,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Build the sensitivity analysis tree.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
app: App instance owning the sensitivity data.
|
|
21
|
+
parent: Container frame for the sensitivity tab.
|
|
22
|
+
"""
|
|
23
|
+
ttk.Label(
|
|
24
|
+
parent,
|
|
25
|
+
text="Sensitivity: Breakeven by New Rate",
|
|
26
|
+
font=("Segoe UI", 10, "bold"),
|
|
27
|
+
).pack(anchor=tk.W, pady=(0, 10))
|
|
28
|
+
|
|
29
|
+
columns = ("rate", "savings", "simple_be", "npv_be", "npv_5yr")
|
|
30
|
+
app.sens_tree = ttk.Treeview(parent, columns=columns, show="headings", height=10)
|
|
31
|
+
|
|
32
|
+
app.sens_tree.heading("rate", text="New Rate")
|
|
33
|
+
app.sens_tree.heading("savings", text="Monthly Savings")
|
|
34
|
+
app.sens_tree.heading("simple_be", text="Simple BE")
|
|
35
|
+
app.sens_tree.heading("npv_be", text="NPV BE")
|
|
36
|
+
app.sens_tree.heading("npv_5yr", text="5-Yr NPV")
|
|
37
|
+
|
|
38
|
+
for col in columns:
|
|
39
|
+
app.sens_tree.column(col, width=90, anchor=tk.CENTER)
|
|
40
|
+
|
|
41
|
+
app.sens_tree.pack(fill=tk.BOTH, expand=True)
|
|
42
|
+
ttk.Button(
|
|
43
|
+
parent,
|
|
44
|
+
text="Export Sensitivity CSV",
|
|
45
|
+
command=app._export_sensitivity_csv,
|
|
46
|
+
).pack(pady=10)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_holding_period_tab(
|
|
50
|
+
app: RefinanceCalculatorApp,
|
|
51
|
+
parent: ttk.Frame,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Build the holding period analysis tree.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
app: App instance owning the holding period data.
|
|
57
|
+
parent: Container frame for the holding period tab.
|
|
58
|
+
"""
|
|
59
|
+
ttk.Label(parent, text="NPV by Holding Period", font=("Segoe UI", 10, "bold")).pack(
|
|
60
|
+
anchor=tk.W,
|
|
61
|
+
pady=(0, 10),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
columns = ("years", "nominal_savings", "npv", "npv_after_tax", "recommendation")
|
|
65
|
+
app.holding_tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
|
|
66
|
+
|
|
67
|
+
app.holding_tree.heading("years", text="Hold (Years)")
|
|
68
|
+
app.holding_tree.heading("nominal_savings", text="Nominal Savings")
|
|
69
|
+
app.holding_tree.heading("npv", text="NPV")
|
|
70
|
+
app.holding_tree.heading("npv_after_tax", text="NPV (After-Tax)")
|
|
71
|
+
app.holding_tree.heading("recommendation", text="Recommendation")
|
|
72
|
+
|
|
73
|
+
app.holding_tree.column("years", width=80, anchor=tk.CENTER)
|
|
74
|
+
app.holding_tree.column("nominal_savings", width=100, anchor=tk.CENTER)
|
|
75
|
+
app.holding_tree.column("npv", width=90, anchor=tk.CENTER)
|
|
76
|
+
app.holding_tree.column("npv_after_tax", width=100, anchor=tk.CENTER)
|
|
77
|
+
app.holding_tree.column("recommendation", width=110, anchor=tk.CENTER)
|
|
78
|
+
|
|
79
|
+
app.holding_tree.pack(fill=tk.BOTH, expand=True)
|
|
80
|
+
ttk.Button(parent, text="Export Holding Period CSV", command=app._export_holding_csv).pack(
|
|
81
|
+
pady=10,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"build_sensitivity_tab",
|
|
87
|
+
"build_holding_period_tab",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
__description__ = """
|
|
91
|
+
Constructors for the analysis sub-tabs.
|
|
92
|
+
"""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Common helpers for building UI tabs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from tkinter import ttk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_input(
|
|
11
|
+
parent: tk.Misc,
|
|
12
|
+
label: str,
|
|
13
|
+
var: tk.StringVar,
|
|
14
|
+
row: int,
|
|
15
|
+
on_change: Callable[[], None],
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Add a labeled entry bound to the provided StringVar.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
parent: Parent frame hosting the entry row.
|
|
21
|
+
label: Text displayed next to the entry.
|
|
22
|
+
var: StringVar that stores the entry value.
|
|
23
|
+
row: Grid row for the label/entry pair.
|
|
24
|
+
on_change: Callback invoked when the value changes.
|
|
25
|
+
"""
|
|
26
|
+
ttk.Label(parent, text=label).grid(row=row, column=0, sticky=tk.W, pady=3)
|
|
27
|
+
entry = ttk.Entry(parent, textvariable=var, width=14)
|
|
28
|
+
entry.grid(row=row, column=1, sticky=tk.E, pady=3, padx=(10, 0))
|
|
29
|
+
entry.bind("<Return>", lambda e: on_change())
|
|
30
|
+
entry.bind("<FocusOut>", lambda e: on_change())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_option(
|
|
34
|
+
parent: tk.Misc,
|
|
35
|
+
label: str,
|
|
36
|
+
var: tk.StringVar,
|
|
37
|
+
row: int,
|
|
38
|
+
tooltip: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Add a labeled option input with descriptive tooltip.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
parent: Parent frame used for the input row.
|
|
44
|
+
label: Label text describing the option.
|
|
45
|
+
var: StringVar associated with the option entry.
|
|
46
|
+
row: Row index inside the grid layout.
|
|
47
|
+
tooltip: Supplemental explanation shown alongside input.
|
|
48
|
+
"""
|
|
49
|
+
ttk.Label(parent, text=label).grid(row=row, column=0, sticky=tk.W, pady=6)
|
|
50
|
+
entry = ttk.Entry(parent, textvariable=var, width=10)
|
|
51
|
+
entry.grid(row=row, column=1, sticky=tk.W, pady=6, padx=(10, 15))
|
|
52
|
+
ttk.Label(parent, text=tooltip, font=("Segoe UI", 8), foreground="#666").grid(
|
|
53
|
+
row=row,
|
|
54
|
+
column=2,
|
|
55
|
+
sticky=tk.W,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def result_block(
|
|
60
|
+
parent: ttk.Frame,
|
|
61
|
+
title: str,
|
|
62
|
+
col: int,
|
|
63
|
+
) -> ttk.Label:
|
|
64
|
+
"""Create a title + value display block within a row.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
parent: Parent frame that hosts the result block.
|
|
68
|
+
title: Title text displayed above the value.
|
|
69
|
+
col: Column index used for layout (unused but maintained for parity).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The label that shows the numeric value.
|
|
73
|
+
"""
|
|
74
|
+
frame = ttk.Frame(parent)
|
|
75
|
+
frame.pack(side=tk.LEFT, expand=True, fill=tk.X)
|
|
76
|
+
ttk.Label(frame, text=title, style="Header.TLabel").pack()
|
|
77
|
+
label = ttk.Label(frame, text="—", style="Result.TLabel")
|
|
78
|
+
label.pack(pady=2)
|
|
79
|
+
return label
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"add_input",
|
|
84
|
+
"add_option",
|
|
85
|
+
"result_block",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
__description__ = """
|
|
89
|
+
Shared helpers for building Tkinter tabs.
|
|
90
|
+
"""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Background and help info tab builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..app import RefinanceCalculatorApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_background_tab(
|
|
14
|
+
app: RefinanceCalculatorApp,
|
|
15
|
+
parent: ttk.Frame,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Build background information scroll area.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
app: Application instance that displays the background text.
|
|
21
|
+
parent: Frame that hosts the scrollable background content.
|
|
22
|
+
"""
|
|
23
|
+
canvas = tk.Canvas(parent, highlightthickness=0)
|
|
24
|
+
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
|
25
|
+
scroll_frame = ttk.Frame(canvas)
|
|
26
|
+
|
|
27
|
+
scroll_frame.bind(
|
|
28
|
+
"<Configure>",
|
|
29
|
+
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
|
30
|
+
)
|
|
31
|
+
canvas_window = canvas.create_window((0, 0), window=scroll_frame, anchor="nw")
|
|
32
|
+
canvas.configure(yscrollcommand=scrollbar.set)
|
|
33
|
+
|
|
34
|
+
def on_canvas_configure(event):
|
|
35
|
+
canvas.itemconfig(canvas_window, width=event.width)
|
|
36
|
+
|
|
37
|
+
canvas.bind("<Configure>", on_canvas_configure)
|
|
38
|
+
|
|
39
|
+
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
40
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
41
|
+
|
|
42
|
+
app._background_canvas = canvas
|
|
43
|
+
content = scroll_frame
|
|
44
|
+
|
|
45
|
+
ttk.Label(
|
|
46
|
+
content,
|
|
47
|
+
text="Refinancing: Background & Concepts",
|
|
48
|
+
font=("Segoe UI", 12, "bold"),
|
|
49
|
+
).pack(anchor=tk.W, pady=(0, 15))
|
|
50
|
+
|
|
51
|
+
sections = [
|
|
52
|
+
(
|
|
53
|
+
"What is Refinancing?",
|
|
54
|
+
"Refinancing replaces your existing mortgage with a new loan, typically to secure a lower interest rate, change the loan term, or access home equity (cash-out refinance). The new loan pays off your old mortgage, and you begin making payments on the new terms.\n\nCommon reasons to refinance:\n• Lower your interest rate and monthly payment\n• Shorten your loan term to pay off faster\n• Switch from adjustable-rate to fixed-rate (or vice versa)\n• Access equity for major expenses (cash-out refi)\n• Remove private mortgage insurance (PMI)",
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
"Key Costs to Consider",
|
|
58
|
+
"Refinancing isn't free. Typical closing costs run 2-5% of the loan amount and may include:\n\n• Origination fees (lender charges)\n• Appraisal fee ($300-$700)\n• Title search and insurance\n• Recording fees\n• Credit report fee\n• Prepaid interest, taxes, and insurance\n\nThese costs can be paid upfront, rolled into the loan balance, or covered by accepting a slightly higher rate (\"no-cost\" refi). Rolling costs into the loan means you'll pay interest on them over time.",
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
"The Breakeven Concept",
|
|
62
|
+
"The fundamental question: How long until your monthly savings recoup the closing costs?\n\nSimple Breakeven = Closing Costs ÷ Monthly Savings\n\nExample: $6,000 in costs with $200/month savings = 30 months to breakeven.\n\nIf you plan to stay in the home longer than the breakeven period, refinancing likely makes sense. If you might move or refinance again before breakeven, you'll lose money on the transaction.\n\nImportant caveat: Simple breakeven ignores the time value of money. A dollar saved three years from now is worth less than a dollar today.",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"Net Present Value (NPV)",
|
|
66
|
+
'NPV provides a more sophisticated analysis by discounting future savings to today\'s dollars. It accounts for the "opportunity cost" of your closing costs — what you could have earned by investing that money instead.\n\nNPV = -Closing Costs + Σ (Monthly Savings / (1 + r)^n)\n\nWhere r is the monthly discount rate and n is the month number.\n\nA positive NPV means refinancing creates value even after accounting for opportunity cost. The higher the NPV, the more clearly beneficial the refinance.',
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"The Term Reset Problem",
|
|
70
|
+
"A critical nuance many borrowers miss: refinancing often resets your amortization clock.\n\nExample: You're 5 years into a 30-year mortgage (25 years remaining). If you refinance into a new 30-year loan, you've added 5 years to your payoff timeline — even if your rate dropped.\n\nThis can dramatically increase total interest paid over the life of the loan, even with a lower rate. Solutions:\n• Refinance into a shorter term (e.g., 20 or 15 years)\n• Make extra principal payments to maintain your original payoff date\n• Compare total interest paid, not just monthly payment",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"Tax Implications",
|
|
74
|
+
"Mortgage interest is tax-deductible if you itemize deductions. This effectively reduces your true interest rate:\n\nAfter-Tax Rate = Nominal Rate × (1 - Marginal Tax Rate)\n\nExample: 6% rate with 24% marginal bracket = 4.56% effective rate\n\nNote: The 2017 tax law changes increased the standard deduction significantly, meaning fewer homeowners now itemize. If you take the standard deduction, there's no mortgage interest tax benefit.",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"Cash-Out Refinancing",
|
|
78
|
+
"A cash-out refi lets you borrow against your home equity, receiving the difference as cash. Your new loan balance equals your old balance plus the cash withdrawn plus closing costs.\n\nConsiderations:\n• You're converting home equity into debt\n• Monthly payment will likely increase even with a lower rate\n• Interest on cash-out amounts above original loan may not be tax-deductible\n• Good for consolidating high-interest debt or funding investments\n• Risky if used for consumption or if home values decline",
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"When NOT to Refinance",
|
|
82
|
+
"Refinancing isn't always beneficial:\n\n• Short holding period: If you'll move before breakeven\n• Small rate reduction: Less than 0.5-0.75% often doesn't justify costs\n• Extended payoff: If resetting to 30 years significantly increases total interest\n• High closing costs: Some lenders charge excessive fees\n• Credit issues: Poor credit may mean higher rates or denial\n• Equity constraints: Most lenders require 20%+ equity for best rates\n\nRule of thumb: A 1% rate reduction with typical costs usually breaks even in 2-3 years.",
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for title, text in sections:
|
|
87
|
+
ttk.Label(content, text=title, font=("Segoe UI", 10, "bold")).pack(
|
|
88
|
+
anchor=tk.W,
|
|
89
|
+
pady=(10, 5),
|
|
90
|
+
)
|
|
91
|
+
ttk.Label(
|
|
92
|
+
content,
|
|
93
|
+
text=text,
|
|
94
|
+
wraplength=450,
|
|
95
|
+
justify=tk.LEFT,
|
|
96
|
+
font=("Segoe UI", 9),
|
|
97
|
+
).pack(anchor=tk.W, padx=(10, 0), pady=(0, 5))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_help_tab(
|
|
101
|
+
app: RefinanceCalculatorApp,
|
|
102
|
+
parent: ttk.Frame,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Build help info tab UI.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
app: Application instance that displays the help text.
|
|
108
|
+
parent: Frame that hosts the scrollable help content.
|
|
109
|
+
"""
|
|
110
|
+
canvas = tk.Canvas(parent, highlightthickness=0)
|
|
111
|
+
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
|
112
|
+
scroll_frame = ttk.Frame(canvas)
|
|
113
|
+
|
|
114
|
+
scroll_frame.bind(
|
|
115
|
+
"<Configure>",
|
|
116
|
+
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
|
117
|
+
)
|
|
118
|
+
canvas_window = canvas.create_window((0, 0), window=scroll_frame, anchor="nw")
|
|
119
|
+
canvas.configure(yscrollcommand=scrollbar.set)
|
|
120
|
+
|
|
121
|
+
def on_canvas_configure(event):
|
|
122
|
+
canvas.itemconfig(canvas_window, width=event.width)
|
|
123
|
+
|
|
124
|
+
canvas.bind("<Configure>", on_canvas_configure)
|
|
125
|
+
|
|
126
|
+
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
127
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
128
|
+
|
|
129
|
+
app._help_canvas = canvas
|
|
130
|
+
content = scroll_frame
|
|
131
|
+
|
|
132
|
+
ttk.Label(content, text="Application Help", font=("Segoe UI", 12, "bold")).pack(
|
|
133
|
+
anchor=tk.W,
|
|
134
|
+
pady=(0, 15),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
sections = [
|
|
138
|
+
(
|
|
139
|
+
"Overview",
|
|
140
|
+
"This calculator helps you analyze whether refinancing your mortgage makes financial sense. It goes beyond simple breakeven calculations to provide NPV analysis, tax-adjusted figures, sensitivity tables, and visualizations.\n\nAll calculations update automatically when you change inputs or press Enter.",
|
|
141
|
+
),
|
|
142
|
+
(
|
|
143
|
+
"Calculator Tab",
|
|
144
|
+
"The main analysis screen with inputs and results.\n\nINPUTS - Current Loan:\n• Balance ($): Your remaining mortgage principal\n• Rate (%): Current annual interest rate\n• Years Remaining: Time left on your current loan\n\nINPUTS - New Loan:\n• Rate (%): Proposed new interest rate\n• Term (years): Length of new loan (typically 15, 20, or 30)\n• Closing Costs ($): Total refinance fees\n• Cash Out ($): Additional amount to borrow (0 for rate-only refi)\n• Opportunity Rate (%): Expected return on alternative investments\n• Marginal Tax Rate (%): Your tax bracket (0 if you don't itemize)",
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"Rate Sensitivity Tab",
|
|
148
|
+
'Shows how breakeven and NPV change at different new interest rates.\n\nThe table displays scenarios from your current rate down to the maximum reduction specified in Options (default: 2% in 0.25% steps).\n\nUse this to answer questions like:\n• "Should I wait for rates to drop further?"\n• "How much does each 0.25% reduction improve my outcome?"',
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
"Holding Period Tab",
|
|
152
|
+
"Shows NPV at various holding periods (1-20 years).\n\nThis helps when you're uncertain how long you'll stay in the home. The recommendation column provides guidance:\n• Strong Yes (green): NPV > $5,000\n• Yes (dark green): NPV > $0\n• Marginal (orange): NPV between -$2,000 and $0\n• No (red): NPV < -$2,000",
|
|
153
|
+
),
|
|
154
|
+
(
|
|
155
|
+
"Loan Visualizations Tab",
|
|
156
|
+
"The Loan Visualizations tab contains the annual amortization comparison table, which now includes a cumulative interest Δ column alongside colored savings/cost indicators so you can track how the refinance affects total interest year over year.",
|
|
157
|
+
),
|
|
158
|
+
(
|
|
159
|
+
"Charts within Loan Visualizations",
|
|
160
|
+
"Two charts live on the Loan Visualizations tab:\n\n"
|
|
161
|
+
"1. Cumulative Savings Chart — shows nominal (blue) and NPV-adjusted (green) savings with monthly ticks, a labeled zero line, and a dashed vertical line marking the NPV breakeven point.\n"
|
|
162
|
+
"2. Loan Balance Comparison Chart — plots the remaining balances for the current (red) and new (blue) loans so you can see how the term reset or accelerated payoff affects your timeline.",
|
|
163
|
+
),
|
|
164
|
+
(
|
|
165
|
+
"Options Tab",
|
|
166
|
+
"Customize calculation parameters:\n\n• NPV Window (years): Time horizon for NPV calculation displayed on main tab\n• Chart Horizon (years): How many years shown on the chart\n• Max Rate Reduction (%): How far below current rate to show in sensitivity table\n• Rate Step (%): Increment between rows in sensitivity table",
|
|
167
|
+
),
|
|
168
|
+
(
|
|
169
|
+
"Exporting Data",
|
|
170
|
+
"Export buttons are available on Calculator, Rate Sensitivity, Holding Period, and Amortization tabs. Files are saved with timestamps to avoid overwriting.",
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
for title, text in sections:
|
|
175
|
+
ttk.Label(content, text=title, font=("Segoe UI", 10, "bold")).pack(
|
|
176
|
+
anchor=tk.W,
|
|
177
|
+
pady=(10, 5),
|
|
178
|
+
)
|
|
179
|
+
ttk.Label(
|
|
180
|
+
content,
|
|
181
|
+
text=text,
|
|
182
|
+
wraplength=450,
|
|
183
|
+
justify=tk.LEFT,
|
|
184
|
+
font=("Segoe UI", 9),
|
|
185
|
+
).pack(anchor=tk.W, padx=(10, 0), pady=(0, 5))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"build_background_tab",
|
|
190
|
+
"build_help_tab",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
__description__ = """
|
|
194
|
+
Builders for the background and help tabs.
|
|
195
|
+
"""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Main calculator tab builder."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: PLR0915
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tkinter as tk
|
|
8
|
+
from tkinter import ttk
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .helpers import add_input, result_block
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..app import RefinanceCalculatorApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_main_tab(
|
|
18
|
+
app: RefinanceCalculatorApp,
|
|
19
|
+
parent: ttk.Frame,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Build the calculator tab inputs and result panels.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
app: Application instance that owns the tab data.
|
|
25
|
+
parent: Frame that hosts the calculator elements.
|
|
26
|
+
"""
|
|
27
|
+
input_frame = ttk.Frame(parent)
|
|
28
|
+
input_frame.pack(fill=tk.X, pady=(0, 10))
|
|
29
|
+
|
|
30
|
+
current_frame = ttk.LabelFrame(input_frame, text="Current Loan", padding=10)
|
|
31
|
+
current_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
|
|
32
|
+
|
|
33
|
+
add_input(current_frame, "Balance ($):", app.current_balance, 0, app._calculate)
|
|
34
|
+
add_input(current_frame, "Rate (%):", app.current_rate, 1, app._calculate)
|
|
35
|
+
add_input(current_frame, "Years Remaining:", app.current_remaining, 2, app._calculate)
|
|
36
|
+
|
|
37
|
+
new_frame = ttk.LabelFrame(input_frame, text="New Loan", padding=10)
|
|
38
|
+
new_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0))
|
|
39
|
+
|
|
40
|
+
add_input(new_frame, "Rate (%):", app.new_rate, 0, app._calculate)
|
|
41
|
+
add_input(new_frame, "Term (years):", app.new_term, 1, app._calculate)
|
|
42
|
+
add_input(new_frame, "Closing Costs ($):", app.closing_costs, 2, app._calculate)
|
|
43
|
+
add_input(new_frame, "Cash Out ($):", app.cash_out, 3, app._calculate)
|
|
44
|
+
add_input(new_frame, "Opportunity Rate (%):", app.opportunity_rate, 4, app._calculate)
|
|
45
|
+
add_input(new_frame, "Marginal Tax Rate (%):", app.marginal_tax_rate, 5, app._calculate)
|
|
46
|
+
|
|
47
|
+
maintain_frame = ttk.Frame(new_frame)
|
|
48
|
+
maintain_frame.grid(row=6, column=0, columnspan=2, sticky=tk.W, pady=(8, 0))
|
|
49
|
+
ttk.Checkbutton(
|
|
50
|
+
maintain_frame,
|
|
51
|
+
text="Maintain current payment (extra → principal)",
|
|
52
|
+
variable=app.maintain_payment,
|
|
53
|
+
command=app._calculate,
|
|
54
|
+
).pack(anchor=tk.W)
|
|
55
|
+
|
|
56
|
+
btn_frame = ttk.Frame(parent)
|
|
57
|
+
btn_frame.pack(pady=8)
|
|
58
|
+
ttk.Button(btn_frame, text="Calculate", command=app._calculate).pack(side=tk.LEFT, padx=5)
|
|
59
|
+
ttk.Button(btn_frame, text="Export CSV", command=app._export_csv).pack(
|
|
60
|
+
side=tk.LEFT,
|
|
61
|
+
padx=5,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
results_frame = ttk.LabelFrame(parent, text="Analysis Results", padding=12)
|
|
65
|
+
results_frame.pack(fill=tk.BOTH, expand=True)
|
|
66
|
+
|
|
67
|
+
style = ttk.Style()
|
|
68
|
+
style.configure("Header.TLabel", font=("Segoe UI", 9, "bold"))
|
|
69
|
+
style.configure("Result.TLabel", font=("Segoe UI", 10))
|
|
70
|
+
style.configure("Big.TLabel", font=("Segoe UI", 13, "bold"))
|
|
71
|
+
|
|
72
|
+
app.pay_frame = ttk.Frame(results_frame)
|
|
73
|
+
app.pay_frame.pack(fill=tk.X, pady=(0, 8))
|
|
74
|
+
app.current_pmt_label = result_block(app.pay_frame, "Current Payment", 0)
|
|
75
|
+
app.new_pmt_label = result_block(app.pay_frame, "New Payment", 1)
|
|
76
|
+
app.savings_label = result_block(app.pay_frame, "Monthly Δ", 2)
|
|
77
|
+
|
|
78
|
+
app.balance_frame = ttk.Frame(results_frame)
|
|
79
|
+
app.balance_frame.pack(fill=tk.X, pady=(0, 8))
|
|
80
|
+
app.new_balance_label = result_block(app.balance_frame, "New Loan Balance", 0)
|
|
81
|
+
app.cash_out_label = result_block(app.balance_frame, "Cash Out", 1)
|
|
82
|
+
result_block(app.balance_frame, "", 2)
|
|
83
|
+
app.balance_frame.pack_forget()
|
|
84
|
+
|
|
85
|
+
ttk.Separator(results_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
86
|
+
|
|
87
|
+
be_frame = ttk.Frame(results_frame)
|
|
88
|
+
be_frame.pack(fill=tk.X, pady=(0, 8))
|
|
89
|
+
app.simple_be_label = result_block(be_frame, "Simple Breakeven", 0)
|
|
90
|
+
app.npv_be_label = result_block(be_frame, "NPV Breakeven", 1)
|
|
91
|
+
|
|
92
|
+
ttk.Separator(results_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
93
|
+
|
|
94
|
+
int_frame = ttk.Frame(results_frame)
|
|
95
|
+
int_frame.pack(fill=tk.X, pady=(0, 8))
|
|
96
|
+
app.curr_int_label = result_block(int_frame, "Current Total Interest", 0)
|
|
97
|
+
app.new_int_label = result_block(int_frame, "New Total Interest", 1)
|
|
98
|
+
app.int_delta_label = result_block(int_frame, "Interest Δ", 2)
|
|
99
|
+
|
|
100
|
+
ttk.Separator(results_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
101
|
+
|
|
102
|
+
app.tax_section_label = ttk.Label(
|
|
103
|
+
results_frame,
|
|
104
|
+
text="After-Tax Analysis (0% marginal rate)",
|
|
105
|
+
style="Header.TLabel",
|
|
106
|
+
)
|
|
107
|
+
app.tax_section_label.pack(anchor=tk.W, pady=(0, 6))
|
|
108
|
+
|
|
109
|
+
tax_pay_frame = ttk.Frame(results_frame)
|
|
110
|
+
tax_pay_frame.pack(fill=tk.X, pady=(0, 8))
|
|
111
|
+
app.at_current_pmt_label = result_block(tax_pay_frame, "Current (After-Tax)", 0)
|
|
112
|
+
app.at_new_pmt_label = result_block(tax_pay_frame, "New (After-Tax)", 1)
|
|
113
|
+
app.at_savings_label = result_block(tax_pay_frame, "Monthly Δ (A-T)", 2)
|
|
114
|
+
|
|
115
|
+
tax_be_frame = ttk.Frame(results_frame)
|
|
116
|
+
tax_be_frame.pack(fill=tk.X, pady=(0, 8))
|
|
117
|
+
app.at_simple_be_label = result_block(tax_be_frame, "Simple BE (A-T)", 0)
|
|
118
|
+
app.at_npv_be_label = result_block(tax_be_frame, "NPV BE (A-T)", 1)
|
|
119
|
+
app.at_int_delta_label = result_block(tax_be_frame, "Interest Δ (A-T)", 2)
|
|
120
|
+
|
|
121
|
+
ttk.Separator(results_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
122
|
+
|
|
123
|
+
app.accel_section_frame = ttk.Frame(results_frame)
|
|
124
|
+
app.accel_section_label = ttk.Label(
|
|
125
|
+
app.accel_section_frame,
|
|
126
|
+
text="Accelerated Payoff (Maintain Payment)",
|
|
127
|
+
style="Header.TLabel",
|
|
128
|
+
)
|
|
129
|
+
app.accel_section_label.pack(anchor=tk.W, pady=(0, 6))
|
|
130
|
+
|
|
131
|
+
accel_row1 = ttk.Frame(app.accel_section_frame)
|
|
132
|
+
accel_row1.pack(fill=tk.X, pady=(0, 8))
|
|
133
|
+
app.accel_months_label = result_block(accel_row1, "Payoff Time", 0)
|
|
134
|
+
app.accel_time_saved_label = result_block(accel_row1, "Time Saved", 1)
|
|
135
|
+
app.accel_interest_saved_label = result_block(accel_row1, "Interest Saved", 2)
|
|
136
|
+
|
|
137
|
+
ttk.Separator(app.accel_section_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
138
|
+
|
|
139
|
+
npv_cost_frame = ttk.Frame(results_frame)
|
|
140
|
+
npv_cost_frame.pack(fill=tk.X, pady=(0, 8))
|
|
141
|
+
|
|
142
|
+
ttk.Label(results_frame, text="Total Cost NPV Analysis", style="Header.TLabel").pack(
|
|
143
|
+
anchor=tk.W,
|
|
144
|
+
pady=(0, 6),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
npv_cost_row = ttk.Frame(results_frame)
|
|
148
|
+
npv_cost_row.pack(fill=tk.X, pady=(0, 8))
|
|
149
|
+
app.current_cost_npv_label = result_block(npv_cost_row, "Current Loan NPV", 0)
|
|
150
|
+
app.new_cost_npv_label = result_block(npv_cost_row, "New Loan NPV", 1)
|
|
151
|
+
app.cost_npv_advantage_label = result_block(npv_cost_row, "NPV Advantage", 2)
|
|
152
|
+
|
|
153
|
+
ttk.Separator(results_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=6)
|
|
154
|
+
|
|
155
|
+
npv_frame = ttk.Frame(results_frame)
|
|
156
|
+
npv_frame.pack(fill=tk.X)
|
|
157
|
+
app.npv_title_label = ttk.Label(
|
|
158
|
+
npv_frame,
|
|
159
|
+
text="5-Year NPV of Refinancing",
|
|
160
|
+
style="Header.TLabel",
|
|
161
|
+
)
|
|
162
|
+
app.npv_title_label.pack()
|
|
163
|
+
app.five_yr_npv_label = ttk.Label(npv_frame, text="$0", style="Big.TLabel")
|
|
164
|
+
app.five_yr_npv_label.pack(pady=3)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"build_main_tab",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
__description__ = """
|
|
172
|
+
Builder for the primary calculator tab.
|
|
173
|
+
"""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Builders for the market data tab."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ...core.market.constants import MARKET_PERIOD_OPTIONS, MARKET_SERIES
|
|
10
|
+
from ..market_chart import MarketChart
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..app import RefinanceCalculatorApp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_market_tab(
|
|
17
|
+
app: RefinanceCalculatorApp,
|
|
18
|
+
parent: ttk.Frame,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Construct the market history tab in the main notebook.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
app: Application instance that owns the Tkinter state.
|
|
24
|
+
parent: Container frame that hosts the market history elements.
|
|
25
|
+
"""
|
|
26
|
+
ttk.Label(
|
|
27
|
+
parent,
|
|
28
|
+
text="Historical Mortgage Rates",
|
|
29
|
+
font=("Segoe UI", 11, "bold"),
|
|
30
|
+
).pack(anchor=tk.W, pady=(0, 6))
|
|
31
|
+
|
|
32
|
+
status_label = ttk.Label(
|
|
33
|
+
parent,
|
|
34
|
+
wraplength=720,
|
|
35
|
+
text="Loading market data...",
|
|
36
|
+
)
|
|
37
|
+
status_label.pack(anchor=tk.W, pady=(0, 6))
|
|
38
|
+
|
|
39
|
+
cache_indicator = ttk.Label(
|
|
40
|
+
parent,
|
|
41
|
+
text="Cache: initializing...",
|
|
42
|
+
font=("Segoe UI", 8),
|
|
43
|
+
foreground="#666",
|
|
44
|
+
)
|
|
45
|
+
cache_indicator.pack(anchor=tk.W, pady=(0, 6))
|
|
46
|
+
|
|
47
|
+
period_frame = ttk.Frame(parent)
|
|
48
|
+
period_frame.pack(fill=tk.X, pady=(0, 6))
|
|
49
|
+
ttk.Label(period_frame, text="Range:").pack(side=tk.LEFT)
|
|
50
|
+
for label, value in MARKET_PERIOD_OPTIONS:
|
|
51
|
+
ttk.Radiobutton(
|
|
52
|
+
period_frame,
|
|
53
|
+
text=label,
|
|
54
|
+
variable=app.market_period_var,
|
|
55
|
+
value=value,
|
|
56
|
+
command=app._populate_market_tab,
|
|
57
|
+
).pack(side=tk.LEFT, padx=(6, 0))
|
|
58
|
+
|
|
59
|
+
action_frame = ttk.Frame(parent)
|
|
60
|
+
action_frame.pack(fill=tk.X, pady=(0, 6))
|
|
61
|
+
ttk.Button(
|
|
62
|
+
action_frame,
|
|
63
|
+
text="Refresh Market Rates",
|
|
64
|
+
command=app._refresh_market_data,
|
|
65
|
+
).pack(side=tk.LEFT)
|
|
66
|
+
|
|
67
|
+
chart = MarketChart(parent)
|
|
68
|
+
chart.pack(fill=tk.X, pady=(0, 8))
|
|
69
|
+
|
|
70
|
+
table_frame = ttk.Frame(parent)
|
|
71
|
+
table_frame.pack(fill=tk.BOTH, expand=True)
|
|
72
|
+
|
|
73
|
+
labels = ["Date"] + [label for label, _ in MARKET_SERIES]
|
|
74
|
+
columns = ("date",) + tuple(label.lower().replace(" ", "_") for label in labels[1:])
|
|
75
|
+
tree = ttk.Treeview(table_frame, columns=columns, show="headings", height=12)
|
|
76
|
+
tree.heading("date", text="Date")
|
|
77
|
+
tree.column("date", width=130, anchor=tk.W)
|
|
78
|
+
for label, column in zip(labels[1:], columns[1:]):
|
|
79
|
+
tree.heading(column, text=label)
|
|
80
|
+
tree.column(column, width=100, anchor=tk.E)
|
|
81
|
+
|
|
82
|
+
scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
|
|
83
|
+
tree.configure(yscrollcommand=scrollbar.set)
|
|
84
|
+
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
85
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
86
|
+
|
|
87
|
+
legend_frame = ttk.Frame(parent)
|
|
88
|
+
legend_frame.pack(anchor=tk.W, pady=(6, 0))
|
|
89
|
+
colors = ["#2563eb", "#ec4899", "#16a34a", "#f59e0b"]
|
|
90
|
+
for idx, (label, _) in enumerate(MARKET_SERIES):
|
|
91
|
+
swatch = tk.Label(
|
|
92
|
+
legend_frame,
|
|
93
|
+
text=" ",
|
|
94
|
+
width=2,
|
|
95
|
+
bg=colors[idx % len(colors)],
|
|
96
|
+
relief=tk.SUNKEN,
|
|
97
|
+
)
|
|
98
|
+
swatch.pack(side=tk.LEFT, padx=(0, 2))
|
|
99
|
+
ttk.Label(
|
|
100
|
+
legend_frame,
|
|
101
|
+
text=label,
|
|
102
|
+
font=("Segoe UI", 8),
|
|
103
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
104
|
+
|
|
105
|
+
app.market_chart = chart
|
|
106
|
+
app.market_tree = tree
|
|
107
|
+
app._market_status_label = status_label
|
|
108
|
+
app._market_cache_indicator = cache_indicator
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["build_market_tab"]
|
|
112
|
+
|
|
113
|
+
__description__ = """
|
|
114
|
+
Builders for the market history tab inside the refinance calculator UI.
|
|
115
|
+
"""
|