refi-calculator 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of refi-calculator might be problematic. Click here for more details.

Files changed (35) hide show
  1. refi_calculator/__init__.py +9 -0
  2. refi_calculator/cli.py +64 -0
  3. refi_calculator/core/__init__.py +36 -0
  4. refi_calculator/core/calculations.py +713 -0
  5. refi_calculator/core/charts.py +77 -0
  6. refi_calculator/core/market/__init__.py +11 -0
  7. refi_calculator/core/market/constants.py +24 -0
  8. refi_calculator/core/market/fred.py +62 -0
  9. refi_calculator/core/models.py +131 -0
  10. refi_calculator/environment.py +124 -0
  11. refi_calculator/gui/__init__.py +13 -0
  12. refi_calculator/gui/app.py +1008 -0
  13. refi_calculator/gui/builders/__init__.py +9 -0
  14. refi_calculator/gui/builders/analysis_tab.py +92 -0
  15. refi_calculator/gui/builders/helpers.py +90 -0
  16. refi_calculator/gui/builders/info_tab.py +195 -0
  17. refi_calculator/gui/builders/main_tab.py +173 -0
  18. refi_calculator/gui/builders/market_tab.py +115 -0
  19. refi_calculator/gui/builders/options_tab.py +81 -0
  20. refi_calculator/gui/builders/visuals_tab.py +128 -0
  21. refi_calculator/gui/chart.py +459 -0
  22. refi_calculator/gui/market_chart.py +192 -0
  23. refi_calculator/web/__init__.py +11 -0
  24. refi_calculator/web/app.py +117 -0
  25. refi_calculator/web/calculator.py +317 -0
  26. refi_calculator/web/formatting.py +90 -0
  27. refi_calculator/web/info.py +226 -0
  28. refi_calculator/web/market.py +270 -0
  29. refi_calculator/web/results.py +455 -0
  30. refi_calculator/web/runner.py +22 -0
  31. refi_calculator-0.8.0.dist-info/METADATA +146 -0
  32. refi_calculator-0.8.0.dist-info/RECORD +35 -0
  33. refi_calculator-0.8.0.dist-info/WHEEL +4 -0
  34. refi_calculator-0.8.0.dist-info/entry_points.txt +4 -0
  35. refi_calculator-0.8.0.dist-info/licenses/LICENSE.txt +201 -0
