Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import pandas_datareader.data as web | |
| import yfinance as yf | |
| import datetime | |
| import plotly.graph_objs as go | |
| import numpy as np | |
| # ---------- Page config (must be the first Streamlit call) ---------- | |
| st.set_page_config(layout="wide") | |
| # ---------- Stable CSS for wider sidebar (avoid fragile class names) ---------- | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Make the sidebar wider in a stable way */ | |
| [data-testid="stSidebar"] { | |
| width: 350px; | |
| min-width: 350px; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ---------- Session state for persistent "Run Analysis" ---------- | |
| if "run_analysis" not in st.session_state: | |
| st.session_state.run_analysis = False | |
| # ---------- App title and description ---------- | |
| st.title("Key Economic Recession Indicators") | |
| st.markdown(""" | |
| This tool allows you to visualize and analyze various recession indicators over time. | |
| - The shaded areas in the charts represent historical recession periods. | |
| - Use the checkboxes in the sidebar to choose the indicators you'd like to explore. | |
| """) | |
| # ---------- Sidebar controls ---------- | |
| with st.sidebar.expander("How to Use", expanded=False): | |
| st.write(""" | |
| **How to use this app:** | |
| 1. Select the indicators you want to visualize from the sidebar. | |
| 2. Click "Run Analysis" to generate the plots. | |
| 3. The plots will show historical data for the selected indicators, with recession periods shaded in gray. | |
| 4. Hover over the charts to see detailed information for each data point. | |
| """) | |
| st.sidebar.header("Select Indicators") | |
| with st.sidebar.expander("Indicators", expanded=True): | |
| # Removed "Federal Funds Rate" per your request | |
| indicators = { | |
| 'Sahm Recession Indicator': 'SAHMREALTIME', | |
| 'U.S. Recession Probabilities': 'RECPROUSM156N', | |
| 'Yield Spread (10Y - 2Y)': 'Yield_Spread', # Calculated, not fetched | |
| 'Stock Market (S&P 500)': 'SP500', # Fetched from yfinance | |
| 'VIX': 'VIX', # Fetched from yfinance | |
| 'Treasury Rates': ('GS10', 'DGS2', 'DGS1MO', 'TB3MS'), | |
| # 'Federal Funds Rate': 'FEDFUNDS', # <-- removed | |
| 'Unemployment Rate': 'UNRATE', | |
| 'Nonfarm Payrolls': 'PAYEMS', | |
| 'Jobless Claims': 'ICSA', | |
| 'Retail Sales': 'RSXFS', | |
| 'Industrial Production': ('INDPRO', 'INDPRO_PCT'), | |
| 'Housing Starts': 'HOUST', | |
| 'Consumer Confidence': 'UMCSENT', | |
| 'Inflation (CPI)': ('CPIAUCSL', 'CPIAUCSL_PCT') | |
| } | |
| selected_indicators = {key: st.checkbox(key, value=True) for key in indicators.keys()} | |
| # Single Run button (no explicit "clear" — re-running implies clearing) | |
| if st.sidebar.button("Run Analysis"): | |
| st.session_state.run_analysis = True | |
| # ---------- Dates ---------- | |
| start_date = datetime.datetime(1920, 1, 1) | |
| end_date = datetime.datetime.today() | |
| # ---------- Recession periods ---------- | |
| crash_periods = { | |
| '1929-08-01': '1933-03-01', | |
| '1937-05-01': '1938-06-01', | |
| '1945-02-01': '1945-10-01', | |
| '1948-11-01': '1949-10-01', | |
| '1953-07-01': '1954-05-01', | |
| '1957-08-01': '1958-04-01', | |
| '1960-04-01': '1961-02-01', | |
| '1969-12-01': '1970-11-01', | |
| '1973-11-01': '1975-03-01', | |
| '1980-01-01': '1980-07-01', | |
| '1981-07-01': '1982-11-01', | |
| '1990-07-01': '1991-03-01', | |
| '2001-03-01': '2001-11-01', | |
| '2007-12-01': '2009-06-01', | |
| '2020-02-01': '2020-04-01' | |
| } | |
| # ---------- Helpers ---------- | |
| def pct_rank(series: pd.Series, value: float) -> float: | |
| s = pd.to_numeric(series, errors="coerce").dropna() | |
| if s.empty or not np.isfinite(value): | |
| return np.nan | |
| return float((s < value).mean() * 100.0) | |
| def fmt_pct(x, decimals=1): | |
| return "n/a" if pd.isna(x) else f"{x*100:.{decimals}f}%" | |
| def fmt_val(x, decimals=2): | |
| return "n/a" if pd.isna(x) else f"{x:.{decimals}f}" | |
| def series_change(s: pd.Series, periods: int = 1, pct: bool = True): | |
| if len(s) <= periods: | |
| return np.nan | |
| if pct: | |
| return float((s.iloc[-1] / s.iloc[-(periods+1)] - 1.0)) | |
| else: | |
| return float(s.iloc[-1] - s.iloc[-(periods+1)]) | |
| def current_and_date(s: pd.Series): | |
| if s is None or s.empty: | |
| return np.nan, "n/a" | |
| return float(s.iloc[-1]), s.index[-1].date().isoformat() | |
| def inversion_streak(series: pd.Series): | |
| """Consecutive periods the series has been < 0 at the end of series.""" | |
| if series is None or series.dropna().empty: | |
| return 0 | |
| v = (series < 0).astype(int).to_numpy() | |
| streak = 0 | |
| for x in v[::-1]: | |
| if x == 1: | |
| streak += 1 | |
| else: | |
| break | |
| return streak | |
| # ---------- Cached data fetchers ---------- | |
| def fetch_fred_series(series_code: str, start: datetime.datetime, end: datetime.datetime) -> pd.Series: | |
| """Fetch a single FRED series as a named Series (empty Series if fails).""" | |
| try: | |
| df = web.DataReader(series_code, 'fred', start, end) | |
| if isinstance(df, pd.DataFrame): | |
| s = df.squeeze("columns") | |
| else: | |
| s = df | |
| s = s.rename(series_code) | |
| return s | |
| except Exception as e: | |
| st.warning(f"Failed to fetch {series_code} from FRED: {e}") | |
| return pd.Series(name=series_code, dtype="float64") | |
| def fetch_yf_series(ticker: str, label: str, start: datetime.datetime, end: datetime.datetime) -> pd.Series: | |
| """Fetch Adj Close from Yahoo Finance as a named Series.""" | |
| try: | |
| df = yf.download(ticker, start=start, end=end, auto_adjust=False, progress=False, threads=False) | |
| if isinstance(df.columns, pd.MultiIndex): | |
| df.columns = df.columns.get_level_values(0) | |
| s = df.get('Adj Close', pd.Series(dtype="float64")).rename(label) | |
| return s | |
| except Exception as e: | |
| st.warning(f"Failed to fetch {label} ({ticker}) from Yahoo Finance: {e}") | |
| return pd.Series(name=label, dtype="float64") | |
| # ---------- Build dataset ---------- | |
| def build_dataset(selected: dict) -> pd.DataFrame: | |
| series_list = [] | |
| # FRED (skip derived) | |
| for key, col in indicators.items(): | |
| if not selected.get(key, False): | |
| continue | |
| if isinstance(col, tuple): | |
| for c in col: | |
| if c in ["INDPRO_PCT", "CPIAUCSL_PCT"]: | |
| continue # derived later | |
| s = fetch_fred_series(c, start_date, end_date) | |
| if not s.empty: | |
| series_list.append(s) | |
| else: | |
| if col in ["Yield_Spread", "SP500", "VIX"]: | |
| continue # handled separately / derived | |
| s = fetch_fred_series(col, start_date, end_date) | |
| if not s.empty: | |
| series_list.append(s) | |
| # YFinance | |
| if selected.get('Stock Market (S&P 500)', False): | |
| s = fetch_yf_series('^GSPC', 'SP500', start_date, end_date) | |
| if not s.empty: | |
| series_list.append(s) | |
| if selected.get('VIX', False): | |
| s = fetch_yf_series('^VIX', 'VIX', start_date, end_date) | |
| if not s.empty: | |
| series_list.append(s) | |
| if not series_list: | |
| return pd.DataFrame() | |
| combined = pd.concat(series_list, axis=1).sort_index() | |
| # Derived columns | |
| if selected.get('Industrial Production', False) and 'INDPRO' in combined.columns: | |
| combined['INDPRO_PCT'] = combined['INDPRO'].pct_change() * 100 | |
| if selected.get('Inflation (CPI)', False) and 'CPIAUCSL' in combined.columns: | |
| combined['CPIAUCSL_PCT'] = combined['CPIAUCSL'].pct_change() * 100 | |
| # Interpolate (time index required) | |
| combined = combined.interpolate(method='time') | |
| # Yield spread | |
| if selected.get('Yield Spread (10Y - 2Y)', False) and {'GS10', 'DGS2'}.issubset(combined.columns): | |
| combined['Yield_Spread'] = combined['GS10'] - combined['DGS2'] | |
| return combined | |
| # ---------- Plotting helpers ---------- | |
| def add_recession_shading(fig: go.Figure): | |
| for peak, trough in crash_periods.items(): | |
| fig.add_shape( | |
| type="rect", | |
| xref="x", | |
| yref="paper", | |
| x0=peak, | |
| y0=0, | |
| x1=trough, | |
| y1=1, | |
| fillcolor="gray", | |
| opacity=0.3, | |
| layer="below", | |
| line_width=0, | |
| ) | |
| def finalize_layout(fig: go.Figure, title: str, ytitle: str): | |
| fig.update_layout( | |
| title=title, | |
| xaxis_title='Date', | |
| yaxis_title=ytitle, | |
| template='plotly_dark', # dark-friendly defaults | |
| paper_bgcolor='rgba(0,0,0,0)', # transparent to match theme background | |
| plot_bgcolor='rgba(0,0,0,0)', # transparent to match theme background | |
| font=dict(color="white"), | |
| xaxis=dict( | |
| tickformat="%Y", | |
| tickmode="linear", | |
| dtick="M36", | |
| showspikes=True, | |
| spikemode='across', | |
| spikesnap='cursor', | |
| spikethickness=1 | |
| ), | |
| hovermode="x unified", | |
| hoverlabel=dict( | |
| bgcolor="rgba(14,17,23,0.95)", # blends with backgroundColor "#0e1117" | |
| font_size=12, | |
| font_family="Rockwell", | |
| font_color="white" | |
| ), | |
| legend=dict( | |
| x=0.02, | |
| y=0.95, | |
| traceorder='normal', | |
| bgcolor='rgba(0,0,0,0)', # transparent legend | |
| bordercolor='rgba(0,0,0,0)', | |
| font=dict(color="white"), | |
| title_font=dict(color="white") | |
| ), | |
| margin=dict(l=60, r=20, t=40, b=40) | |
| ) | |
| fig.update_xaxes( | |
| showgrid=True, | |
| gridwidth=1, | |
| gridcolor='rgba(255,255,255,0.12)', # subtle grid for dark | |
| tickangle=45, | |
| tickformatstops=[ | |
| dict(dtickrange=[None, "M1"], value="%b %d, %Y"), | |
| dict(dtickrange=["M1", None], value="%Y") | |
| ] | |
| ) | |
| fig.update_yaxes( | |
| showgrid=True, | |
| gridwidth=1, | |
| gridcolor='rgba(255,255,255,0.12)' | |
| ) | |
| fig.update_traces(hovertemplate='%{x|%b %d, %Y}<br>%{y}<extra></extra>') | |
| # ---------- Interpretation blocks ---------- | |
| def show_interpretation_for(key: str, column, data: pd.DataFrame): | |
| with st.expander("Interpretation", expanded=False): | |
| # Helper to write a bullet line | |
| def blt(text): st.write(f"- {text}") | |
| if key == 'Sahm Recession Indicator' and 'SAHMREALTIME' in data.columns: | |
| s = data['SAHMREALTIME'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| ch_3 = series_change(s, 3, pct=False) | |
| ma3 = s.rolling(3, min_periods=2).mean().iloc[-1] if len(s) else np.nan | |
| blt(f"Latest reading ({d}): **{fmt_val(cur, 2)}**; historical percentile: **{fmt_val(pr,1)}**.") | |
| blt("Rule-of-thumb threshold is **0.5** (dashed line in the chart). Values above this often coincide with recessions.") | |
| if not pd.isna(ch_3): | |
| blt(f"3-period change (approx. 3 months for monthly data): **{fmt_val(ch_3, 2)}** points.") | |
| if not pd.isna(ma3): | |
| blt(f"Trend check: the indicator is {'above' if cur>ma3 else 'below' if cur<ma3 else 'near'} its 3-period average.") | |
| st.write("**How to read**: A sharp rise above 0.5 historically flags ongoing recessions; falling values suggest recovery.") | |
| elif key == 'U.S. Recession Probabilities' and 'RECPROUSM156N' in data.columns: | |
| s = data['RECPROUSM156N'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| ch_3 = series_change(s, 3, pct=False) | |
| blt(f"Latest probability ({d}): **{fmt_val(cur, 1)}%**; historical percentile: **{fmt_val(pr,1)}**.") | |
| if not pd.isna(ch_3): | |
| blt(f"3-period change: **{fmt_val(ch_3,1)}** percentage points.") | |
| blt("Sustained moves to elevated probabilities (e.g., >50%) tend to align with recession periods, but short spikes can be false alarms.") | |
| elif key == 'Yield Spread (10Y - 2Y)' and 'Yield_Spread' in data.columns: | |
| s = data['Yield_Spread'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| inv_streak = inversion_streak(s) | |
| ch_3 = series_change(s, 3, pct=False) | |
| blt(f"Latest spread ({d}): **{fmt_val(cur,2)} pp**; historical percentile: **{fmt_val(pr,1)}**.") | |
| if cur < 0: | |
| blt(f"**Inversion** is active (10Y < 2Y). Current inversion streak: **{inv_streak}** observations.") | |
| else: | |
| blt("Curve is **not inverted** currently.") | |
| if not pd.isna(ch_3): | |
| blt(f"3-period change: **{fmt_val(ch_3,2)}** pp.") | |
| st.write("**How to read**: Deep or persistent inversion often precedes recessions by several months; steepening from very negative levels can signal normalization.") | |
| elif key == 'Stock Market (S&P 500)' and 'SP500' in data.columns: | |
| s = data['SP500'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| r_21 = series_change(s, 21, pct=True) | |
| r_63 = series_change(s, 63, pct=True) | |
| r_252 = series_change(s, 252, pct=True) | |
| rolling_max = s.cummax() | |
| drawdown = float(s.iloc[-1] / rolling_max.iloc[-1] - 1.0) if len(s) else np.nan | |
| vol20 = float(s.pct_change().rolling(20).std(ddof=0).iloc[-1] * np.sqrt(252)) if len(s) >= 20 else np.nan | |
| blt(f"Last close ({d}): **{fmt_val(cur,2)}**; percentile vs history: **{fmt_val(pr,1)}**.") | |
| blt(f"Returns — 1m: **{fmt_pct(r_21)}**, 3m: **{fmt_pct(r_63)}**, 12m: **{fmt_pct(r_252)}**.") | |
| if not pd.isna(drawdown): | |
| blt(f"Drawdown from peak: **{fmt_pct(drawdown)}**.") | |
| if not pd.isna(vol20): | |
| blt(f"Realized vol (20d, annualized): **{fmt_pct(vol20)}**.") | |
| st.write("**How to read**: Equity weakness often leads or coincides with recessions; watch for persistent downtrends and elevated volatility near shaded bands.") | |
| elif key == 'VIX' and 'VIX' in data.columns: | |
| s = data['VIX'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| m20 = float(s.rolling(20).mean().iloc[-1]) if len(s) >= 20 else np.nan | |
| m60 = float(s.rolling(60).mean().iloc[-1]) if len(s) >= 60 else np.nan | |
| blt(f"Latest VIX ({d}): **{fmt_val(cur,2)}**; percentile vs history: **{fmt_val(pr,1)}**.") | |
| if not pd.isna(m20): | |
| blt(f"Position vs 20-day avg: **{('above' if cur>m20 else 'below' if cur<m20 else 'near')}** ({fmt_val(m20,2)}).") | |
| if not pd.isna(m60): | |
| blt(f"Position vs 60-day avg: **{('above' if cur>m60 else 'below' if cur<m60 else 'near')}** ({fmt_val(m60,2)}).") | |
| st.write("**How to read**: High percentiles indicate stress; falling VIX from high levels can mark stabilization, while spikes from low levels often accompany drawdowns.") | |
| elif key == 'Treasury Rates': | |
| cols = [c for c in ['GS10', 'DGS2', 'TB3MS', 'DGS1MO'] if c in data.columns] | |
| if cols: | |
| latests = {c: current_and_date(data[c].dropna())[0] for c in cols} | |
| # Spread diagnostics if available | |
| s_10_2 = data['GS10'] - data['DGS2'] if {'GS10', 'DGS2'}.issubset(data.columns) else None | |
| s_10_3m = data['GS10'] - data['TB3MS'] if {'GS10', 'TB3MS'}.issubset(data.columns) else None | |
| blt("Latest yields (percent): " + ", ".join([f"**{k}={fmt_val(v,2)}**" for k, v in latests.items() if not pd.isna(v)])) | |
| if s_10_2 is not None: | |
| cur = float(s_10_2.dropna().iloc[-1]) | |
| blt(f"10Y−2Y spread: **{fmt_val(cur,2)} pp** ({'inverted' if cur<0 else 'normal'}).") | |
| if s_10_3m is not None: | |
| cur = float(s_10_3m.dropna().iloc[-1]) | |
| blt(f"10Y−3M spread: **{fmt_val(cur,2)} pp** ({'inverted' if cur<0 else 'normal'}).") | |
| st.write("**How to read**: Rising short rates vs long rates flatten/invert the curve. Inversions often precede recessions; re-steepening from very negative levels can precede recoveries.") | |
| elif key == 'Unemployment Rate' and 'UNRATE' in data.columns: | |
| s = data['UNRATE'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| ch_3 = series_change(s, 3, pct=False) | |
| ch_12 = series_change(s, 12, pct=False) | |
| blt(f"Latest unemployment ({d}): **{fmt_val(cur,2)}%**; percentile vs history: **{fmt_val(pr,1)}**.") | |
| if not pd.isna(ch_3): blt(f"Change over 3 periods: **{fmt_val(ch_3,2)} pp**.") | |
| if not pd.isna(ch_12): blt(f"Change over 12 periods: **{fmt_val(ch_12,2)} pp**.") | |
| st.write("**How to read**: Rapid rises often occur around recessions; peaks typically lag recession start dates.") | |
| elif key == 'Nonfarm Payrolls' and 'PAYEMS' in data.columns: | |
| s = data['PAYEMS'].dropna() | |
| cur, d = current_and_date(s) | |
| ch_1 = series_change(s, 1, pct=False) | |
| ch_3 = series_change(s, 3, pct=False) | |
| yoy = series_change(s, 12, pct=True) | |
| blt(f"Latest payrolls ({d}): **{fmt_val(cur,0)}k jobs**.") | |
| if not pd.isna(ch_1): blt(f"1-period change: **{fmt_val(ch_1,0)}k**.") | |
| if not pd.isna(ch_3): blt(f"3-period change: **{fmt_val(ch_3,0)}k**.") | |
| if not pd.isna(yoy): blt(f"12-period growth: **{fmt_pct(yoy)}**.") | |
| st.write("**How to read**: Payroll growth slows before and during recessions; contractions are strong signals of broad weakness.") | |
| elif key == 'Jobless Claims' and 'ICSA' in data.columns: | |
| s = data['ICSA'].dropna() | |
| cur, d = current_and_date(s) | |
| ma4 = float(s.rolling(4).mean().iloc[-1]) if len(s) >= 4 else np.nan | |
| yoy = series_change(s, 52, pct=True) # weekly series (approx.) | |
| blt(f"Latest claims ({d}): **{fmt_val(cur,0)}**; 4-wk avg: **{fmt_val(ma4,0)}**.") | |
| if not pd.isna(yoy): blt(f"YoY change (approx.): **{fmt_pct(yoy)}**.") | |
| st.write("**How to read**: Persistent uptrends in the 4-week average often precede rising unemployment and recessions.") | |
| elif key == 'Retail Sales' and 'RSXFS' in data.columns: | |
| s = data['RSXFS'].dropna() | |
| cur, d = current_and_date(s) | |
| mom = series_change(s, 1, pct=True) | |
| qoq = series_change(s, 3, pct=True) | |
| yoy = series_change(s, 12, pct=True) | |
| blt(f"Latest ({d}): **{fmt_val(cur,2)}** (index). MoM: **{fmt_pct(mom)}**, QoQ: **{fmt_pct(qoq)}**, YoY: **{fmt_pct(yoy)}**.") | |
| st.write("**How to read**: Retail sales proxy consumption strength; broad slowdowns or contractions often align with late-cycle and recessionary phases.") | |
| elif key == 'Industrial Production' and 'INDPRO' in data.columns: | |
| s = data['INDPRO'].dropna() | |
| pct = data.get('INDPRO_PCT', pd.Series(dtype='float64')).dropna() | |
| cur, d = current_and_date(s) | |
| yoy = series_change(s, 12, pct=True) | |
| blt(f"Level ({d}): **{fmt_val(cur,2)}** (index). YoY: **{fmt_pct(yoy)}**.") | |
| if not pct.empty: | |
| blt(f"Latest monthly change: **{fmt_val(pct.iloc[-1],2)}%**; average over last 6m: **{fmt_val(pct.tail(6).mean(),2)}%**.") | |
| st.write("**How to read**: Production falls and negative monthly prints tend to cluster near recessions; rebounds suggest early recovery.") | |
| elif key == 'Housing Starts' and 'HOUST' in data.columns: | |
| s = data['HOUST'].dropna() | |
| cur, d = current_and_date(s) | |
| mom = series_change(s, 1, pct=True) | |
| yoy = series_change(s, 12, pct=True) | |
| blt(f"Latest starts ({d}): **{fmt_val(cur,0)}k** (annualized). MoM: **{fmt_pct(mom)}**, YoY: **{fmt_pct(yoy)}**.") | |
| st.write("**How to read**: Housing is interest-rate sensitive and typically weakens well before recessions; stabilization often leads broader upturns.") | |
| elif key == 'Consumer Confidence' and 'UMCSENT' in data.columns: | |
| s = data['UMCSENT'].dropna() | |
| cur, d = current_and_date(s) | |
| pr = pct_rank(s, cur) | |
| mom = series_change(s, 1, pct=False) | |
| yoy = series_change(s, 12, pct=False) | |
| blt(f"Latest sentiment ({d}): **{fmt_val(cur,1)}**; percentile vs history: **{fmt_val(pr,1)}**.") | |
| if not pd.isna(mom): blt(f"1-period change: **{fmt_val(mom,1)}** points.") | |
| if not pd.isna(yoy): blt(f"12-period change: **{fmt_val(yoy,1)}** points.") | |
| st.write("**How to read**: Collapses in sentiment often occur around recessions; recovering sentiment can confirm early-cycle improvement.") | |
| elif key == 'Inflation (CPI)' and 'CPIAUCSL' in data.columns: | |
| s = data['CPIAUCSL'].dropna() | |
| mom = data.get('CPIAUCSL_PCT', pd.Series(dtype='float64')).dropna() | |
| cur, d = current_and_date(s) | |
| yoy = series_change(s, 12, pct=True) | |
| blt(f"CPI level ({d}): **{fmt_val(cur,1)}** (index). YoY: **{fmt_pct(yoy)}**.") | |
| if not mom.empty: | |
| blt(f"Latest month-over-month change: **{fmt_val(mom.iloc[-1],2)}%**; 3-month average: **{fmt_val(mom.tail(3).mean(),2)}%**.") | |
| st.write("**How to read**: Cooling inflation eases pressure on policy and supports soft-landing scenarios; re-acceleration risks tighter financial conditions.") | |
| else: | |
| st.write("No interpretation available for this selection (insufficient data).") | |
| # ---------- Main render ---------- | |
| if st.session_state.run_analysis: | |
| with st.spinner("Fetching data and building charts..."): | |
| combined_data = build_dataset(selected_indicators) | |
| if combined_data.empty: | |
| st.error("No data was successfully fetched for the selected indicators.") | |
| else: | |
| # Loop through selections and plot | |
| for key, column in indicators.items(): | |
| if not selected_indicators.get(key, False): | |
| continue | |
| fig = go.Figure() | |
| add_recession_shading(fig) | |
| if isinstance(column, tuple): | |
| # Industrial Production: level + % change on y2 | |
| if column == ('INDPRO', 'INDPRO_PCT') and 'INDPRO' in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data['INDPRO'], | |
| mode='lines', name='Industrial Production' | |
| )) | |
| if 'INDPRO_PCT' in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data['INDPRO_PCT'], | |
| mode='lines', name='Industrial Production % Change', yaxis='y2' | |
| )) | |
| fig.update_layout(yaxis2=dict( | |
| title="Industrial Production % Change", | |
| overlaying='y', side='right' | |
| )) | |
| finalize_layout(fig, key, key) | |
| # Inflation: CPI + % change on y2 | |
| elif column == ('CPIAUCSL', 'CPIAUCSL_PCT') and 'CPIAUCSL' in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data['CPIAUCSL'], | |
| mode='lines', name='Inflation (CPI)' | |
| )) | |
| if 'CPIAUCSL_PCT' in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data['CPIAUCSL_PCT'], | |
| mode='lines', name='Inflation % Change', yaxis='y2' | |
| )) | |
| fig.update_layout(yaxis2=dict( | |
| title="Inflation % Change", | |
| overlaying='y', side='right' | |
| )) | |
| finalize_layout(fig, key, key) | |
| # Treasury rates: plot each available | |
| elif column == ('GS10', 'DGS2', 'DGS1MO', 'TB3MS'): | |
| any_added = False | |
| for col in column: | |
| if col in combined_data.columns: | |
| any_added = True | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data[col], mode='lines', name=col | |
| )) | |
| if any_added: | |
| finalize_layout(fig, key, key) | |
| else: | |
| # Generic multi-series if needed | |
| for col in column: | |
| if col in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data[col], mode='lines', name=col | |
| )) | |
| finalize_layout(fig, key, key) | |
| else: | |
| # Single series or derived | |
| if column in combined_data.columns: | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data[column], mode='lines', name=key | |
| )) | |
| if key == 'Sahm Recession Indicator': | |
| fig.add_hline( | |
| y=0.5, line=dict(color="#ff6b6b", dash="dash"), | |
| annotation_text="Recession Threshold", | |
| annotation_position="bottom right" | |
| ) | |
| finalize_layout(fig, key, key) | |
| elif column == 'Yield_Spread' and {'GS10', 'DGS2'}.issubset(combined_data.columns): | |
| fig.add_trace(go.Scatter( | |
| x=combined_data.index, y=combined_data['Yield_Spread'], | |
| mode='lines', name='Yield Spread (10Y - 2Y)' | |
| )) | |
| finalize_layout(fig, key, key) | |
| # Only render if we actually added something beyond the shading | |
| if fig.data: | |
| st.plotly_chart(fig, use_container_width=True) | |
| # --- Interpretation for this panel --- | |
| show_interpretation_for(key, column, combined_data) | |
| # ---------- Hide default Streamlit branding ---------- | |
| hide_streamlit_style = """ | |
| <style> | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| </style> | |
| """ | |
| st.markdown(hide_streamlit_style, unsafe_allow_html=True) | |