QuantumLearner commited on
Commit
398ed44
·
verified ·
1 Parent(s): ffb1755

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +223 -5
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 buttons)
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 Streamlit theme
200
- plot_bgcolor='rgba(0,0,0,0)', # transparent to match Streamlit theme
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 column == 'SAHMREALTIME':
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 = """