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,90 @@
1
+ """Helpers for formatting values displayed in the Streamlit interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging import getLogger
6
+
7
+ logger = getLogger(__name__)
8
+
9
+
10
+ def format_currency(value: float) -> str:
11
+ """Format a numeric value as whole-dollar currency.
12
+
13
+ Args:
14
+ value: Numeric value to format.
15
+
16
+ Returns:
17
+ Dollar-formatted string without decimal cents.
18
+ """
19
+ return f"${value:,.0f}"
20
+
21
+
22
+ def format_optional_currency(value: float | None) -> str:
23
+ """Format an optional numeric value as currency, falling back to N/A.
24
+
25
+ Args:
26
+ value: Optional numeric value.
27
+
28
+ Returns:
29
+ Formatted currency or "N/A" when no value is present.
30
+ """
31
+ if value is None:
32
+ return "N/A"
33
+ return format_currency(value)
34
+
35
+
36
+ def format_months(value: float | int | None) -> str:
37
+ """Describe a month count alongside its equivalent in years.
38
+
39
+ Args:
40
+ value: Number of months to convert.
41
+
42
+ Returns:
43
+ Human-friendly string or "N/A" when value is missing.
44
+ """
45
+ if value is None:
46
+ return "N/A"
47
+ months = int(value)
48
+ years = value / 12
49
+ return f"{months} mo ({years:.1f} yr)"
50
+
51
+
52
+ def format_signed_currency(value: float) -> str:
53
+ """Format a signed value with explicit plus/minus.
54
+
55
+ Args:
56
+ value: Value to format with sign.
57
+
58
+ Returns:
59
+ Signed currency string to highlight deltas.
60
+ """
61
+ prefix = "+" if value >= 0 else "-"
62
+ return f"{prefix}{format_currency(abs(value))}"
63
+
64
+
65
+ def format_savings_delta(value: float) -> str:
66
+ """Invert savings sign to match user-facing storytelling.
67
+
68
+ Args:
69
+ value: Value representing savings.
70
+
71
+ Returns:
72
+ Signed string reflecting the UX messaging used in the GUI.
73
+ """
74
+ prefix = "-" if value >= 0 else "+"
75
+ return f"{prefix}{format_currency(abs(value))}"
76
+
77
+
78
+ logger.debug("Formatting helpers module initialized.")
79
+
80
+ __all__ = [
81
+ "format_currency",
82
+ "format_optional_currency",
83
+ "format_months",
84
+ "format_signed_currency",
85
+ "format_savings_delta",
86
+ ]
87
+
88
+ __description__ = """
89
+ Formatting utilities for the Streamlit refinance calculator widgets.
90
+ """
@@ -0,0 +1,226 @@
1
+ """Background and help content shared on the informational tab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging import getLogger
6
+
7
+ import streamlit as st
8
+
9
+ logger = getLogger(__name__)
10
+
11
+ BACKGROUND_SECTIONS = [
12
+ (
13
+ "What is Refinancing?",
14
+ (
15
+ "Refinancing replaces your existing mortgage with a new loan, typically to secure a "
16
+ "lower interest rate, change the loan term, or access home equity (cash-out refinance). "
17
+ "The new loan pays off your old mortgage, and you begin making payments on the new terms.\n\n"
18
+ "Common reasons to refinance:\n"
19
+ "• Lower your interest rate and monthly payment\n"
20
+ "• Shorten your loan term to pay off faster\n"
21
+ "• Switch from adjustable-rate to fixed-rate (or vice versa)\n"
22
+ "• Access equity for major expenses (cash-out refi)\n"
23
+ "• Remove private mortgage insurance (PMI)"
24
+ ),
25
+ ),
26
+ (
27
+ "Key Costs to Consider",
28
+ (
29
+ "Refinancing isn't free. Typical closing costs run 2-5% of the loan amount and may include:\n\n"
30
+ "• Origination fees (lender charges)\n"
31
+ "• Appraisal fee ($300-$700)\n"
32
+ "• Title search and insurance\n"
33
+ "• Recording fees\n"
34
+ "• Credit report fee\n"
35
+ "• Prepaid interest, taxes, and insurance\n\n"
36
+ "These costs can be paid upfront, rolled into the loan balance, or covered by accepting a "
37
+ 'slightly higher rate ("no-cost" refi). Rolling costs into the loan means you\'ll pay interest '
38
+ "on them over time."
39
+ ),
40
+ ),
41
+ (
42
+ "The Breakeven Concept",
43
+ (
44
+ "The fundamental question: How long until your monthly savings recoup the closing costs?\n\n"
45
+ "Simple Breakeven = Closing Costs ÷ Monthly Savings\n\n"
46
+ "Example: $6,000 in costs with $200/month savings = 30 months to breakeven.\n\n"
47
+ "If you plan to stay in the home longer than the breakeven period, refinancing likely makes sense. "
48
+ "If you might move or refinance again before breakeven, you'll lose money on the transaction.\n\n"
49
+ "Important caveat: Simple breakeven ignores the time value of money. A dollar saved three years "
50
+ "from now is worth less than a dollar today."
51
+ ),
52
+ ),
53
+ (
54
+ "Net Present Value (NPV)",
55
+ (
56
+ "NPV provides a more sophisticated analysis by discounting future savings to today's dollars. "
57
+ 'It accounts for the "opportunity cost" of your closing costs — what you could have earned by investing '
58
+ "that money instead.\n\n"
59
+ "NPV = -Closing Costs + Σ (Monthly Savings / (1 + r)^n)\n\n"
60
+ "Where r is the monthly discount rate and n is the month number.\n\n"
61
+ "A positive NPV means refinancing creates value even after accounting for opportunity cost. "
62
+ "The higher the NPV, the more clearly beneficial the refinance."
63
+ ),
64
+ ),
65
+ (
66
+ "The Term Reset Problem",
67
+ (
68
+ "A critical nuance many borrowers miss: refinancing often resets your amortization clock.\n\n"
69
+ "Example: You're 5 years into a 30-year mortgage (25 years remaining). If you refinance into a new "
70
+ "30-year loan, you've added 5 years to your payoff timeline — even if your rate dropped.\n\n"
71
+ "This can dramatically increase total interest paid over the life of the loan, even with a lower rate. "
72
+ "Solutions:\n"
73
+ "• Refinance into a shorter term (e.g., 20 or 15 years)\n"
74
+ "• Make extra principal payments to maintain your original payoff date\n"
75
+ "• Compare total interest paid, not just monthly payment"
76
+ ),
77
+ ),
78
+ (
79
+ "Tax Implications",
80
+ (
81
+ "Mortgage interest is tax-deductible if you itemize deductions. This effectively reduces your true "
82
+ "interest rate:\n\n"
83
+ "After-Tax Rate = Nominal Rate × (1 - Marginal Tax Rate)\n\n"
84
+ "Example: 6% rate with 24% marginal bracket = 4.56% effective rate\n\n"
85
+ "Note: The 2017 tax law changes increased the standard deduction significantly, meaning fewer homeowners "
86
+ "now itemize. If you take the standard deduction, there's no mortgage interest tax benefit."
87
+ ),
88
+ ),
89
+ (
90
+ "Cash-Out Refinancing",
91
+ (
92
+ "A cash-out refi lets you borrow against your home equity, receiving the difference as cash. Your new "
93
+ "loan balance equals your old balance plus the cash withdrawn plus closing costs.\n\n"
94
+ "Considerations:\n"
95
+ "• You're converting home equity into debt\n"
96
+ "• Monthly payment will likely increase even with a lower rate\n"
97
+ "• Interest on cash-out amounts above original loan may not be tax-deductible\n"
98
+ "• Good for consolidating high-interest debt or funding investments\n"
99
+ "• Risky if used for consumption or if home values decline"
100
+ ),
101
+ ),
102
+ (
103
+ "When NOT to Refinance",
104
+ (
105
+ "Refinancing isn't always beneficial:\n\n"
106
+ "• Short holding period: If you'll move before breakeven\n"
107
+ "• Small rate reduction: Less than 0.5-0.75% often doesn't justify costs\n"
108
+ "• Extended payoff: If resetting to 30 years significantly increases total interest\n"
109
+ "• High closing costs: Some lenders charge excessive fees\n"
110
+ "• Credit issues: Poor credit may mean higher rates or denial\n"
111
+ "• Equity constraints: Most lenders require 20%+ equity for best rates\n\n"
112
+ "Rule of thumb: A 1% rate reduction with typical costs usually breaks even in 2-3 years."
113
+ ),
114
+ ),
115
+ ]
116
+
117
+ HELP_SECTIONS = [
118
+ (
119
+ "Overview",
120
+ (
121
+ "This calculator helps you analyze whether refinancing your mortgage makes financial sense. "
122
+ "It goes beyond simple breakeven calculations to provide NPV analysis, tax-adjusted figures, "
123
+ "sensitivity tables, and visualizations.\n\n"
124
+ "All calculations update automatically when you change inputs or press Enter."
125
+ ),
126
+ ),
127
+ (
128
+ "Calculator Tab",
129
+ (
130
+ "The main analysis screen with inputs and results.\n\n"
131
+ "INPUTS - Current Loan:\n"
132
+ "• Balance ($): Your remaining mortgage principal\n"
133
+ "• Rate (%): Current annual interest rate\n"
134
+ "• Years Remaining: Time left on your current loan\n\n"
135
+ "INPUTS - New Loan:\n"
136
+ "• Rate (%): Proposed new interest rate\n"
137
+ "• Term (years): Length of new loan (typically 15, 20, or 30)\n"
138
+ "• Closing Costs ($): Total refinance fees\n"
139
+ "• Cash Out ($): Additional amount to borrow (0 for rate-only refi)\n"
140
+ "• Opportunity Rate (%): Expected return on alternative investments\n"
141
+ "• Marginal Tax Rate (%): Your tax bracket (0 if you don't itemize)"
142
+ ),
143
+ ),
144
+ (
145
+ "Rate Sensitivity Tab",
146
+ (
147
+ "Shows how breakeven and NPV change at different new interest rates.\n\n"
148
+ "The table displays scenarios from your current rate down to the maximum reduction specified in Options (default: 2% "
149
+ "in 0.25% steps).\n\n"
150
+ "Use this to answer questions like:\n"
151
+ '• "Should I wait for rates to drop further?"\n'
152
+ '• "How much does each 0.25% reduction improve my outcome?"'
153
+ ),
154
+ ),
155
+ (
156
+ "Holding Period Tab",
157
+ (
158
+ "Shows NPV at various holding periods (1-20 years).\n\n"
159
+ "This helps when you're uncertain how long you'll stay in the home. The recommendation column provides guidance:\n"
160
+ "• Strong Yes (green): NPV > $5,000\n"
161
+ "• Yes (dark green): NPV > $0\n"
162
+ "• Marginal (orange): NPV between -$2,000 and $0\n"
163
+ "• No (red): NPV < -$2,000"
164
+ ),
165
+ ),
166
+ (
167
+ "Loan Visualizations Tab",
168
+ (
169
+ "The Loan Visualizations tab contains the annual amortization comparison table, which now includes a cumulative interest Δ "
170
+ "column alongside colored savings/cost indicators so you can track how the refinance affects total interest year over year."
171
+ ),
172
+ ),
173
+ (
174
+ "Charts within Loan Visualizations",
175
+ (
176
+ "Two charts live on the Loan Visualizations tab:\n\n"
177
+ "1. Cumulative Savings Chart — shows nominal (blue) and NPV-adjusted (green) savings with monthly ticks, a labeled zero line, "
178
+ "and a dashed vertical line marking the NPV breakeven point.\n"
179
+ "2. Loan Balance Comparison Chart — plots the remaining balances for the current (red) and new (blue) loans so you can see how "
180
+ "the term reset or accelerated payoff affects your timeline."
181
+ ),
182
+ ),
183
+ (
184
+ "Options Tab",
185
+ (
186
+ "Customize calculation parameters:\n\n"
187
+ "• NPV Window (years): Time horizon for NPV calculation displayed on main tab\n"
188
+ "• Chart Horizon (years): How many years shown on the chart\n"
189
+ "• Max Rate Reduction (%): How far below current rate to show in sensitivity table\n"
190
+ "• Rate Step (%): Increment between rows in sensitivity table"
191
+ ),
192
+ ),
193
+ (
194
+ "Exporting Data",
195
+ (
196
+ "Export buttons are available on Calculator, Rate Sensitivity, Holding Period, and Amortization tabs. "
197
+ "Files are saved with timestamps to avoid overwriting."
198
+ ),
199
+ ),
200
+ ]
201
+
202
+
203
+ def render_info_tab() -> None:
204
+ """Render background and help guidance content."""
205
+ background_tab, help_tab = st.tabs(["Background", "Help"])
206
+
207
+ with background_tab:
208
+ for title, text in BACKGROUND_SECTIONS:
209
+ st.markdown(f"**{title}**")
210
+ st.markdown(text)
211
+ st.divider()
212
+
213
+ with help_tab:
214
+ for title, text in HELP_SECTIONS:
215
+ st.markdown(f"**{title}**")
216
+ st.markdown(text)
217
+ st.divider()
218
+
219
+
220
+ logger.debug("Info tab helpers initialized.")
221
+
222
+ __all__ = ["render_info_tab"]
223
+
224
+ __description__ = """
225
+ Background and help guidance for the refinance calculator experience.
226
+ """
@@ -0,0 +1,270 @@
1
+ """Market data helpers for the refinance calculator interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging import getLogger
6
+ from typing import cast
7
+
8
+ import pandas as pd
9
+ import plotly.graph_objects as go
10
+ import streamlit as st
11
+
12
+ from refi_calculator.core.market.fred import fetch_fred_series
13
+ from refi_calculator.gui.market_constants import MARKET_PERIOD_OPTIONS, MARKET_SERIES
14
+
15
+ logger = getLogger(__name__)
16
+
17
+ MARKET_CACHE_TTL_SECONDS = 15 * 60
18
+ MARKET_AXIS_YEAR_THRESHOLD_MONTHS = 24
19
+
20
+
21
+ def get_api_key() -> str | None:
22
+ """Retrieve the FRED API key stored in Streamlit secrets."""
23
+ return st.secrets.get("FRED_API_KEY")
24
+
25
+
26
+ @st.cache_data(ttl=MARKET_CACHE_TTL_SECONDS)
27
+ def _fetch_series(series_id: str, api_key: str) -> list[tuple[str, float]]:
28
+ """Fetch a single series from FRED with caching.
29
+
30
+ Args:
31
+ series_id: FRED series identifier.
32
+ api_key: API key to authenticate with FRED.
33
+
34
+ Returns:
35
+ List of (date, value) observations.
36
+ """
37
+ return fetch_fred_series(series_id, api_key)
38
+
39
+
40
+ def fetch_all_series(api_key: str) -> tuple[dict[str, list[tuple[str, float]]], list[str]]:
41
+ """Retrieve all configured market series from FRED.
42
+
43
+ Args:
44
+ api_key: API key to authenticate with FRED.
45
+
46
+ Returns:
47
+ Tuple of raw series data and collection of error messages.
48
+ """
49
+ raw_series: dict[str, list[tuple[str, float]]] = {}
50
+ errors: list[str] = []
51
+ for label, series_id in MARKET_SERIES:
52
+ try:
53
+ observations = _fetch_series(series_id, api_key)
54
+ except RuntimeError as exc:
55
+ logger.exception("Failed to fetch %s series", label)
56
+ errors.append(f"{label}: {exc}")
57
+ observations = []
58
+ raw_series[label] = observations
59
+ return raw_series, errors
60
+
61
+
62
+ def _build_market_dataframe(
63
+ raw_series: dict[str, list[tuple[str, float]]],
64
+ ) -> pd.DataFrame:
65
+ """Combine multiple FRED series into a Date-indexed DataFrame.
66
+
67
+ Args:
68
+ raw_series: Mapping of series labels to raw (date, value) observations.
69
+
70
+ Returns:
71
+ Combined DataFrame with Date index and one column per series.
72
+ """
73
+ frames: list[pd.DataFrame] = []
74
+ for label, observations in raw_series.items():
75
+ if not observations:
76
+ continue
77
+ df = pd.DataFrame(observations, columns=pd.Index(["Date", label]))
78
+ df["Date"] = pd.to_datetime(df["Date"])
79
+ df.set_index("Date", inplace=True)
80
+ frames.append(df)
81
+
82
+ if not frames:
83
+ return pd.DataFrame()
84
+
85
+ return pd.concat(frames, axis=1).sort_index()
86
+
87
+
88
+ def _filter_market_dataframe(
89
+ data: pd.DataFrame,
90
+ months: int | None,
91
+ ) -> pd.DataFrame:
92
+ """Restrict the provided series to the most recent `months`.
93
+
94
+ Args:
95
+ data: Date-indexed DataFrame with market series.
96
+ months: Number of trailing months to keep or None for full history.
97
+
98
+ Returns:
99
+ Filtered DataFrame.
100
+ """
101
+ if months is None or data.empty:
102
+ return data
103
+
104
+ last_date_raw = data.index.max()
105
+ if last_date_raw is pd.NaT:
106
+ return data
107
+ last_date = cast(pd.Timestamp, last_date_raw)
108
+ cutoff = last_date - pd.DateOffset(months=months)
109
+ return data.loc[data.index >= cutoff]
110
+
111
+
112
+ def _segment_months(value: str) -> int | None:
113
+ """Convert the UI option value into a number of months.
114
+
115
+ Args:
116
+ value: Selected option value.
117
+
118
+ Returns:
119
+ Number of months or None for full history.
120
+ """
121
+ if value == "0":
122
+ return None
123
+ return int(value)
124
+
125
+
126
+ def _render_market_chart(data: pd.DataFrame) -> None:
127
+ """Render the market series chart with optimized axes.
128
+
129
+ Args:
130
+ data: Date-indexed DataFrame with market series.
131
+ """
132
+ if data.empty:
133
+ return
134
+
135
+ melted = data.reset_index().melt("Date", var_name="Series", value_name="Rate")
136
+ min_rate = melted["Rate"].min()
137
+ max_rate = melted["Rate"].max()
138
+ span = max_rate - min_rate
139
+ padding = max(span * 0.05, 0.1)
140
+ lower = max(min_rate - padding, 0)
141
+ upper = max_rate + padding
142
+
143
+ first_date_raw = data.index.min()
144
+ last_date_raw = data.index.max()
145
+ if first_date_raw is pd.NaT or last_date_raw is pd.NaT:
146
+ return
147
+ first_date = cast(pd.Timestamp, first_date_raw)
148
+ last_date = cast(pd.Timestamp, last_date_raw)
149
+ date_span = cast(pd.Timedelta, last_date - first_date)
150
+ months = date_span.days / 30
151
+ use_year_only = months >= MARKET_AXIS_YEAR_THRESHOLD_MONTHS
152
+ date_format = "%Y" if use_year_only else "%b %Y"
153
+
154
+ fig = go.Figure()
155
+ for label, group in melted.groupby("Series"):
156
+ fig.add_trace(
157
+ go.Scatter(
158
+ x=group["Date"],
159
+ y=group["Rate"],
160
+ mode="lines",
161
+ name=label,
162
+ hovertemplate="Date=%{x|%b %Y}<br>Series=%{text}<br>Rate=%{y:.2f}%<extra></extra>",
163
+ text=[label] * len(group),
164
+ ),
165
+ )
166
+
167
+ xaxis_kwargs: dict[str, object] = {
168
+ "title": "Date",
169
+ "tickformat": date_format,
170
+ "tickangle": 0 if use_year_only else -45,
171
+ }
172
+ if use_year_only:
173
+ start_year = int(first_date.year)
174
+ end_year = int(last_date.year)
175
+ xaxis_kwargs.update(
176
+ {
177
+ "tickmode": "array",
178
+ "tickvals": [
179
+ pd.Timestamp(year=y, month=1, day=1) for y in range(start_year, end_year + 1)
180
+ ],
181
+ },
182
+ )
183
+ else:
184
+ xaxis_kwargs["tickmode"] = "auto"
185
+
186
+ fig.update_layout(
187
+ xaxis=xaxis_kwargs,
188
+ yaxis=dict(
189
+ title="Rate (%)",
190
+ range=[lower, upper],
191
+ ),
192
+ legend=dict(title="Series"),
193
+ margin=dict(t=5, b=30, l=50, r=10),
194
+ hovermode="x",
195
+ )
196
+
197
+ st.plotly_chart(fig, use_container_width=True)
198
+
199
+
200
+ def render_market_tab() -> None:
201
+ """Render the market data tab with metrics, range selector, chart, and table."""
202
+ st.subheader("Market Data")
203
+
204
+ api_key = get_api_key()
205
+ if not api_key:
206
+ st.warning(
207
+ "Add your FRED API key to `st.secrets['FRED_API_KEY']` to view mortgage rate history.",
208
+ )
209
+ return
210
+
211
+ raw_series, errors = fetch_all_series(api_key)
212
+ for err in errors:
213
+ st.error(err)
214
+
215
+ market_df_all = _build_market_dataframe(raw_series)
216
+ if market_df_all.empty:
217
+ st.info("Market data is not available for the selected range.")
218
+ return
219
+
220
+ latest_valid = market_df_all.dropna(how="all")
221
+ if latest_valid.empty:
222
+ st.info("Market data lacks recent observations.")
223
+ return
224
+
225
+ latest = latest_valid.iloc[-1].dropna()
226
+ latest_timestamp_raw = latest_valid.index.max()
227
+ if latest_timestamp_raw is pd.NaT:
228
+ st.info("Market data lacks recent observations.")
229
+ return
230
+ latest_date = cast(pd.Timestamp, latest_timestamp_raw).date()
231
+ st.markdown(
232
+ f"**Current rates (latest available as of {latest_date:%Y-%m-%d})**",
233
+ )
234
+ if not latest.empty:
235
+ metric_cols = st.columns(len(latest))
236
+ for col, (label, value) in zip(metric_cols, latest.items()):
237
+ col.metric(label, f"{value:.2f}%")
238
+
239
+ options = [label for label, _ in MARKET_PERIOD_OPTIONS]
240
+ option_mapping = {label: value for label, value in MARKET_PERIOD_OPTIONS}
241
+ period_label = st.radio(
242
+ "Range",
243
+ options,
244
+ horizontal=True,
245
+ key="market_period",
246
+ )
247
+ period_months = _segment_months(option_mapping[period_label])
248
+
249
+ market_df = _filter_market_dataframe(market_df_all, period_months)
250
+ if market_df.empty:
251
+ st.info("Filtered market data is not available for the selected range.")
252
+ return
253
+
254
+ _render_market_chart(market_df)
255
+
256
+ table = market_df.sort_index(ascending=False).head(12).reset_index()
257
+ table["Date"] = table["Date"].dt.date
258
+ st.dataframe(table, width="stretch")
259
+
260
+
261
+ logger.debug("Market helpers module initialized.")
262
+
263
+ __all__ = [
264
+ "render_market_tab",
265
+ "get_api_key",
266
+ ]
267
+
268
+ __description__ = """
269
+ Helpers for fetching FRED data, rendering the market chart, and displaying latest rates.
270
+ """