refi-calculator 0.7.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.
Files changed (34) hide show
  1. refi_calculator/__init__.py +37 -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/fred.py +62 -0
  8. refi_calculator/core/models.py +131 -0
  9. refi_calculator/environment.py +124 -0
  10. refi_calculator/gui/__init__.py +12 -0
  11. refi_calculator/gui/app.py +1008 -0
  12. refi_calculator/gui/builders/analysis_tab.py +92 -0
  13. refi_calculator/gui/builders/helpers.py +90 -0
  14. refi_calculator/gui/builders/info_tab.py +195 -0
  15. refi_calculator/gui/builders/main_tab.py +173 -0
  16. refi_calculator/gui/builders/market_tab.py +115 -0
  17. refi_calculator/gui/builders/options_tab.py +81 -0
  18. refi_calculator/gui/builders/visuals_tab.py +128 -0
  19. refi_calculator/gui/chart.py +459 -0
  20. refi_calculator/gui/market_chart.py +192 -0
  21. refi_calculator/gui/market_constants.py +24 -0
  22. refi_calculator/web/__init__.py +11 -0
  23. refi_calculator/web/app.py +92 -0
  24. refi_calculator/web/calculator.py +317 -0
  25. refi_calculator/web/formatting.py +90 -0
  26. refi_calculator/web/info.py +226 -0
  27. refi_calculator/web/market.py +270 -0
  28. refi_calculator/web/results.py +455 -0
  29. refi_calculator/web/runner.py +22 -0
  30. refi_calculator-0.7.0.dist-info/METADATA +132 -0
  31. refi_calculator-0.7.0.dist-info/RECORD +34 -0
  32. refi_calculator-0.7.0.dist-info/WHEEL +4 -0
  33. refi_calculator-0.7.0.dist-info/entry_points.txt +4 -0
  34. refi_calculator-0.7.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,
27
+ width=400,
28
+ height=200,
29
+ ):
30
+ """Initialize SavingsChart.
31
+
32
+ Args:
33
+ parent: Parent Tkinter widget.
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,
299
+ width=400,
300
+ height=220,
301
+ ):
302
+ """Initialize AmortizationChart.
303
+
304
+ Args:
305
+ parent: Parent Tkinter widget.
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
+ """