@@ -0,0 +1,77 @@
1
+ """Shared chart helper utilities for refinance visualizations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MIN_LINEAR_TICKS = 2
6
+
7
+
8
+ def build_month_ticks(max_month: int, max_ticks: int = 6) -> list[int]:
9
+ """Generate tick positions for month-based axes.
10
+
11
+ Args:
12
+ max_month: Latest month number to display on the axis.
13
+ max_ticks: Maximum number of tick marks to emit.
14
+
15
+ Returns:
16
+ A list of month values to use for axis ticks.
17
+ """
18
+ if max_month <= 0:
19
+ return [0]
20
+
21
+ tick_count = min(max_ticks, max_month + 1)
22
+ if tick_count <= 1:
23
+ return [max_month]
24
+
25
+ interval = max_month / (tick_count - 1)
26
+ ticks: list[int] = []
27
+ last = -1
28
+ for i in range(tick_count):
29
+ tick = int(round(i * interval))
30
+ if tick <= last:
31
+ tick = last + 1
32
+ tick = min(max_month, tick)
33
+ ticks.append(tick)
34
+ last = tick
35
+
36
+ if ticks[-1] != max_month:
37
+ ticks[-1] = max_month
38
+
39
+ return ticks
40
+
41
+
42
+ def build_linear_ticks(min_value: float, max_value: float, max_ticks: int = 5) -> list[float]:
43
+ """Generate evenly spaced linear axis tick values.
44
+
45
+ Args:
46
+ min_value: Minimum value to include on the axis.
47
+ max_value: Maximum value to include on the axis.
48
+ max_ticks: Maximum number of ticks to produce.
49
+
50
+ Returns:
51
+ A list of axis values covering the requested range.
52
+ """
53
+ if max_ticks < MIN_LINEAR_TICKS:
54
+ return [min_value, max_value]
55
+
56
+ span = max_value - min_value
57
+ if span == 0:
58
+ expansion = abs(max_value) or 1.0
59
+ min_value -= expansion / 2
60
+ max_value += expansion / 2
61
+ span = max_value - min_value
62
+
63
+ step = span / (max_ticks - 1)
64
+ ticks = [min_value + step * i for i in range(max_ticks)]
65
+ ticks[-1] = max_value
66
+ return ticks
67
+
68
+
69
+ __all__ = [
70
+ "MIN_LINEAR_TICKS",
71
+ "build_month_ticks",
72
+ "build_linear_ticks",
73
+ ]
74
+
75
+ __description__ = """
76
+ Shared chart utilities for all refinance-calculator interfaces.
77
+ """
@@ -0,0 +1,11 @@
1
+ """Market data helpers for the refinance calculator core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .fred import fetch_fred_series
6
+
7
+ __all__ = ["fetch_fred_series"]
8
+
9
+ __description__ = """
10
+ Shared helpers for retrieving market data in core interfaces.
11
+ """
@@ -0,0 +1,24 @@
1
+ """Constants for market data processing in the ReFi calculator."""
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 for market data processing in the ReFi calculator.
24
+ """
@@ -0,0 +1,62 @@
1
+ """FRED API helpers for borrowing-rate data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+
10
+
11
+ def fetch_fred_series(
12
+ series_id: str,
13
+ api_key: str,
14
+ limit: int | None = None,
15
+ ) -> list[tuple[str, float]]:
16
+ """Fetch a stationary FRED series and return (date, value) pairs.
17
+
18
+ Args:
19
+ series_id: FRED series identifier (e.g., "MORTGAGE30US").
20
+ api_key: Your FRED API key.
21
+ limit: Maximum number of observations to fetch. Defaults to the service limit.
22
+
23
+ Returns:
24
+ Observations sorted newest-first, (date, float value).
25
+ """
26
+ params: dict[str, str] = {
27
+ "series_id": series_id,
28
+ "api_key": api_key,
29
+ "file_type": "json",
30
+ "sort_order": "desc",
31
+ }
32
+ if limit:
33
+ params["limit"] = str(limit)
34
+
35
+ url = f"https://api.stlouisfed.org/fred/series/observations?{urllib.parse.urlencode(params)}"
36
+
37
+ try:
38
+ with urllib.request.urlopen(url, timeout=10) as resp:
39
+ payload = json.load(resp)
40
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
41
+ raise RuntimeError("Failed to fetch FRED series") from exc
42
+
43
+ observations = payload.get("observations", [])
44
+ results: list[tuple[str, float]] = []
45
+ for obs in observations:
46
+ date = obs.get("date")
47
+ value = obs.get("value")
48
+ if date is None or value is None or value == ".":
49
+ continue
50
+ try:
51
+ parsed = float(value)
52
+ except ValueError:
53
+ continue
54
+ results.append((date, parsed))
55
+ return results
56
+
57
+
58
+ __all__ = ["fetch_fred_series"]
59
+
60
+ __description__ = """
61
+ Helpers for retrieving FRED economic series.
62
+ """
@@ -0,0 +1,131 @@
1
+ """Loan and analysis data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class LoanParams:
10
+ """Parameters for a mortgage loan.
11
+
12
+ Attributes:
13
+ balance: Loan balance.
14
+ rate: Annual interest rate as a decimal.
15
+ term_years: Loan term in years.
16
+ """
17
+
18
+ balance: float
19
+ rate: float
20
+ term_years: float
21
+
22
+ @property
23
+ def monthly_rate(self) -> float:
24
+ """Monthly interest rate.
25
+
26
+ Returns:
27
+ Monthly interest rate as a decimal.
28
+ """
29
+ return self.rate / 12
30
+
31
+ @property
32
+ def num_payments(self) -> int:
33
+ """Total number of monthly payments.
34
+
35
+ Returns:
36
+ Total number of payments.
37
+ """
38
+ return int(self.term_years * 12)
39
+
40
+ @property
41
+ def monthly_payment(self) -> float:
42
+ """Monthly payment using the standard amortization formula.
43
+
44
+ Returns:
45
+ Monthly payment amount.
46
+ """
47
+ r = self.monthly_rate
48
+ n = self.num_payments
49
+ if r == 0:
50
+ return self.balance / n
51
+ return self.balance * (r * (1 + r) ** n) / ((1 + r) ** n - 1)
52
+
53
+ @property
54
+ def total_interest(self) -> float:
55
+ """Total interest paid over the life of the loan."""
56
+ return (self.monthly_payment * self.num_payments) - self.balance
57
+
58
+
59
+ @dataclass
60
+ class RefinanceAnalysis:
61
+ """Results of refinance breakeven analysis.
62
+
63
+ Attributes:
64
+ current_payment: Current monthly payment.
65
+ new_payment: New monthly payment.
66
+ monthly_savings: Monthly savings from refinancing.
67
+ simple_breakeven_months: Months to simple breakeven (nominal).
68
+ npv_breakeven_months: Months to NPV breakeven.
69
+ current_total_interest: Total interest of the current loan.
70
+ new_total_interest: Total interest of the new loan.
71
+ interest_delta: Interest difference between new and current loans.
72
+ five_year_npv: NPV of savings over five years.
73
+ cumulative_savings: Cumulative savings timeline.
74
+ current_after_tax_payment: Current payment after tax benefit.
75
+ new_after_tax_payment: New payment after tax benefit.
76
+ after_tax_monthly_savings: After-tax monthly savings.
77
+ after_tax_simple_breakeven_months: After-tax simple breakeven.
78
+ after_tax_npv_breakeven_months: After-tax NPV breakeven.
79
+ after_tax_npv: After-tax NPV of savings.
80
+ current_after_tax_total_interest: Current loan interest after tax.
81
+ new_after_tax_total_interest: New loan interest after tax.
82
+ after_tax_interest_delta: Interest delta after tax.
83
+ new_loan_balance: Balance of the new loan.
84
+ cash_out_amount: Cash out amount included in the refinance.
85
+ accelerated_months: Months to payoff when maintaining payment.
86
+ accelerated_total_interest: Total interest when accelerating payoff.
87
+ accelerated_interest_savings: Interest savings from acceleration.
88
+ accelerated_time_savings_months: Months saved by accelerating payoff.
89
+ current_total_cost_npv: NPV of the current loan total cost.
90
+ new_total_cost_npv: NPV of the new loan total cost.
91
+ total_cost_npv_advantage: NPV advantage of refinancing.
92
+ """
93
+
94
+ current_payment: float
95
+ new_payment: float
96
+ monthly_savings: float
97
+ simple_breakeven_months: float | None
98
+ npv_breakeven_months: int | None
99
+ current_total_interest: float
100
+ new_total_interest: float
101
+ interest_delta: float
102
+ five_year_npv: float
103
+ cumulative_savings: list[tuple[int, float, float]]
104
+ current_after_tax_payment: float
105
+ new_after_tax_payment: float
106
+ after_tax_monthly_savings: float
107
+ after_tax_simple_breakeven_months: float | None
108
+ after_tax_npv_breakeven_months: int | None
109
+ after_tax_npv: float
110
+ current_after_tax_total_interest: float
111
+ new_after_tax_total_interest: float
112
+ after_tax_interest_delta: float
113
+ new_loan_balance: float
114
+ cash_out_amount: float
115
+ accelerated_months: int | None
116
+ accelerated_total_interest: float | None
117
+ accelerated_interest_savings: float | None
118
+ accelerated_time_savings_months: int | None
119
+ current_total_cost_npv: float
120
+ new_total_cost_npv: float
121
+ total_cost_npv_advantage: float
122
+
123
+
124
+ __all__ = [
125
+ "LoanParams",
126
+ "RefinanceAnalysis",
127
+ ]
128
+
129
+ __description__ = """
130
+ Data models for refinance calculation results.
131
+ """
@@ -0,0 +1,124 @@
1
+ """Utilities for loading environment variables from .env files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable
7
+ from logging import getLogger
8
+ from pathlib import Path
9
+
10
+ logger = getLogger(__name__)
11
+
12
+
13
+ def _strip_quotes(value: str) -> str:
14
+ """Remove matching wrapping quotes from a string.
15
+
16
+ Args:
17
+ value: The string to strip quotes from.
18
+
19
+ Returns:
20
+ The unquoted string.
21
+ """
22
+ if (value.startswith('"') and value.endswith('"')) or (
23
+ value.startswith("'") and value.endswith("'")
24
+ ):
25
+ return value[1:-1]
26
+
27
+ return value
28
+
29
+
30
+ def _parse_dotenv_line(line: str) -> tuple[str, str] | None:
31
+ """Parse a single line from a .env file into a key/value pair.
32
+
33
+ Args:
34
+ line: The line to parse.
35
+
36
+ Returns:
37
+ A tuple of (key, value) if the line is valid, otherwise None.
38
+ """
39
+ stripped = line.strip()
40
+ if not stripped or stripped.startswith("#"):
41
+ return None
42
+
43
+ if stripped.startswith("export "):
44
+ stripped = stripped[len("export ") :].strip()
45
+
46
+ if "=" not in stripped:
47
+ logger.warning("Skipping malformed .env line: %s", line)
48
+ return None
49
+
50
+ key, value = stripped.split("=", 1)
51
+ key = key.strip()
52
+ value = _strip_quotes(value.strip())
53
+
54
+ if not key:
55
+ logger.warning("Skipping .env entry with missing key: %s", line)
56
+ return None
57
+
58
+ return key, value
59
+
60
+
61
+ def _iter_lines(content: str) -> Iterable[str]:
62
+ """Yield lines from .env file content.
63
+
64
+ Args:
65
+ content: The content of the .env file.
66
+
67
+ Yields:
68
+ Lines from the content.
69
+ """
70
+ return (line for line in content.splitlines())
71
+
72
+
73
+ def load_dotenv(
74
+ dotenv_path: Path | str | None = None,
75
+ *,
76
+ override_existing: bool = False,
77
+ ) -> dict[str, str]:
78
+ """Load environment variables from a .env file into the process environment.
79
+
80
+ Args:
81
+ dotenv_path: Path to the .env file. Defaults to ``Path.cwd() / ".env"``.
82
+ override_existing: Whether to overwrite existing environment variables.
83
+
84
+ Returns:
85
+ A mapping of keys that were set from the .env file to their values.
86
+
87
+ Raises:
88
+ OSError: If the .env file could not be read.
89
+ """
90
+ path = Path(dotenv_path) if dotenv_path is not None else Path.cwd() / ".env"
91
+ if not path.exists():
92
+ logger.debug("No .env file found at %s", path)
93
+ return {}
94
+
95
+ try:
96
+ content = path.read_text(encoding="utf-8")
97
+ except OSError:
98
+ logger.exception("Failed to read .env file at %s", path)
99
+ raise
100
+
101
+ loaded: dict[str, str] = {}
102
+ for line in _iter_lines(content):
103
+ pair = _parse_dotenv_line(line)
104
+ if pair is None:
105
+ continue
106
+
107
+ key, value = pair
108
+ if override_existing or key not in os.environ:
109
+ os.environ[key] = value
110
+ loaded[key] = value
111
+ else:
112
+ logger.debug("Skipping existing environment variable %s", key)
113
+
114
+ logger.debug("Loaded %d environment variables from %s", len(loaded), path)
115
+ return loaded
116
+
117
+
118
+ __all__ = [
119
+ "load_dotenv",
120
+ ]
121
+
122
+ __description__ = """
123
+ Utilities for loading environment variables from .env files.
124
+ """
@@ -0,0 +1,13 @@
1
+ """GUI helpers for the refinance calculator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import builders
6
+ from .app import RefinanceCalculatorApp, main
7
+ from .chart import SavingsChart
8
+
9
+ __all__ = ["RefinanceCalculatorApp", "main", "SavingsChart", "builders"]
10
+
11
+ __description__ = """
12
+ Tkinter-based GUI exports for the refinance calculator.
13
+ """