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.
- refi_calculator/__init__.py +37 -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/fred.py +62 -0
- refi_calculator/core/models.py +131 -0
- refi_calculator/environment.py +124 -0
- refi_calculator/gui/__init__.py +12 -0
- refi_calculator/gui/app.py +1008 -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/gui/market_constants.py +24 -0
- refi_calculator/web/__init__.py +11 -0
- refi_calculator/web/app.py +92 -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.7.0.dist-info/METADATA +132 -0
- refi_calculator-0.7.0.dist-info/RECORD +34 -0
- refi_calculator-0.7.0.dist-info/WHEEL +4 -0
- refi_calculator-0.7.0.dist-info/entry_points.txt +4 -0
- refi_calculator-0.7.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Canvas for plotting historical market rates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarketChart(tk.Canvas):
|
|
10
|
+
"""Simple line chart for market rate series.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
width: Canvas width.
|
|
14
|
+
height: Canvas height.
|
|
15
|
+
padding: Chart padding.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
width: int
|
|
19
|
+
height: int
|
|
20
|
+
padding: dict[str, int]
|
|
21
|
+
|
|
22
|
+
def __init__(self, parent: tk.Misc, width: int = 780, height: int = 220):
|
|
23
|
+
"""Initialize the canvas.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
parent: Parent widget for the chart.
|
|
27
|
+
width: Chart width in pixels.
|
|
28
|
+
height: Chart height in pixels.
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(
|
|
31
|
+
parent,
|
|
32
|
+
width=width,
|
|
33
|
+
height=height,
|
|
34
|
+
bg="white",
|
|
35
|
+
highlightthickness=1,
|
|
36
|
+
highlightbackground="#ccc",
|
|
37
|
+
)
|
|
38
|
+
self.width = width
|
|
39
|
+
self.height = height
|
|
40
|
+
self.padding = {"left": 70, "right": 20, "top": 20, "bottom": 40}
|
|
41
|
+
|
|
42
|
+
def plot(self, series_data: dict[str, list[tuple[datetime, float]]]) -> None:
|
|
43
|
+
"""Draw a multi-line chart for the supplied rate series.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
series_data: Mapping of series label to date/rate pairs (newest-first).
|
|
47
|
+
"""
|
|
48
|
+
self.delete("all")
|
|
49
|
+
filtered = {
|
|
50
|
+
label: list(reversed(points)) for label, points in series_data.items() if points
|
|
51
|
+
}
|
|
52
|
+
if not filtered:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
all_values = [rate for points in filtered.values() for _, rate in points]
|
|
56
|
+
if not all_values:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
min_rate = min(all_values)
|
|
60
|
+
max_rate = max(all_values)
|
|
61
|
+
rate_range = max_rate - min_rate if max_rate != min_rate else 1
|
|
62
|
+
|
|
63
|
+
plot_width = self.width - self.padding["left"] - self.padding["right"]
|
|
64
|
+
plot_height = self.height - self.padding["top"] - self.padding["bottom"]
|
|
65
|
+
|
|
66
|
+
def x_coord(idx: int, total: int) -> float:
|
|
67
|
+
return self.padding["left"] + (idx / max(total - 1, 1)) * plot_width
|
|
68
|
+
|
|
69
|
+
def y_coord(value: float) -> float:
|
|
70
|
+
return self.padding["top"] + (1 - (value - min_rate) / rate_range) * plot_height
|
|
71
|
+
|
|
72
|
+
colors = ["#2563eb", "#ec4899", "#16a34a", "#f59e0b"]
|
|
73
|
+
for idx, (label, points) in enumerate(filtered.items()):
|
|
74
|
+
coords = [
|
|
75
|
+
(x_coord(i, len(points)), y_coord(rate)) for i, (_, rate) in enumerate(points)
|
|
76
|
+
]
|
|
77
|
+
min_coords = 2
|
|
78
|
+
if len(coords) < min_coords:
|
|
79
|
+
continue
|
|
80
|
+
self.create_line(
|
|
81
|
+
*[component for point in coords for component in point],
|
|
82
|
+
fill=colors[idx % len(colors)],
|
|
83
|
+
width=2,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.create_line(
|
|
87
|
+
self.padding["left"],
|
|
88
|
+
self.padding["top"],
|
|
89
|
+
self.padding["left"],
|
|
90
|
+
self.height - self.padding["bottom"],
|
|
91
|
+
fill="#333",
|
|
92
|
+
)
|
|
93
|
+
self.create_line(
|
|
94
|
+
self.padding["left"],
|
|
95
|
+
self.height - self.padding["bottom"],
|
|
96
|
+
self.width - self.padding["right"],
|
|
97
|
+
self.height - self.padding["bottom"],
|
|
98
|
+
fill="#333",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Y-axis ticks
|
|
102
|
+
tick_count_y = 4
|
|
103
|
+
for idx in range(tick_count_y + 1):
|
|
104
|
+
rate_value = min_rate + (rate_range / tick_count_y) * idx
|
|
105
|
+
y = y_coord(rate_value)
|
|
106
|
+
self.create_line(
|
|
107
|
+
self.padding["left"] - 8,
|
|
108
|
+
y,
|
|
109
|
+
self.padding["left"],
|
|
110
|
+
y,
|
|
111
|
+
fill="#333",
|
|
112
|
+
)
|
|
113
|
+
self.create_text(
|
|
114
|
+
self.padding["left"] - 14,
|
|
115
|
+
y,
|
|
116
|
+
text=f"{rate_value:.2f}%",
|
|
117
|
+
anchor=tk.E,
|
|
118
|
+
font=("Segoe UI", 7),
|
|
119
|
+
fill="#666",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# X-axis ticks
|
|
123
|
+
sample_points = next(iter(filtered.values()))
|
|
124
|
+
total_points = len(sample_points)
|
|
125
|
+
tick_step = max(1, total_points // 5)
|
|
126
|
+
tick_indices = list(range(0, total_points, tick_step))
|
|
127
|
+
if total_points - 1 not in tick_indices:
|
|
128
|
+
tick_indices.append(total_points - 1)
|
|
129
|
+
|
|
130
|
+
for idx in tick_indices:
|
|
131
|
+
x = x_coord(idx, total_points)
|
|
132
|
+
self.create_line(
|
|
133
|
+
x,
|
|
134
|
+
self.height - self.padding["bottom"],
|
|
135
|
+
x,
|
|
136
|
+
self.height - self.padding["bottom"] + 4,
|
|
137
|
+
fill="#333",
|
|
138
|
+
)
|
|
139
|
+
date_label = sample_points[idx][0].strftime("%Y-%m-%d")
|
|
140
|
+
self.create_text(
|
|
141
|
+
x,
|
|
142
|
+
self.height - self.padding["bottom"] + 14,
|
|
143
|
+
text=date_label,
|
|
144
|
+
anchor=tk.N,
|
|
145
|
+
font=("Segoe UI", 7),
|
|
146
|
+
fill="#666",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Axis labels
|
|
150
|
+
self.create_text(
|
|
151
|
+
self.width // 2,
|
|
152
|
+
self.height - 10,
|
|
153
|
+
text="Date (oldest → newest)",
|
|
154
|
+
font=("Segoe UI", 8, "bold"),
|
|
155
|
+
fill="#444",
|
|
156
|
+
)
|
|
157
|
+
self.create_text(
|
|
158
|
+
self.padding["left"] - 55,
|
|
159
|
+
(self.height + self.padding["top"] - self.padding["bottom"]) // 2,
|
|
160
|
+
text="Rate (%)",
|
|
161
|
+
angle=90,
|
|
162
|
+
font=("Segoe UI", 8, "bold"),
|
|
163
|
+
fill="#444",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
legend_x = self.width - self.padding["right"] - 110
|
|
167
|
+
legend_y = self.padding["top"] + 10
|
|
168
|
+
for idx, label in enumerate(filtered.keys()):
|
|
169
|
+
color = colors[idx % len(colors)]
|
|
170
|
+
self.create_line(
|
|
171
|
+
legend_x,
|
|
172
|
+
legend_y + idx * 16,
|
|
173
|
+
legend_x + 20,
|
|
174
|
+
legend_y + idx * 16,
|
|
175
|
+
fill=color,
|
|
176
|
+
width=2,
|
|
177
|
+
)
|
|
178
|
+
self.create_text(
|
|
179
|
+
legend_x + 25,
|
|
180
|
+
legend_y + idx * 16,
|
|
181
|
+
text=label,
|
|
182
|
+
anchor=tk.W,
|
|
183
|
+
font=("Segoe UI", 8),
|
|
184
|
+
fill="#444",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = ["MarketChart"]
|
|
189
|
+
|
|
190
|
+
__description__ = """
|
|
191
|
+
Canvas helper for plotting historical mortgage rate series.
|
|
192
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Constants shared by the market data UI components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MARKET_SERIES: list[tuple[str, str]] = [
|
|
6
|
+
("30-Year", "MORTGAGE30US"),
|
|
7
|
+
("15-Year", "MORTGAGE15US"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
MARKET_PERIOD_OPTIONS: list[tuple[str, str]] = [
|
|
11
|
+
("1 Year", "12"),
|
|
12
|
+
("2 Years", "24"),
|
|
13
|
+
("5 Years", "60"),
|
|
14
|
+
("All", "0"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
MARKET_DEFAULT_PERIOD = "12"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["MARKET_SERIES", "MARKET_PERIOD_OPTIONS", "MARKET_DEFAULT_PERIOD"]
|
|
21
|
+
|
|
22
|
+
__description__ = """
|
|
23
|
+
Constants used by the market data tab.
|
|
24
|
+
"""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Streamlit web interface for the refinance calculator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
|
|
7
|
+
import streamlit as st
|
|
8
|
+
|
|
9
|
+
from refi_calculator.core.models import RefinanceAnalysis
|
|
10
|
+
from refi_calculator.web.calculator import (
|
|
11
|
+
CalculatorInputs,
|
|
12
|
+
collect_inputs,
|
|
13
|
+
ensure_option_state,
|
|
14
|
+
prepare_auxiliary_data,
|
|
15
|
+
run_analysis,
|
|
16
|
+
)
|
|
17
|
+
from refi_calculator.web.info import render_info_tab
|
|
18
|
+
from refi_calculator.web.market import render_market_tab
|
|
19
|
+
from refi_calculator.web.results import (
|
|
20
|
+
render_analysis_tab,
|
|
21
|
+
render_loan_visualizations_tab,
|
|
22
|
+
render_options_tab,
|
|
23
|
+
render_results,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""Render the refinance calculator Streamlit application."""
|
|
31
|
+
logger.debug("Rendering Streamlit refinance calculator main screen.")
|
|
32
|
+
ensure_option_state()
|
|
33
|
+
st.set_page_config(
|
|
34
|
+
page_title="Refinance Calculator",
|
|
35
|
+
layout="wide",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
st.title("Refinance Calculator")
|
|
39
|
+
st.write(
|
|
40
|
+
"Use the inputs below to compare refinancing scenarios, cash-out needs, "
|
|
41
|
+
"and after-tax impacts before reviewing the cumulative savings timeline.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
calc_tab, analysis_tab, visuals_tab, market_tab, options_tab, info_tab = st.tabs(
|
|
45
|
+
[
|
|
46
|
+
"Calculator",
|
|
47
|
+
"Analysis",
|
|
48
|
+
"Loan Visualizations",
|
|
49
|
+
"Market",
|
|
50
|
+
"Options",
|
|
51
|
+
"Info",
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
inputs: CalculatorInputs | None = None
|
|
56
|
+
analysis: RefinanceAnalysis | None = None
|
|
57
|
+
|
|
58
|
+
with calc_tab:
|
|
59
|
+
inputs = collect_inputs()
|
|
60
|
+
analysis = run_analysis(inputs)
|
|
61
|
+
render_results(inputs, analysis)
|
|
62
|
+
|
|
63
|
+
if inputs is None or analysis is None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
sensitivity_data, holding_period_data, amortization_data = prepare_auxiliary_data(inputs)
|
|
67
|
+
|
|
68
|
+
with analysis_tab:
|
|
69
|
+
render_analysis_tab(inputs, sensitivity_data, holding_period_data)
|
|
70
|
+
|
|
71
|
+
with visuals_tab:
|
|
72
|
+
render_loan_visualizations_tab(analysis, amortization_data)
|
|
73
|
+
|
|
74
|
+
with market_tab:
|
|
75
|
+
render_market_tab()
|
|
76
|
+
|
|
77
|
+
with options_tab:
|
|
78
|
+
render_options_tab(inputs)
|
|
79
|
+
|
|
80
|
+
with info_tab:
|
|
81
|
+
render_info_tab()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["main"]
|
|
89
|
+
|
|
90
|
+
__description__ = """
|
|
91
|
+
Streamlit app wiring that mirrors the desktop refinance calculator experience.
|
|
92
|
+
"""
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Helpers for gathering calculator inputs and orchestrating core analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
|
|
8
|
+
import streamlit as st
|
|
9
|
+
|
|
10
|
+
from refi_calculator.core.calculations import (
|
|
11
|
+
analyze_refinance,
|
|
12
|
+
generate_comparison_schedule,
|
|
13
|
+
run_holding_period_analysis,
|
|
14
|
+
run_sensitivity,
|
|
15
|
+
)
|
|
16
|
+
from refi_calculator.core.models import RefinanceAnalysis
|
|
17
|
+
|
|
18
|
+
logger = getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DEFAULT_CURRENT_BALANCE = 400_000.0
|
|
21
|
+
DEFAULT_CURRENT_RATE = 6.5
|
|
22
|
+
DEFAULT_CURRENT_REMAINING = 25.0
|
|
23
|
+
DEFAULT_NEW_RATE = 5.75
|
|
24
|
+
DEFAULT_NEW_TERM = 30.0
|
|
25
|
+
DEFAULT_CLOSING_COSTS = 8_000.0
|
|
26
|
+
DEFAULT_CASH_OUT = 0.0
|
|
27
|
+
DEFAULT_OPPORTUNITY_RATE = 5.0
|
|
28
|
+
DEFAULT_MARGINAL_TAX_RATE = 0.0
|
|
29
|
+
DEFAULT_NPV_WINDOW_YEARS = 5
|
|
30
|
+
DEFAULT_CHART_HORIZON_YEARS = 10
|
|
31
|
+
DEFAULT_SENSITIVITY_MAX_REDUCTION = 2.5
|
|
32
|
+
DEFAULT_SENSITIVITY_STEP = 0.125
|
|
33
|
+
|
|
34
|
+
OPTION_STATE_DEFAULTS: dict[str, float] = {
|
|
35
|
+
"chart_horizon_years": DEFAULT_CHART_HORIZON_YEARS,
|
|
36
|
+
"sensitivity_max_reduction": DEFAULT_SENSITIVITY_MAX_REDUCTION,
|
|
37
|
+
"sensitivity_step": DEFAULT_SENSITIVITY_STEP,
|
|
38
|
+
}
|
|
39
|
+
ADVANCED_STATE_DEFAULTS: dict[str, float | bool] = {
|
|
40
|
+
"opportunity_rate": DEFAULT_OPPORTUNITY_RATE,
|
|
41
|
+
"marginal_tax_rate": DEFAULT_MARGINAL_TAX_RATE,
|
|
42
|
+
"npv_window_years": DEFAULT_NPV_WINDOW_YEARS,
|
|
43
|
+
"maintain_payment": False,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
HOLDING_PERIODS = [1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 15, 20]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CalculatorInputs:
|
|
51
|
+
"""Inputs collected from the Streamlit UI.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
current_balance: Current loan balance.
|
|
55
|
+
current_rate: Current percentage rate on the existing loan.
|
|
56
|
+
current_remaining_years: Remaining years on the current mortgage.
|
|
57
|
+
new_rate: Candidate refinance rate percentage.
|
|
58
|
+
new_term_years: Term for the new loan in years.
|
|
59
|
+
closing_costs: Expected closing costs for the refinance.
|
|
60
|
+
cash_out: Cash out amount requested with the refinance.
|
|
61
|
+
opportunity_rate: Discount rate for NPV computations (percent).
|
|
62
|
+
marginal_tax_rate: Marginal tax rate for after-tax calculations (percent).
|
|
63
|
+
npv_window_years: Horizon used to compute NPV savings.
|
|
64
|
+
chart_horizon_years: Years displayed on the cumulative savings chart.
|
|
65
|
+
maintain_payment: Whether the borrower maintains the current payment level.
|
|
66
|
+
sensitivity_max_reduction: Max reduction below the current rate for sensitivity scenarios.
|
|
67
|
+
sensitivity_step: Step size between successive sensitivity scenarios.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
current_balance: float
|
|
71
|
+
current_rate: float
|
|
72
|
+
current_remaining_years: float
|
|
73
|
+
new_rate: float
|
|
74
|
+
new_term_years: float
|
|
75
|
+
closing_costs: float
|
|
76
|
+
cash_out: float
|
|
77
|
+
opportunity_rate: float
|
|
78
|
+
marginal_tax_rate: float
|
|
79
|
+
npv_window_years: int
|
|
80
|
+
chart_horizon_years: int
|
|
81
|
+
maintain_payment: bool
|
|
82
|
+
sensitivity_max_reduction: float
|
|
83
|
+
sensitivity_step: float
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def collect_inputs() -> CalculatorInputs:
|
|
87
|
+
"""Gather user inputs from Streamlit widgets.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
CalculatorInputs populated with the current values.
|
|
91
|
+
"""
|
|
92
|
+
st.subheader("Loan Inputs")
|
|
93
|
+
current_col, new_col = st.columns(2)
|
|
94
|
+
|
|
95
|
+
with current_col:
|
|
96
|
+
current_balance = st.number_input(
|
|
97
|
+
"Balance ($)",
|
|
98
|
+
min_value=0.0,
|
|
99
|
+
value=DEFAULT_CURRENT_BALANCE,
|
|
100
|
+
step=1_000.0,
|
|
101
|
+
)
|
|
102
|
+
current_rate = st.number_input(
|
|
103
|
+
"Rate (%):",
|
|
104
|
+
min_value=0.0,
|
|
105
|
+
value=DEFAULT_CURRENT_RATE,
|
|
106
|
+
step=0.01,
|
|
107
|
+
)
|
|
108
|
+
current_remaining_years = st.number_input(
|
|
109
|
+
"Years Remaining",
|
|
110
|
+
min_value=0.5,
|
|
111
|
+
value=DEFAULT_CURRENT_REMAINING,
|
|
112
|
+
step=0.5,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
with new_col:
|
|
116
|
+
new_rate = st.number_input(
|
|
117
|
+
"New Rate (%):",
|
|
118
|
+
min_value=0.0,
|
|
119
|
+
value=DEFAULT_NEW_RATE,
|
|
120
|
+
step=0.01,
|
|
121
|
+
)
|
|
122
|
+
new_term_years = st.number_input(
|
|
123
|
+
"Term (years)",
|
|
124
|
+
min_value=1.0,
|
|
125
|
+
value=DEFAULT_NEW_TERM,
|
|
126
|
+
step=0.5,
|
|
127
|
+
)
|
|
128
|
+
closing_costs = st.number_input(
|
|
129
|
+
"Closing Costs ($)",
|
|
130
|
+
min_value=0.0,
|
|
131
|
+
value=DEFAULT_CLOSING_COSTS,
|
|
132
|
+
step=500.0,
|
|
133
|
+
)
|
|
134
|
+
cash_out = st.number_input(
|
|
135
|
+
"Cash Out ($)",
|
|
136
|
+
min_value=0.0,
|
|
137
|
+
value=DEFAULT_CASH_OUT,
|
|
138
|
+
step=500.0,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
with st.expander("Advanced options", expanded=False):
|
|
142
|
+
opportunity_rate = st.number_input(
|
|
143
|
+
"Opportunity Rate (%)",
|
|
144
|
+
min_value=0.0,
|
|
145
|
+
max_value=100.0,
|
|
146
|
+
value=st.session_state["opportunity_rate"],
|
|
147
|
+
step=0.1,
|
|
148
|
+
key="opportunity_rate",
|
|
149
|
+
)
|
|
150
|
+
marginal_tax_rate = st.number_input(
|
|
151
|
+
"Marginal Tax Rate (%)",
|
|
152
|
+
min_value=0.0,
|
|
153
|
+
max_value=100.0,
|
|
154
|
+
value=st.session_state["marginal_tax_rate"],
|
|
155
|
+
step=0.1,
|
|
156
|
+
key="marginal_tax_rate",
|
|
157
|
+
)
|
|
158
|
+
npv_window_years = int(
|
|
159
|
+
st.number_input(
|
|
160
|
+
"NPV Window (years)",
|
|
161
|
+
min_value=1,
|
|
162
|
+
max_value=30,
|
|
163
|
+
value=st.session_state["npv_window_years"],
|
|
164
|
+
step=1,
|
|
165
|
+
key="npv_window_years",
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
maintain_payment = st.checkbox(
|
|
169
|
+
"Maintain current payment (extra → principal)",
|
|
170
|
+
value=st.session_state["maintain_payment"],
|
|
171
|
+
key="maintain_payment",
|
|
172
|
+
)
|
|
173
|
+
st.caption("Opportunity cost and tax rate feed into the NPV and savings dashboard.")
|
|
174
|
+
|
|
175
|
+
chart_horizon_years = int(st.session_state["chart_horizon_years"])
|
|
176
|
+
sensitivity_max_reduction = float(st.session_state["sensitivity_max_reduction"])
|
|
177
|
+
sensitivity_step = float(st.session_state["sensitivity_step"])
|
|
178
|
+
|
|
179
|
+
return CalculatorInputs(
|
|
180
|
+
current_balance=current_balance,
|
|
181
|
+
current_rate=current_rate,
|
|
182
|
+
current_remaining_years=current_remaining_years,
|
|
183
|
+
new_rate=new_rate,
|
|
184
|
+
new_term_years=new_term_years,
|
|
185
|
+
closing_costs=closing_costs,
|
|
186
|
+
cash_out=cash_out,
|
|
187
|
+
opportunity_rate=opportunity_rate,
|
|
188
|
+
marginal_tax_rate=marginal_tax_rate,
|
|
189
|
+
npv_window_years=npv_window_years,
|
|
190
|
+
chart_horizon_years=chart_horizon_years,
|
|
191
|
+
maintain_payment=maintain_payment,
|
|
192
|
+
sensitivity_max_reduction=sensitivity_max_reduction,
|
|
193
|
+
sensitivity_step=sensitivity_step,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_analysis(inputs: CalculatorInputs) -> RefinanceAnalysis:
|
|
198
|
+
"""Run the refinance analysis calculations.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
inputs: Inputs captured from the UI.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Analysis results for the provided scenario.
|
|
205
|
+
"""
|
|
206
|
+
return analyze_refinance(
|
|
207
|
+
current_balance=inputs.current_balance,
|
|
208
|
+
current_rate=inputs.current_rate / 100,
|
|
209
|
+
current_remaining_years=inputs.current_remaining_years,
|
|
210
|
+
new_rate=inputs.new_rate / 100,
|
|
211
|
+
new_term_years=inputs.new_term_years,
|
|
212
|
+
closing_costs=inputs.closing_costs,
|
|
213
|
+
cash_out=inputs.cash_out,
|
|
214
|
+
opportunity_rate=inputs.opportunity_rate / 100,
|
|
215
|
+
npv_window_years=inputs.npv_window_years,
|
|
216
|
+
chart_horizon_years=inputs.chart_horizon_years,
|
|
217
|
+
marginal_tax_rate=inputs.marginal_tax_rate / 100,
|
|
218
|
+
maintain_payment=inputs.maintain_payment,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_rate_steps(
|
|
223
|
+
current_rate_pct: float,
|
|
224
|
+
max_reduction: float,
|
|
225
|
+
step: float,
|
|
226
|
+
) -> list[float]:
|
|
227
|
+
"""Build new-rate steps for sensitivity analysis loops.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
current_rate_pct: Current rate in percent.
|
|
231
|
+
max_reduction: Max percent reduction to explore.
|
|
232
|
+
step: Step between subsequent rows (percent).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of new rates expressed as decimals.
|
|
236
|
+
"""
|
|
237
|
+
if step <= 0:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
rate_steps: list[float] = []
|
|
241
|
+
reduction = step
|
|
242
|
+
max_steps = 20
|
|
243
|
+
while reduction <= max_reduction + 0.001 and len(rate_steps) < max_steps:
|
|
244
|
+
new_rate_pct = current_rate_pct - reduction
|
|
245
|
+
if new_rate_pct > 0:
|
|
246
|
+
rate_steps.append(new_rate_pct / 100)
|
|
247
|
+
reduction += step
|
|
248
|
+
return rate_steps
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def ensure_option_state() -> None:
|
|
252
|
+
"""Restore default option values in Streamlit session state."""
|
|
253
|
+
for key, default in OPTION_STATE_DEFAULTS.items():
|
|
254
|
+
st.session_state.setdefault(key, default)
|
|
255
|
+
for key, default in ADVANCED_STATE_DEFAULTS.items():
|
|
256
|
+
st.session_state.setdefault(key, default)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def prepare_auxiliary_data(
|
|
260
|
+
inputs: CalculatorInputs,
|
|
261
|
+
) -> tuple[list[dict], list[dict], list[dict]]:
|
|
262
|
+
"""Compute supporting tables for the analysis tab.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
inputs: Combination of all UI parameters.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Tuple of sensitivity, holding period, and amortization data.
|
|
269
|
+
"""
|
|
270
|
+
rate_steps = _build_rate_steps(
|
|
271
|
+
inputs.current_rate,
|
|
272
|
+
inputs.sensitivity_max_reduction,
|
|
273
|
+
inputs.sensitivity_step,
|
|
274
|
+
)
|
|
275
|
+
sensitivity_data = run_sensitivity(
|
|
276
|
+
inputs.current_balance,
|
|
277
|
+
inputs.current_rate / 100,
|
|
278
|
+
inputs.current_remaining_years,
|
|
279
|
+
inputs.new_term_years,
|
|
280
|
+
inputs.closing_costs,
|
|
281
|
+
inputs.opportunity_rate / 100,
|
|
282
|
+
rate_steps,
|
|
283
|
+
inputs.npv_window_years,
|
|
284
|
+
)
|
|
285
|
+
holding_period_data = run_holding_period_analysis(
|
|
286
|
+
inputs.current_balance,
|
|
287
|
+
inputs.current_rate / 100,
|
|
288
|
+
inputs.current_remaining_years,
|
|
289
|
+
inputs.new_rate / 100,
|
|
290
|
+
inputs.new_term_years,
|
|
291
|
+
inputs.closing_costs,
|
|
292
|
+
inputs.opportunity_rate / 100,
|
|
293
|
+
inputs.marginal_tax_rate / 100,
|
|
294
|
+
HOLDING_PERIODS,
|
|
295
|
+
cash_out=inputs.cash_out,
|
|
296
|
+
)
|
|
297
|
+
amortization_data = generate_comparison_schedule(
|
|
298
|
+
inputs.current_balance,
|
|
299
|
+
inputs.current_rate / 100,
|
|
300
|
+
inputs.current_remaining_years,
|
|
301
|
+
inputs.new_rate / 100,
|
|
302
|
+
inputs.new_term_years,
|
|
303
|
+
inputs.closing_costs,
|
|
304
|
+
cash_out=inputs.cash_out,
|
|
305
|
+
maintain_payment=inputs.maintain_payment,
|
|
306
|
+
)
|
|
307
|
+
return sensitivity_data, holding_period_data, amortization_data
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
logger.debug("Calculator helpers module initialized.")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = ["CalculatorInputs", "collect_inputs", "run_analysis", "prepare_auxiliary_data"]
|
|
314
|
+
|
|
315
|
+
__description__ = """
|
|
316
|
+
Helpers for collecting inputs and driving core refinance calculations.
|
|
317
|
+
"""
|