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,81 @@
|
|
|
1
|
+
"""Options tab builder."""
|
|
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 .helpers import add_option
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..app import RefinanceCalculatorApp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_options_tab(
|
|
16
|
+
app: RefinanceCalculatorApp,
|
|
17
|
+
parent: ttk.Frame,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Build the options tab for NPV/chart settings.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
app: Application instance with option state.
|
|
23
|
+
parent: Frame that hosts the options controls.
|
|
24
|
+
"""
|
|
25
|
+
ttk.Label(parent, text="Application Options", font=("Segoe UI", 10, "bold")).pack(
|
|
26
|
+
anchor=tk.W,
|
|
27
|
+
pady=(0, 15),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
options_frame = ttk.LabelFrame(parent, text="NPV & Chart Settings", padding=15)
|
|
31
|
+
options_frame.pack(fill=tk.X, pady=(0, 10))
|
|
32
|
+
|
|
33
|
+
add_option(
|
|
34
|
+
options_frame,
|
|
35
|
+
"NPV Window (years):",
|
|
36
|
+
app.npv_window_years,
|
|
37
|
+
0,
|
|
38
|
+
"Time horizon for NPV calculation (e.g., 5 = 5-Year NPV)",
|
|
39
|
+
)
|
|
40
|
+
add_option(
|
|
41
|
+
options_frame,
|
|
42
|
+
"Chart Horizon (years):",
|
|
43
|
+
app.chart_horizon_years,
|
|
44
|
+
1,
|
|
45
|
+
"How many years to display on the cumulative savings chart",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
sens_frame = ttk.LabelFrame(parent, text="Sensitivity Analysis", padding=15)
|
|
49
|
+
sens_frame.pack(fill=tk.X, pady=(0, 10))
|
|
50
|
+
|
|
51
|
+
add_option(
|
|
52
|
+
sens_frame,
|
|
53
|
+
"Max Rate Reduction (%):",
|
|
54
|
+
app.sensitivity_max_reduction,
|
|
55
|
+
0,
|
|
56
|
+
"How far below current rate to analyze (e.g., 2.0 = current - 2%)",
|
|
57
|
+
)
|
|
58
|
+
add_option(
|
|
59
|
+
sens_frame,
|
|
60
|
+
"Rate Step (%):",
|
|
61
|
+
app.sensitivity_step,
|
|
62
|
+
1,
|
|
63
|
+
"Increment between rate scenarios (e.g., 0.25)",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
ttk.Button(parent, text="Apply & Recalculate", command=app._calculate).pack(pady=15)
|
|
67
|
+
ttk.Label(
|
|
68
|
+
parent,
|
|
69
|
+
text="Changes take effect after clicking 'Apply & Recalculate' or running a new calculation.",
|
|
70
|
+
font=("Segoe UI", 8),
|
|
71
|
+
foreground="#666",
|
|
72
|
+
).pack(anchor=tk.W)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"build_options_tab",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
__description__ = """
|
|
80
|
+
Constructs the options tab controls.
|
|
81
|
+
"""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Visual tab builders for amortization and chart views."""
|
|
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 ..chart import AmortizationChart, SavingsChart
|
|
10
|
+
from .helpers import result_block
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..app import RefinanceCalculatorApp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_amortization_tab(
|
|
17
|
+
app: RefinanceCalculatorApp,
|
|
18
|
+
parent: ttk.Frame,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Build amortization comparison tree and summary.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
app: Application instance owning the amortization data.
|
|
24
|
+
parent: Frame that contains amortization widgets.
|
|
25
|
+
"""
|
|
26
|
+
ttk.Label(
|
|
27
|
+
parent,
|
|
28
|
+
text="Amortization Schedule Comparison (Annual)",
|
|
29
|
+
font=("Segoe UI", 10, "bold"),
|
|
30
|
+
).pack(anchor=tk.W, pady=(0, 10))
|
|
31
|
+
|
|
32
|
+
tree_frame = ttk.Frame(parent)
|
|
33
|
+
tree_frame.pack(fill=tk.BOTH, expand=True)
|
|
34
|
+
|
|
35
|
+
columns = (
|
|
36
|
+
"year",
|
|
37
|
+
"curr_principal",
|
|
38
|
+
"curr_interest",
|
|
39
|
+
"curr_balance",
|
|
40
|
+
"new_principal",
|
|
41
|
+
"new_interest",
|
|
42
|
+
"new_balance",
|
|
43
|
+
"int_diff",
|
|
44
|
+
"cum_interest_diff",
|
|
45
|
+
)
|
|
46
|
+
app.amort_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", height=15)
|
|
47
|
+
|
|
48
|
+
app.amort_tree.heading("year", text="Year")
|
|
49
|
+
app.amort_tree.heading("curr_principal", text="Curr Principal")
|
|
50
|
+
app.amort_tree.heading("curr_interest", text="Curr Interest")
|
|
51
|
+
app.amort_tree.heading("curr_balance", text="Curr Balance")
|
|
52
|
+
app.amort_tree.heading("new_principal", text="New Principal")
|
|
53
|
+
app.amort_tree.heading("new_interest", text="New Interest")
|
|
54
|
+
app.amort_tree.heading("new_balance", text="New Balance")
|
|
55
|
+
app.amort_tree.heading("int_diff", text="Interest Δ")
|
|
56
|
+
app.amort_tree.heading("cum_interest_diff", text="Cumulative Interest Δ")
|
|
57
|
+
|
|
58
|
+
app.amort_tree.column("year", width=45, anchor=tk.CENTER)
|
|
59
|
+
app.amort_tree.column("curr_principal", width=85, anchor=tk.E)
|
|
60
|
+
app.amort_tree.column("curr_interest", width=80, anchor=tk.E)
|
|
61
|
+
app.amort_tree.column("curr_balance", width=85, anchor=tk.E)
|
|
62
|
+
app.amort_tree.column("new_principal", width=85, anchor=tk.E)
|
|
63
|
+
app.amort_tree.column("new_interest", width=80, anchor=tk.E)
|
|
64
|
+
app.amort_tree.column("new_balance", width=85, anchor=tk.E)
|
|
65
|
+
app.amort_tree.column("int_diff", width=70, anchor=tk.E)
|
|
66
|
+
app.amort_tree.column("cum_interest_diff", width=105, anchor=tk.E)
|
|
67
|
+
|
|
68
|
+
y_scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=app.amort_tree.yview)
|
|
69
|
+
app.amort_tree.configure(yscrollcommand=y_scroll.set)
|
|
70
|
+
|
|
71
|
+
app.amort_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
72
|
+
y_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
73
|
+
|
|
74
|
+
summary_frame = ttk.LabelFrame(parent, text="Cumulative Totals", padding=10)
|
|
75
|
+
summary_frame.pack(fill=tk.X, pady=(10, 5))
|
|
76
|
+
|
|
77
|
+
summary_cols = ttk.Frame(summary_frame)
|
|
78
|
+
summary_cols.pack(fill=tk.X)
|
|
79
|
+
|
|
80
|
+
app.amort_curr_total_int = result_block(summary_cols, "Current Total Interest", 0)
|
|
81
|
+
app.amort_new_total_int = result_block(summary_cols, "New Total Interest", 1)
|
|
82
|
+
app.amort_int_savings = result_block(summary_cols, "Interest Savings", 2)
|
|
83
|
+
|
|
84
|
+
ttk.Button(
|
|
85
|
+
parent,
|
|
86
|
+
text="Export Amortization CSV",
|
|
87
|
+
command=app._export_amortization_csv,
|
|
88
|
+
).pack(pady=10)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_chart_tab(
|
|
92
|
+
app: RefinanceCalculatorApp,
|
|
93
|
+
parent: ttk.Frame,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Build cumulative savings chart tab.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
app: App instance providing chart parameters.
|
|
99
|
+
parent: Frame used for the chart canvas.
|
|
100
|
+
"""
|
|
101
|
+
chart_years = int(float(app.chart_horizon_years.get() or 10))
|
|
102
|
+
ttk.Label(
|
|
103
|
+
parent,
|
|
104
|
+
text=f"Cumulative Savings Over Time ({chart_years} Years)",
|
|
105
|
+
font=("Segoe UI", 10, "bold"),
|
|
106
|
+
).pack(anchor=tk.W, pady=(0, 10))
|
|
107
|
+
|
|
108
|
+
app.chart = SavingsChart(parent, width=480, height=280)
|
|
109
|
+
app.chart.pack(fill=tk.BOTH, expand=True)
|
|
110
|
+
|
|
111
|
+
ttk.Label(
|
|
112
|
+
parent,
|
|
113
|
+
text="Loan Balance Comparison",
|
|
114
|
+
font=("Segoe UI", 10, "bold"),
|
|
115
|
+
).pack(anchor=tk.W, pady=(18, 6))
|
|
116
|
+
|
|
117
|
+
app.amortization_balance_chart = AmortizationChart(parent, width=480, height=240)
|
|
118
|
+
app.amortization_balance_chart.pack(fill=tk.BOTH, expand=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"build_amortization_tab",
|
|
123
|
+
"build_chart_tab",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
__description__ = """
|
|
127
|
+
Builders for the visuals sub-tabs.
|
|
128
|
+
"""
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Chart components for the refinance GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from ..core.charts import build_linear_ticks, build_month_ticks
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SavingsChart(tk.Canvas):
|
|
12
|
+
"""Canvas that draws cumulative savings / NPV trends.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
width: Canvas width.
|
|
16
|
+
height: Canvas height.
|
|
17
|
+
padding: Padding around the plot area.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
width: int
|
|
21
|
+
height: int
|
|
22
|
+
padding: dict[str, int]
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
parent: tk.Misc,
|
|
27
|
+
width: int = 400,
|
|
28
|
+
height: int = 200,
|
|
29
|
+
):
|
|
30
|
+
"""Initialize SavingsChart.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
parent: Parent Tkinter widget (any widget subclass).
|
|
34
|
+
width: Canvas width.
|
|
35
|
+
height: Canvas height.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(
|
|
38
|
+
parent,
|
|
39
|
+
width=width,
|
|
40
|
+
height=height,
|
|
41
|
+
bg="white",
|
|
42
|
+
highlightthickness=1,
|
|
43
|
+
highlightbackground="#ccc",
|
|
44
|
+
)
|
|
45
|
+
self.width = width
|
|
46
|
+
self.height = height
|
|
47
|
+
self.padding = {
|
|
48
|
+
"left": 60,
|
|
49
|
+
"right": 20,
|
|
50
|
+
"top": 20,
|
|
51
|
+
"bottom": 40,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def plot(
|
|
55
|
+
self,
|
|
56
|
+
data: list[tuple[int, float, float]],
|
|
57
|
+
breakeven: int | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Plot cumulative savings tuples and optional breakeven marker.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: List of (month, nominal savings, NPV savings) tuples.
|
|
63
|
+
breakeven: Optional breakeven month to mark on the chart.
|
|
64
|
+
"""
|
|
65
|
+
self.delete("all")
|
|
66
|
+
min_number_of_data_points = 2
|
|
67
|
+
if len(data) < min_number_of_data_points:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
months = [d[0] for d in data]
|
|
71
|
+
nominal = [d[1] for d in data]
|
|
72
|
+
npv = [d[2] for d in data]
|
|
73
|
+
|
|
74
|
+
all_values = nominal + npv
|
|
75
|
+
y_min, y_max = min(all_values), max(all_values)
|
|
76
|
+
if y_min == y_max:
|
|
77
|
+
expansion = abs(y_max) or 1.0
|
|
78
|
+
y_min -= expansion / 2
|
|
79
|
+
y_max += expansion / 2
|
|
80
|
+
y_range = y_max - y_min
|
|
81
|
+
if y_range == 0:
|
|
82
|
+
y_range = 1.0
|
|
83
|
+
|
|
84
|
+
plot_w = self.width - self.padding["left"] - self.padding["right"]
|
|
85
|
+
plot_h = self.height - self.padding["top"] - self.padding["bottom"]
|
|
86
|
+
|
|
87
|
+
max_month = max(months)
|
|
88
|
+
if max_month == 0:
|
|
89
|
+
max_month = 1
|
|
90
|
+
|
|
91
|
+
def to_canvas(month: int, value: float) -> tuple[float, float]:
|
|
92
|
+
x = self.padding["left"] + (month / max_month) * plot_w
|
|
93
|
+
y = self.padding["top"] + (1 - (value - y_min) / y_range) * plot_h
|
|
94
|
+
return x, y
|
|
95
|
+
|
|
96
|
+
if y_min < 0 < y_max:
|
|
97
|
+
self._draw_zero_reference(to_canvas)
|
|
98
|
+
|
|
99
|
+
if breakeven and breakeven <= max_month:
|
|
100
|
+
self._draw_breakeven_line(breakeven, to_canvas)
|
|
101
|
+
|
|
102
|
+
nominal_points = self._canvas_points(months, nominal, to_canvas)
|
|
103
|
+
npv_points = self._canvas_points(months, npv, to_canvas)
|
|
104
|
+
|
|
105
|
+
self._render_series(nominal_points, "#2563eb")
|
|
106
|
+
self._render_series(npv_points, "#16a34a")
|
|
107
|
+
|
|
108
|
+
left = self.padding["left"]
|
|
109
|
+
right = self.width - self.padding["right"]
|
|
110
|
+
bottom = self.height - self.padding["bottom"]
|
|
111
|
+
|
|
112
|
+
self._draw_axis_lines(left, bottom, right)
|
|
113
|
+
self._draw_month_ticks(left, bottom, plot_w, max_month)
|
|
114
|
+
self._draw_value_ticks(left, plot_h, bottom, y_min, y_max, y_range)
|
|
115
|
+
self._draw_range_labels(left, bottom, y_min, y_max)
|
|
116
|
+
self._draw_legend()
|
|
117
|
+
|
|
118
|
+
def _draw_zero_reference(
|
|
119
|
+
self,
|
|
120
|
+
to_canvas: Callable[[int, float], tuple[float, float]],
|
|
121
|
+
) -> None:
|
|
122
|
+
_, zero_y = to_canvas(0, 0)
|
|
123
|
+
self.create_line(
|
|
124
|
+
self.padding["left"],
|
|
125
|
+
zero_y,
|
|
126
|
+
self.width - self.padding["right"],
|
|
127
|
+
zero_y,
|
|
128
|
+
fill="#ccc",
|
|
129
|
+
dash=(4, 2),
|
|
130
|
+
)
|
|
131
|
+
self.create_text(
|
|
132
|
+
self.padding["left"] + 4,
|
|
133
|
+
zero_y - 5,
|
|
134
|
+
text="0 cumulative savings",
|
|
135
|
+
anchor=tk.SW,
|
|
136
|
+
font=("Segoe UI", 7),
|
|
137
|
+
fill="#666",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _draw_breakeven_line(
|
|
141
|
+
self,
|
|
142
|
+
breakeven: int,
|
|
143
|
+
to_canvas: Callable[[int, float], tuple[float, float]],
|
|
144
|
+
) -> None:
|
|
145
|
+
be_x, _ = to_canvas(breakeven, 0)
|
|
146
|
+
self.create_line(
|
|
147
|
+
be_x,
|
|
148
|
+
self.padding["top"],
|
|
149
|
+
be_x,
|
|
150
|
+
self.height - self.padding["bottom"],
|
|
151
|
+
fill="#888",
|
|
152
|
+
dash=(2, 2),
|
|
153
|
+
)
|
|
154
|
+
self.create_text(
|
|
155
|
+
be_x,
|
|
156
|
+
self.padding["top"] - 5,
|
|
157
|
+
text=f"BE: {breakeven}mo",
|
|
158
|
+
font=("Segoe UI", 7),
|
|
159
|
+
fill="#666",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _canvas_points(
|
|
163
|
+
self,
|
|
164
|
+
months: list[int],
|
|
165
|
+
values: list[float],
|
|
166
|
+
to_canvas: Callable[[int, float], tuple[float, float]],
|
|
167
|
+
) -> list[tuple[float, float]]:
|
|
168
|
+
return [to_canvas(month, value) for month, value in zip(months, values)]
|
|
169
|
+
|
|
170
|
+
def _render_series(self, points: list[tuple[float, float]], color: str) -> None:
|
|
171
|
+
if len(points) > 1:
|
|
172
|
+
self.create_line(
|
|
173
|
+
*[coord for point in points for coord in point],
|
|
174
|
+
fill=color,
|
|
175
|
+
width=2,
|
|
176
|
+
smooth=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _draw_axis_lines(self, left: float, bottom: float, right: float) -> None:
|
|
180
|
+
self.create_line(
|
|
181
|
+
left,
|
|
182
|
+
self.padding["top"],
|
|
183
|
+
left,
|
|
184
|
+
bottom,
|
|
185
|
+
fill="#333",
|
|
186
|
+
)
|
|
187
|
+
self.create_line(
|
|
188
|
+
left,
|
|
189
|
+
bottom,
|
|
190
|
+
right,
|
|
191
|
+
bottom,
|
|
192
|
+
fill="#333",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _draw_month_ticks(
|
|
196
|
+
self,
|
|
197
|
+
left: float,
|
|
198
|
+
bottom: float,
|
|
199
|
+
plot_width: float,
|
|
200
|
+
max_month: int,
|
|
201
|
+
) -> None:
|
|
202
|
+
for month in build_month_ticks(max_month):
|
|
203
|
+
x = left + (month / max_month) * plot_width
|
|
204
|
+
self.create_line(x, bottom, x, bottom + 5, fill="#999")
|
|
205
|
+
self.create_text(
|
|
206
|
+
x,
|
|
207
|
+
bottom + 12,
|
|
208
|
+
text=f"{month} mo",
|
|
209
|
+
anchor=tk.N,
|
|
210
|
+
font=("Segoe UI", 7),
|
|
211
|
+
fill="#666",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _draw_value_ticks(
|
|
215
|
+
self,
|
|
216
|
+
left: float,
|
|
217
|
+
plot_height: float,
|
|
218
|
+
bottom: float,
|
|
219
|
+
y_min: float,
|
|
220
|
+
y_max: float,
|
|
221
|
+
y_range: float,
|
|
222
|
+
) -> None:
|
|
223
|
+
top = self.padding["top"]
|
|
224
|
+
for value in build_linear_ticks(y_min, y_max):
|
|
225
|
+
y = top + (1 - (value - y_min) / y_range) * plot_height
|
|
226
|
+
self.create_line(left - 5, y, left, y, fill="#999")
|
|
227
|
+
self.create_text(
|
|
228
|
+
left - 8,
|
|
229
|
+
y,
|
|
230
|
+
text=f"${value / 1000:.0f}k",
|
|
231
|
+
anchor=tk.E,
|
|
232
|
+
font=("Segoe UI", 7),
|
|
233
|
+
fill="#666",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _draw_range_labels(self, left: float, bottom: float, y_min: float, y_max: float) -> None:
|
|
237
|
+
self.create_text(
|
|
238
|
+
self.width // 2,
|
|
239
|
+
self.height - 8,
|
|
240
|
+
text="Months",
|
|
241
|
+
font=("Segoe UI", 8),
|
|
242
|
+
fill="#666",
|
|
243
|
+
)
|
|
244
|
+
self.create_text(
|
|
245
|
+
left - 5,
|
|
246
|
+
self.padding["top"],
|
|
247
|
+
text=f"${y_max / 1000:.0f}k",
|
|
248
|
+
anchor=tk.E,
|
|
249
|
+
font=("Segoe UI", 7),
|
|
250
|
+
fill="#666",
|
|
251
|
+
)
|
|
252
|
+
self.create_text(
|
|
253
|
+
left - 5,
|
|
254
|
+
bottom,
|
|
255
|
+
text=f"${y_min / 1000:.0f}k",
|
|
256
|
+
anchor=tk.E,
|
|
257
|
+
font=("Segoe UI", 7),
|
|
258
|
+
fill="#666",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _draw_legend(self) -> None:
|
|
262
|
+
legend_x = self.width - self.padding["right"] - 90
|
|
263
|
+
self.create_line(legend_x, 14, legend_x + 24, 14, fill="#2563eb", width=2)
|
|
264
|
+
self.create_text(
|
|
265
|
+
legend_x + 28,
|
|
266
|
+
14,
|
|
267
|
+
text="Nominal",
|
|
268
|
+
anchor=tk.W,
|
|
269
|
+
font=("Segoe UI", 7),
|
|
270
|
+
fill="#666",
|
|
271
|
+
)
|
|
272
|
+
self.create_line(legend_x, 26, legend_x + 24, 26, fill="#16a34a", width=2)
|
|
273
|
+
self.create_text(
|
|
274
|
+
legend_x + 28,
|
|
275
|
+
26,
|
|
276
|
+
text="NPV",
|
|
277
|
+
anchor=tk.W,
|
|
278
|
+
font=("Segoe UI", 7),
|
|
279
|
+
fill="#666",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class AmortizationChart(tk.Canvas):
|
|
284
|
+
"""Chart showing remaining balances for current and new loans.
|
|
285
|
+
|
|
286
|
+
Attributes:
|
|
287
|
+
width: Canvas width.
|
|
288
|
+
height: Canvas height.
|
|
289
|
+
padding: Plot padding.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
width: int
|
|
293
|
+
height: int
|
|
294
|
+
padding: dict[str, int]
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
parent: tk.Misc,
|
|
299
|
+
width: int = 400,
|
|
300
|
+
height: int = 220,
|
|
301
|
+
):
|
|
302
|
+
"""Initialize AmortizationChart.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
parent: Parent Tkinter widget (any widget subclass).
|
|
306
|
+
width: Canvas width.
|
|
307
|
+
height: Canvas height.
|
|
308
|
+
"""
|
|
309
|
+
super().__init__(
|
|
310
|
+
parent,
|
|
311
|
+
width=width,
|
|
312
|
+
height=height,
|
|
313
|
+
bg="white",
|
|
314
|
+
highlightthickness=1,
|
|
315
|
+
highlightbackground="#ccc",
|
|
316
|
+
)
|
|
317
|
+
self.width = width
|
|
318
|
+
self.height = height
|
|
319
|
+
self.padding = {
|
|
320
|
+
"left": 60,
|
|
321
|
+
"right": 20,
|
|
322
|
+
"top": 30,
|
|
323
|
+
"bottom": 40,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def plot(
|
|
327
|
+
self,
|
|
328
|
+
current_schedule: list[dict],
|
|
329
|
+
new_schedule: list[dict],
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Plot remaining balances for both loans.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
current_schedule: Monthly data for the current loan.
|
|
335
|
+
new_schedule: Monthly data for the new loan.
|
|
336
|
+
"""
|
|
337
|
+
self.delete("all")
|
|
338
|
+
if not current_schedule or not new_schedule:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
current_months = [row["month"] for row in current_schedule]
|
|
342
|
+
current_balances = [row["balance"] for row in current_schedule]
|
|
343
|
+
new_months = [row["month"] for row in new_schedule]
|
|
344
|
+
new_balances = [row["balance"] for row in new_schedule]
|
|
345
|
+
|
|
346
|
+
max_month = max(current_months[-1], new_months[-1])
|
|
347
|
+
if max_month == 0:
|
|
348
|
+
max_month = 1
|
|
349
|
+
|
|
350
|
+
all_balances = [0.0]
|
|
351
|
+
all_balances.extend(current_balances)
|
|
352
|
+
all_balances.extend(new_balances)
|
|
353
|
+
max_balance = max(all_balances)
|
|
354
|
+
y_min = 0.0
|
|
355
|
+
y_max = max_balance if max_balance > 0 else 1.0
|
|
356
|
+
y_range = y_max - y_min if y_max != y_min else 1.0
|
|
357
|
+
|
|
358
|
+
plot_w = self.width - self.padding["left"] - self.padding["right"]
|
|
359
|
+
plot_h = self.height - self.padding["top"] - self.padding["bottom"]
|
|
360
|
+
|
|
361
|
+
def to_canvas(month: int, balance: float) -> tuple[float, float]:
|
|
362
|
+
x = self.padding["left"] + (month / max_month) * plot_w
|
|
363
|
+
y = self.padding["top"] + (1 - (balance - y_min) / y_range) * plot_h
|
|
364
|
+
return x, y
|
|
365
|
+
|
|
366
|
+
current_points = [to_canvas(m, b) for m, b in zip(current_months, current_balances)]
|
|
367
|
+
new_points = [to_canvas(m, b) for m, b in zip(new_months, new_balances)]
|
|
368
|
+
|
|
369
|
+
if len(current_points) > 1:
|
|
370
|
+
self.create_line(
|
|
371
|
+
*[value for point in current_points for value in point],
|
|
372
|
+
fill="#dc2626",
|
|
373
|
+
width=2,
|
|
374
|
+
smooth=True,
|
|
375
|
+
)
|
|
376
|
+
if len(new_points) > 1:
|
|
377
|
+
self.create_line(
|
|
378
|
+
*[value for point in new_points for value in point],
|
|
379
|
+
fill="#2563eb",
|
|
380
|
+
width=2,
|
|
381
|
+
smooth=True,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
left = self.padding["left"]
|
|
385
|
+
bottom = self.height - self.padding["bottom"]
|
|
386
|
+
right = self.width - self.padding["right"]
|
|
387
|
+
|
|
388
|
+
self.create_line(left, self.padding["top"], left, bottom, fill="#333")
|
|
389
|
+
self.create_line(left, bottom, right, bottom, fill="#333")
|
|
390
|
+
|
|
391
|
+
for month in build_month_ticks(max_month):
|
|
392
|
+
x = left + (month / max_month) * plot_w
|
|
393
|
+
self.create_line(x, bottom, x, bottom + 5, fill="#999")
|
|
394
|
+
self.create_text(
|
|
395
|
+
x,
|
|
396
|
+
bottom + 12,
|
|
397
|
+
text=f"{month} mo",
|
|
398
|
+
anchor=tk.N,
|
|
399
|
+
font=("Segoe UI", 7),
|
|
400
|
+
fill="#666",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
for value in build_linear_ticks(y_min, y_max):
|
|
404
|
+
y = self.padding["top"] + (1 - (value - y_min) / y_range) * plot_h
|
|
405
|
+
self.create_line(left - 5, y, left, y, fill="#999")
|
|
406
|
+
self.create_text(
|
|
407
|
+
left - 8,
|
|
408
|
+
y,
|
|
409
|
+
text=f"${value / 1000:.0f}k",
|
|
410
|
+
anchor=tk.E,
|
|
411
|
+
font=("Segoe UI", 7),
|
|
412
|
+
fill="#666",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
self.create_text(
|
|
416
|
+
self.padding["left"],
|
|
417
|
+
self.padding["top"] - 6,
|
|
418
|
+
text="Remaining Balance",
|
|
419
|
+
anchor=tk.SW,
|
|
420
|
+
font=("Segoe UI", 7),
|
|
421
|
+
fill="#666",
|
|
422
|
+
)
|
|
423
|
+
self.create_text(
|
|
424
|
+
self.width // 2,
|
|
425
|
+
self.height - 8,
|
|
426
|
+
text="Months",
|
|
427
|
+
font=("Segoe UI", 8),
|
|
428
|
+
fill="#666",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
legend_x = self.width - self.padding["right"] - 90
|
|
432
|
+
self.create_line(legend_x, 14, legend_x + 24, 14, fill="#dc2626", width=2)
|
|
433
|
+
self.create_text(
|
|
434
|
+
legend_x + 28,
|
|
435
|
+
14,
|
|
436
|
+
text="Current",
|
|
437
|
+
anchor=tk.W,
|
|
438
|
+
font=("Segoe UI", 7),
|
|
439
|
+
fill="#666",
|
|
440
|
+
)
|
|
441
|
+
self.create_line(legend_x, 26, legend_x + 24, 26, fill="#2563eb", width=2)
|
|
442
|
+
self.create_text(
|
|
443
|
+
legend_x + 28,
|
|
444
|
+
26,
|
|
445
|
+
text="New",
|
|
446
|
+
anchor=tk.W,
|
|
447
|
+
font=("Segoe UI", 7),
|
|
448
|
+
fill="#666",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
__all__ = [
|
|
453
|
+
"AmortizationChart",
|
|
454
|
+
"SavingsChart",
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
__description__ = """
|
|
458
|
+
Canvas helpers for cumulative savings and amortization comparison visuals.
|
|
459
|
+
"""
|