Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -4,6 +4,7 @@ import pandas_datareader.data as web
|
|
| 4 |
import yfinance as yf
|
| 5 |
import datetime
|
| 6 |
import plotly.graph_objs as go
|
|
|
|
| 7 |
|
| 8 |
# ---------- Page config (must be the first Streamlit call) ----------
|
| 9 |
st.set_page_config(layout="wide")
|
|
@@ -46,6 +47,7 @@ with st.sidebar.expander("How to Use", expanded=False):
|
|
| 46 |
|
| 47 |
st.sidebar.header("Select Indicators")
|
| 48 |
with st.sidebar.expander("Indicators", expanded=True):
|
|
|
|
| 49 |
indicators = {
|
| 50 |
'Sahm Recession Indicator': 'SAHMREALTIME',
|
| 51 |
'U.S. Recession Probabilities': 'RECPROUSM156N',
|
|
@@ -53,7 +55,7 @@ with st.sidebar.expander("Indicators", expanded=True):
|
|
| 53 |
'Stock Market (S&P 500)': 'SP500', # Fetched from yfinance
|
| 54 |
'VIX': 'VIX', # Fetched from yfinance
|
| 55 |
'Treasury Rates': ('GS10', 'DGS2', 'DGS1MO', 'TB3MS'),
|
| 56 |
-
'Federal Funds Rate': 'FEDFUNDS',
|
| 57 |
'Unemployment Rate': 'UNRATE',
|
| 58 |
'Nonfarm Payrolls': 'PAYEMS',
|
| 59 |
'Jobless Claims': 'ICSA',
|
|
@@ -65,7 +67,7 @@ with st.sidebar.expander("Indicators", expanded=True):
|
|
| 65 |
}
|
| 66 |
selected_indicators = {key: st.checkbox(key, value=True) for key in indicators.keys()}
|
| 67 |
|
| 68 |
-
# Single Run button (no clear
|
| 69 |
if st.sidebar.button("Run Analysis"):
|
| 70 |
st.session_state.run_analysis = True
|
| 71 |
|
|
@@ -92,6 +94,45 @@ crash_periods = {
|
|
| 92 |
'2020-02-01': '2020-04-01'
|
| 93 |
}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
# ---------- Cached data fetchers ----------
|
| 96 |
@st.cache_data(ttl=6 * 60 * 60, show_spinner=False)
|
| 97 |
def fetch_fred_series(series_code: str, start: datetime.datetime, end: datetime.datetime) -> pd.Series:
|
|
@@ -196,8 +237,8 @@ def finalize_layout(fig: go.Figure, title: str, ytitle: str):
|
|
| 196 |
xaxis_title='Date',
|
| 197 |
yaxis_title=ytitle,
|
| 198 |
template='plotly_dark', # dark-friendly defaults
|
| 199 |
-
paper_bgcolor='rgba(0,0,0,0)', # transparent to match
|
| 200 |
-
plot_bgcolor='rgba(0,0,0,0)', # transparent to match
|
| 201 |
font=dict(color="white"),
|
| 202 |
xaxis=dict(
|
| 203 |
tickformat="%Y",
|
|
@@ -243,6 +284,181 @@ def finalize_layout(fig: go.Figure, title: str, ytitle: str):
|
|
| 243 |
)
|
| 244 |
fig.update_traces(hovertemplate='%{x|%b %d, %Y}<br>%{y}<extra></extra>')
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
# ---------- Main render ----------
|
| 247 |
if st.session_state.run_analysis:
|
| 248 |
with st.spinner("Fetching data and building charts..."):
|
|
@@ -321,7 +537,7 @@ if st.session_state.run_analysis:
|
|
| 321 |
fig.add_trace(go.Scatter(
|
| 322 |
x=combined_data.index, y=combined_data[column], mode='lines', name=key
|
| 323 |
))
|
| 324 |
-
if
|
| 325 |
fig.add_hline(
|
| 326 |
y=0.5, line=dict(color="#ff6b6b", dash="dash"),
|
| 327 |
annotation_text="Recession Threshold",
|
|
@@ -339,6 +555,8 @@ if st.session_state.run_analysis:
|
|
| 339 |
# Only render if we actually added something beyond the shading
|
| 340 |
if fig.data:
|
| 341 |
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
|
|
|
| 342 |
|
| 343 |
# ---------- Hide default Streamlit branding ----------
|
| 344 |
hide_streamlit_style = """
|
|
|
|
| 4 |
import yfinance as yf
|
| 5 |
import datetime
|
| 6 |
import plotly.graph_objs as go
|
| 7 |
+
import numpy as np
|
| 8 |
|
| 9 |
# ---------- Page config (must be the first Streamlit call) ----------
|
| 10 |
st.set_page_config(layout="wide")
|
|
|
|
| 47 |
|
| 48 |
st.sidebar.header("Select Indicators")
|
| 49 |
with st.sidebar.expander("Indicators", expanded=True):
|
| 50 |
+
# Removed "Federal Funds Rate" per your request
|
| 51 |
indicators = {
|
| 52 |
'Sahm Recession Indicator': 'SAHMREALTIME',
|
| 53 |
'U.S. Recession Probabilities': 'RECPROUSM156N',
|
|
|
|
| 55 |
'Stock Market (S&P 500)': 'SP500', # Fetched from yfinance
|
| 56 |
'VIX': 'VIX', # Fetched from yfinance
|
| 57 |
'Treasury Rates': ('GS10', 'DGS2', 'DGS1MO', 'TB3MS'),
|
| 58 |
+
# 'Federal Funds Rate': 'FEDFUNDS', # <-- removed
|
| 59 |
'Unemployment Rate': 'UNRATE',
|
| 60 |
'Nonfarm Payrolls': 'PAYEMS',
|
| 61 |
'Jobless Claims': 'ICSA',
|
|
|
|
| 67 |
}
|
| 68 |
selected_indicators = {key: st.checkbox(key, value=True) for key in indicators.keys()}
|
| 69 |
|
| 70 |
+
# Single Run button (no explicit "clear" — re-running implies clearing)
|
| 71 |
if st.sidebar.button("Run Analysis"):
|
| 72 |
st.session_state.run_analysis = True
|
| 73 |
|
|
|
|
| 94 |
'2020-02-01': '2020-04-01'
|
| 95 |
}
|
| 96 |
|
| 97 |
+
# ---------- Helpers ----------
|
| 98 |
+
def pct_rank(series: pd.Series, value: float) -> float:
|
| 99 |
+
s = pd.to_numeric(series, errors="coerce").dropna()
|
| 100 |
+
if s.empty or not np.isfinite(value):
|
| 101 |
+
return np.nan
|
| 102 |
+
return float((s < value).mean() * 100.0)
|
| 103 |
+
|
| 104 |
+
def fmt_pct(x, decimals=1):
|
| 105 |
+
return "n/a" if pd.isna(x) else f"{x*100:.{decimals}f}%"
|
| 106 |
+
|
| 107 |
+
def fmt_val(x, decimals=2):
|
| 108 |
+
return "n/a" if pd.isna(x) else f"{x:.{decimals}f}"
|
| 109 |
+
|
| 110 |
+
def series_change(s: pd.Series, periods: int = 1, pct: bool = True):
|
| 111 |
+
if len(s) <= periods:
|
| 112 |
+
return np.nan
|
| 113 |
+
if pct:
|
| 114 |
+
return float((s.iloc[-1] / s.iloc[-(periods+1)] - 1.0))
|
| 115 |
+
else:
|
| 116 |
+
return float(s.iloc[-1] - s.iloc[-(periods+1)])
|
| 117 |
+
|
| 118 |
+
def current_and_date(s: pd.Series):
|
| 119 |
+
if s is None or s.empty:
|
| 120 |
+
return np.nan, "n/a"
|
| 121 |
+
return float(s.iloc[-1]), s.index[-1].date().isoformat()
|
| 122 |
+
|
| 123 |
+
def inversion_streak(series: pd.Series):
|
| 124 |
+
"""Consecutive periods the series has been < 0 at the end of series."""
|
| 125 |
+
if series is None or series.dropna().empty:
|
| 126 |
+
return 0
|
| 127 |
+
v = (series < 0).astype(int).to_numpy()
|
| 128 |
+
streak = 0
|
| 129 |
+
for x in v[::-1]:
|
| 130 |
+
if x == 1:
|
| 131 |
+
streak += 1
|
| 132 |
+
else:
|
| 133 |
+
break
|
| 134 |
+
return streak
|
| 135 |
+
|
| 136 |
# ---------- Cached data fetchers ----------
|
| 137 |
@st.cache_data(ttl=6 * 60 * 60, show_spinner=False)
|
| 138 |
def fetch_fred_series(series_code: str, start: datetime.datetime, end: datetime.datetime) -> pd.Series:
|
|
|
|
| 237 |
xaxis_title='Date',
|
| 238 |
yaxis_title=ytitle,
|
| 239 |
template='plotly_dark', # dark-friendly defaults
|
| 240 |
+
paper_bgcolor='rgba(0,0,0,0)', # transparent to match theme background
|
| 241 |
+
plot_bgcolor='rgba(0,0,0,0)', # transparent to match theme background
|
| 242 |
font=dict(color="white"),
|
| 243 |
xaxis=dict(
|
| 244 |
tickformat="%Y",
|
|
|
|
| 284 |
)
|
| 285 |
fig.update_traces(hovertemplate='%{x|%b %d, %Y}<br>%{y}<extra></extra>')
|
| 286 |
|
| 287 |
+
# ---------- Interpretation blocks ----------
|
| 288 |
+
def show_interpretation_for(key: str, column, data: pd.DataFrame):
|
| 289 |
+
with st.expander("Interpretation", expanded=False):
|
| 290 |
+
# Helper to write a bullet line
|
| 291 |
+
def blt(text): st.write(f"- {text}")
|
| 292 |
+
|
| 293 |
+
if key == 'Sahm Recession Indicator' and 'SAHMREALTIME' in data.columns:
|
| 294 |
+
s = data['SAHMREALTIME'].dropna()
|
| 295 |
+
cur, d = current_and_date(s)
|
| 296 |
+
pr = pct_rank(s, cur)
|
| 297 |
+
ch_3 = series_change(s, 3, pct=False)
|
| 298 |
+
ma3 = s.rolling(3, min_periods=2).mean().iloc[-1] if len(s) else np.nan
|
| 299 |
+
blt(f"Latest reading ({d}): **{fmt_val(cur, 2)}**; historical percentile: **{fmt_val(pr,1)}**.")
|
| 300 |
+
blt("Rule-of-thumb threshold is **0.5** (dashed line in the chart). Values above this often coincide with recessions.")
|
| 301 |
+
if not pd.isna(ch_3):
|
| 302 |
+
blt(f"3-period change (approx. 3 months for monthly data): **{fmt_val(ch_3, 2)}** points.")
|
| 303 |
+
if not pd.isna(ma3):
|
| 304 |
+
blt(f"Trend check: the indicator is {'above' if cur>ma3 else 'below' if cur<ma3 else 'near'} its 3-period average.")
|
| 305 |
+
st.write("**How to read**: A sharp rise above 0.5 historically flags ongoing recessions; falling values suggest recovery.")
|
| 306 |
+
|
| 307 |
+
elif key == 'U.S. Recession Probabilities' and 'RECPROUSM156N' in data.columns:
|
| 308 |
+
s = data['RECPROUSM156N'].dropna()
|
| 309 |
+
cur, d = current_and_date(s)
|
| 310 |
+
pr = pct_rank(s, cur)
|
| 311 |
+
ch_3 = series_change(s, 3, pct=False)
|
| 312 |
+
blt(f"Latest probability ({d}): **{fmt_val(cur, 1)}%**; historical percentile: **{fmt_val(pr,1)}**.")
|
| 313 |
+
if not pd.isna(ch_3):
|
| 314 |
+
blt(f"3-period change: **{fmt_val(ch_3,1)}** percentage points.")
|
| 315 |
+
blt("Sustained moves to elevated probabilities (e.g., >50%) tend to align with recession periods, but short spikes can be false alarms.")
|
| 316 |
+
|
| 317 |
+
elif key == 'Yield Spread (10Y - 2Y)' and 'Yield_Spread' in data.columns:
|
| 318 |
+
s = data['Yield_Spread'].dropna()
|
| 319 |
+
cur, d = current_and_date(s)
|
| 320 |
+
pr = pct_rank(s, cur)
|
| 321 |
+
inv_streak = inversion_streak(s)
|
| 322 |
+
ch_3 = series_change(s, 3, pct=False)
|
| 323 |
+
blt(f"Latest spread ({d}): **{fmt_val(cur,2)} pp**; historical percentile: **{fmt_val(pr,1)}**.")
|
| 324 |
+
if cur < 0:
|
| 325 |
+
blt(f"**Inversion** is active (10Y < 2Y). Current inversion streak: **{inv_streak}** observations.")
|
| 326 |
+
else:
|
| 327 |
+
blt("Curve is **not inverted** currently.")
|
| 328 |
+
if not pd.isna(ch_3):
|
| 329 |
+
blt(f"3-period change: **{fmt_val(ch_3,2)}** pp.")
|
| 330 |
+
st.write("**How to read**: Deep or persistent inversion often precedes recessions by several months; steepening from very negative levels can signal normalization.")
|
| 331 |
+
|
| 332 |
+
elif key == 'Stock Market (S&P 500)' and 'SP500' in data.columns:
|
| 333 |
+
s = data['SP500'].dropna()
|
| 334 |
+
cur, d = current_and_date(s)
|
| 335 |
+
pr = pct_rank(s, cur)
|
| 336 |
+
r_21 = series_change(s, 21, pct=True)
|
| 337 |
+
r_63 = series_change(s, 63, pct=True)
|
| 338 |
+
r_252 = series_change(s, 252, pct=True)
|
| 339 |
+
rolling_max = s.cummax()
|
| 340 |
+
drawdown = float(s.iloc[-1] / rolling_max.iloc[-1] - 1.0) if len(s) else np.nan
|
| 341 |
+
vol20 = float(s.pct_change().rolling(20).std(ddof=0).iloc[-1] * np.sqrt(252)) if len(s) >= 20 else np.nan
|
| 342 |
+
blt(f"Last close ({d}): **{fmt_val(cur,2)}**; percentile vs history: **{fmt_val(pr,1)}**.")
|
| 343 |
+
blt(f"Returns — 1m: **{fmt_pct(r_21)}**, 3m: **{fmt_pct(r_63)}**, 12m: **{fmt_pct(r_252)}**.")
|
| 344 |
+
if not pd.isna(drawdown):
|
| 345 |
+
blt(f"Drawdown from peak: **{fmt_pct(drawdown)}**.")
|
| 346 |
+
if not pd.isna(vol20):
|
| 347 |
+
blt(f"Realized vol (20d, annualized): **{fmt_pct(vol20)}**.")
|
| 348 |
+
st.write("**How to read**: Equity weakness often leads or coincides with recessions; watch for persistent downtrends and elevated volatility near shaded bands.")
|
| 349 |
+
|
| 350 |
+
elif key == 'VIX' and 'VIX' in data.columns:
|
| 351 |
+
s = data['VIX'].dropna()
|
| 352 |
+
cur, d = current_and_date(s)
|
| 353 |
+
pr = pct_rank(s, cur)
|
| 354 |
+
m20 = float(s.rolling(20).mean().iloc[-1]) if len(s) >= 20 else np.nan
|
| 355 |
+
m60 = float(s.rolling(60).mean().iloc[-1]) if len(s) >= 60 else np.nan
|
| 356 |
+
blt(f"Latest VIX ({d}): **{fmt_val(cur,2)}**; percentile vs history: **{fmt_val(pr,1)}**.")
|
| 357 |
+
if not pd.isna(m20):
|
| 358 |
+
blt(f"Position vs 20-day avg: **{('above' if cur>m20 else 'below' if cur<m20 else 'near')}** ({fmt_val(m20,2)}).")
|
| 359 |
+
if not pd.isna(m60):
|
| 360 |
+
blt(f"Position vs 60-day avg: **{('above' if cur>m60 else 'below' if cur<m60 else 'near')}** ({fmt_val(m60,2)}).")
|
| 361 |
+
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.")
|
| 362 |
+
|
| 363 |
+
elif key == 'Treasury Rates':
|
| 364 |
+
cols = [c for c in ['GS10', 'DGS2', 'TB3MS', 'DGS1MO'] if c in data.columns]
|
| 365 |
+
if cols:
|
| 366 |
+
latests = {c: current_and_date(data[c].dropna())[0] for c in cols}
|
| 367 |
+
# Spread diagnostics if available
|
| 368 |
+
s_10_2 = data['GS10'] - data['DGS2'] if {'GS10', 'DGS2'}.issubset(data.columns) else None
|
| 369 |
+
s_10_3m = data['GS10'] - data['TB3MS'] if {'GS10', 'TB3MS'}.issubset(data.columns) else None
|
| 370 |
+
blt("Latest yields (percent): " + ", ".join([f"**{k}={fmt_val(v,2)}**" for k, v in latests.items() if not pd.isna(v)]))
|
| 371 |
+
if s_10_2 is not None:
|
| 372 |
+
cur = float(s_10_2.dropna().iloc[-1])
|
| 373 |
+
blt(f"10Y−2Y spread: **{fmt_val(cur,2)} pp** ({'inverted' if cur<0 else 'normal'}).")
|
| 374 |
+
if s_10_3m is not None:
|
| 375 |
+
cur = float(s_10_3m.dropna().iloc[-1])
|
| 376 |
+
blt(f"10Y−3M spread: **{fmt_val(cur,2)} pp** ({'inverted' if cur<0 else 'normal'}).")
|
| 377 |
+
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.")
|
| 378 |
+
|
| 379 |
+
elif key == 'Unemployment Rate' and 'UNRATE' in data.columns:
|
| 380 |
+
s = data['UNRATE'].dropna()
|
| 381 |
+
cur, d = current_and_date(s)
|
| 382 |
+
pr = pct_rank(s, cur)
|
| 383 |
+
ch_3 = series_change(s, 3, pct=False)
|
| 384 |
+
ch_12 = series_change(s, 12, pct=False)
|
| 385 |
+
blt(f"Latest unemployment ({d}): **{fmt_val(cur,2)}%**; percentile vs history: **{fmt_val(pr,1)}**.")
|
| 386 |
+
if not pd.isna(ch_3): blt(f"Change over 3 periods: **{fmt_val(ch_3,2)} pp**.")
|
| 387 |
+
if not pd.isna(ch_12): blt(f"Change over 12 periods: **{fmt_val(ch_12,2)} pp**.")
|
| 388 |
+
st.write("**How to read**: Rapid rises often occur around recessions; peaks typically lag recession start dates.")
|
| 389 |
+
|
| 390 |
+
elif key == 'Nonfarm Payrolls' and 'PAYEMS' in data.columns:
|
| 391 |
+
s = data['PAYEMS'].dropna()
|
| 392 |
+
cur, d = current_and_date(s)
|
| 393 |
+
ch_1 = series_change(s, 1, pct=False)
|
| 394 |
+
ch_3 = series_change(s, 3, pct=False)
|
| 395 |
+
yoy = series_change(s, 12, pct=True)
|
| 396 |
+
blt(f"Latest payrolls ({d}): **{fmt_val(cur,0)}k jobs**.")
|
| 397 |
+
if not pd.isna(ch_1): blt(f"1-period change: **{fmt_val(ch_1,0)}k**.")
|
| 398 |
+
if not pd.isna(ch_3): blt(f"3-period change: **{fmt_val(ch_3,0)}k**.")
|
| 399 |
+
if not pd.isna(yoy): blt(f"12-period growth: **{fmt_pct(yoy)}**.")
|
| 400 |
+
st.write("**How to read**: Payroll growth slows before and during recessions; contractions are strong signals of broad weakness.")
|
| 401 |
+
|
| 402 |
+
elif key == 'Jobless Claims' and 'ICSA' in data.columns:
|
| 403 |
+
s = data['ICSA'].dropna()
|
| 404 |
+
cur, d = current_and_date(s)
|
| 405 |
+
ma4 = float(s.rolling(4).mean().iloc[-1]) if len(s) >= 4 else np.nan
|
| 406 |
+
yoy = series_change(s, 52, pct=True) # weekly series (approx.)
|
| 407 |
+
blt(f"Latest claims ({d}): **{fmt_val(cur,0)}**; 4-wk avg: **{fmt_val(ma4,0)}**.")
|
| 408 |
+
if not pd.isna(yoy): blt(f"YoY change (approx.): **{fmt_pct(yoy)}**.")
|
| 409 |
+
st.write("**How to read**: Persistent uptrends in the 4-week average often precede rising unemployment and recessions.")
|
| 410 |
+
|
| 411 |
+
elif key == 'Retail Sales' and 'RSXFS' in data.columns:
|
| 412 |
+
s = data['RSXFS'].dropna()
|
| 413 |
+
cur, d = current_and_date(s)
|
| 414 |
+
mom = series_change(s, 1, pct=True)
|
| 415 |
+
qoq = series_change(s, 3, pct=True)
|
| 416 |
+
yoy = series_change(s, 12, pct=True)
|
| 417 |
+
blt(f"Latest ({d}): **{fmt_val(cur,2)}** (index). MoM: **{fmt_pct(mom)}**, QoQ: **{fmt_pct(qoq)}**, YoY: **{fmt_pct(yoy)}**.")
|
| 418 |
+
st.write("**How to read**: Retail sales proxy consumption strength; broad slowdowns or contractions often align with late-cycle and recessionary phases.")
|
| 419 |
+
|
| 420 |
+
elif key == 'Industrial Production' and 'INDPRO' in data.columns:
|
| 421 |
+
s = data['INDPRO'].dropna()
|
| 422 |
+
pct = data.get('INDPRO_PCT', pd.Series(dtype='float64')).dropna()
|
| 423 |
+
cur, d = current_and_date(s)
|
| 424 |
+
yoy = series_change(s, 12, pct=True)
|
| 425 |
+
blt(f"Level ({d}): **{fmt_val(cur,2)}** (index). YoY: **{fmt_pct(yoy)}**.")
|
| 426 |
+
if not pct.empty:
|
| 427 |
+
blt(f"Latest monthly change: **{fmt_val(pct.iloc[-1],2)}%**; average over last 6m: **{fmt_val(pct.tail(6).mean(),2)}%**.")
|
| 428 |
+
st.write("**How to read**: Production falls and negative monthly prints tend to cluster near recessions; rebounds suggest early recovery.")
|
| 429 |
+
|
| 430 |
+
elif key == 'Housing Starts' and 'HOUST' in data.columns:
|
| 431 |
+
s = data['HOUST'].dropna()
|
| 432 |
+
cur, d = current_and_date(s)
|
| 433 |
+
mom = series_change(s, 1, pct=True)
|
| 434 |
+
yoy = series_change(s, 12, pct=True)
|
| 435 |
+
blt(f"Latest starts ({d}): **{fmt_val(cur,0)}k** (annualized). MoM: **{fmt_pct(mom)}**, YoY: **{fmt_pct(yoy)}**.")
|
| 436 |
+
st.write("**How to read**: Housing is interest-rate sensitive and typically weakens well before recessions; stabilization often leads broader upturns.")
|
| 437 |
+
|
| 438 |
+
elif key == 'Consumer Confidence' and 'UMCSENT' in data.columns:
|
| 439 |
+
s = data['UMCSENT'].dropna()
|
| 440 |
+
cur, d = current_and_date(s)
|
| 441 |
+
pr = pct_rank(s, cur)
|
| 442 |
+
mom = series_change(s, 1, pct=False)
|
| 443 |
+
yoy = series_change(s, 12, pct=False)
|
| 444 |
+
blt(f"Latest sentiment ({d}): **{fmt_val(cur,1)}**; percentile vs history: **{fmt_val(pr,1)}**.")
|
| 445 |
+
if not pd.isna(mom): blt(f"1-period change: **{fmt_val(mom,1)}** points.")
|
| 446 |
+
if not pd.isna(yoy): blt(f"12-period change: **{fmt_val(yoy,1)}** points.")
|
| 447 |
+
st.write("**How to read**: Collapses in sentiment often occur around recessions; recovering sentiment can confirm early-cycle improvement.")
|
| 448 |
+
|
| 449 |
+
elif key == 'Inflation (CPI)' and 'CPIAUCSL' in data.columns:
|
| 450 |
+
s = data['CPIAUCSL'].dropna()
|
| 451 |
+
mom = data.get('CPIAUCSL_PCT', pd.Series(dtype='float64')).dropna()
|
| 452 |
+
cur, d = current_and_date(s)
|
| 453 |
+
yoy = series_change(s, 12, pct=True)
|
| 454 |
+
blt(f"CPI level ({d}): **{fmt_val(cur,1)}** (index). YoY: **{fmt_pct(yoy)}**.")
|
| 455 |
+
if not mom.empty:
|
| 456 |
+
blt(f"Latest month-over-month change: **{fmt_val(mom.iloc[-1],2)}%**; 3-month average: **{fmt_val(mom.tail(3).mean(),2)}%**.")
|
| 457 |
+
st.write("**How to read**: Cooling inflation eases pressure on policy and supports soft-landing scenarios; re-acceleration risks tighter financial conditions.")
|
| 458 |
+
|
| 459 |
+
else:
|
| 460 |
+
st.write("No interpretation available for this selection (insufficient data).")
|
| 461 |
+
|
| 462 |
# ---------- Main render ----------
|
| 463 |
if st.session_state.run_analysis:
|
| 464 |
with st.spinner("Fetching data and building charts..."):
|
|
|
|
| 537 |
fig.add_trace(go.Scatter(
|
| 538 |
x=combined_data.index, y=combined_data[column], mode='lines', name=key
|
| 539 |
))
|
| 540 |
+
if key == 'Sahm Recession Indicator':
|
| 541 |
fig.add_hline(
|
| 542 |
y=0.5, line=dict(color="#ff6b6b", dash="dash"),
|
| 543 |
annotation_text="Recession Threshold",
|
|
|
|
| 555 |
# Only render if we actually added something beyond the shading
|
| 556 |
if fig.data:
|
| 557 |
st.plotly_chart(fig, use_container_width=True)
|
| 558 |
+
# --- Interpretation for this panel ---
|
| 559 |
+
show_interpretation_for(key, column, combined_data)
|
| 560 |
|
| 561 |
# ---------- Hide default Streamlit branding ----------
|
| 562 |
hide_streamlit_style = """
|