Spaces:
Sleeping
Sleeping
| # app.py — Macro Cycle Composite (2 sections, production style) | |
| import math | |
| from datetime import datetime, date, timedelta | |
| import numpy as np | |
| import pandas as pd | |
| import pandas_datareader.data as web | |
| import yfinance as yf | |
| import streamlit as st | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| import plotly.express as px | |
| # --------------------------- Page config --------------------------- | |
| st.set_page_config( | |
| page_title="Market Cycle Composite", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| st.title("Market Cycle Phases") | |
| st.markdown( | |
| "Classifies the market cycle each period: **Early**, **Mid-Late**, **Decline**, or **Uncertain**. " | |
| "The solid line is the composite score; the dashed line is S&P 500 YoY. " | |
| "Colored bands show the active phase over time. " | |
| "Use the sidebar to set the sample start, smoothing, slope lookback, thresholds, and minimum run. " | |
| "Click **Run Analysis** to update the charts." | |
| ) | |
| # --------------------------- Constants ----------------------------- | |
| TODAY_PLUS_1 = (date.today() + timedelta(days=1)) | |
| DEFAULT_START = date(2000, 1, 1) | |
| FRED_MAP = { | |
| 'LEI' : 'USSLIND', | |
| 'Philly Manuf Diff' : 'GACDFSA066MSFRBPHI', | |
| 'Texas Serv Diff' : 'TSSOSBACTUAMFRBDAL', | |
| 'Capacity Util' : 'CUMFNS', | |
| 'BBK Leading' : 'BBKMLEIX', | |
| 'CFNAI 3MMA' : 'CFNAIMA3', | |
| 'Core CPI' : 'CPILFESL', | |
| 'Core PCE' : 'PCEPILFE', | |
| 'Hourly Wage' : 'CES0500000003', | |
| 'PPI' : 'PPIACO', | |
| 'Commodities' : 'PALLFNFINDEXM', | |
| '10Y' : 'DGS10', | |
| 'HY OAS' : 'BAMLH0A0HYM2', | |
| 'StLouis FSI' : 'STLFSI4', | |
| } | |
| PHASE_COLORS = { | |
| 'Early' : '#54d62c', | |
| 'Mid-Late' : '#3fa1ff', | |
| 'Decline' : '#ff4c4c', | |
| 'Uncertain' : '#ffd400' | |
| } | |
| BG = "#0e1117" | |
| # --------------------------- Caching ------------------------------- | |
| def load_macro_series(start: date, end: date) -> pd.DataFrame: | |
| raw = {} | |
| for k, tkr in FRED_MAP.items(): | |
| s = web.DataReader(tkr, 'fred', start, end).squeeze() | |
| raw[k] = s | |
| df = pd.DataFrame(raw) | |
| return df | |
| def load_spx(start: date, end: date) -> pd.Series: | |
| data = yf.download('^GSPC', start=start, end=end, auto_adjust=False, progress=False) | |
| data.columns = data.columns.get_level_values(0) | |
| spx = data['Adj Close'].resample('ME').last().ffill() | |
| return spx | |
| # --------------------------- Sidebar ------------------------------- | |
| with st.sidebar: | |
| st.title("Parameters") | |
| with st.expander("Data Range", expanded=False): | |
| start_date = st.date_input( | |
| "Start Date", | |
| value=DEFAULT_START, | |
| min_value=date(1950, 1, 1), | |
| max_value=TODAY_PLUS_1, | |
| help=( | |
| "Start date for all series. " | |
| "Earlier = more history, heavier load. " | |
| "Later = less history." | |
| ) | |
| ) | |
| st.caption(f"End Date: {TODAY_PLUS_1.isoformat()} (fixed to today + 1)") | |
| freq = st.selectbox( | |
| "Resample Frequency", | |
| options=["ME", "QE"], | |
| index=0, | |
| help=( | |
| "Aggregation for all series. " | |
| "ME = month end. QE = quarter end. " | |
| "Higher aggregation smooths high-frequency noise." | |
| ) | |
| ) | |
| with st.expander("Composite & Phase Parameters", expanded=False): | |
| smooth_window = st.number_input( | |
| "Composite Smoothing Window (months)", | |
| value=2, min_value=1, max_value=12, step=1, | |
| help=( | |
| "Moving average on the composite. " | |
| "Increase to smooth more (slower). " | |
| "Decrease to react faster." | |
| ) | |
| ) | |
| slope_window = st.number_input( | |
| "Slope Lookback (months)", | |
| value=3, min_value=1, max_value=12, step=1, | |
| help=( | |
| "Difference span used for slope. " | |
| "Higher = coarser trend. " | |
| "Lower = more sensitive." | |
| ) | |
| ) | |
| comp_thr = st.number_input( | |
| "Composite Threshold", | |
| value=0.15, step=0.05, format="%.2f", | |
| help=( | |
| "Band around zero for phase changes. " | |
| "Increase = fewer flips. " | |
| "Decrease = earlier shifts." | |
| ) | |
| ) | |
| slope_thr = st.number_input( | |
| "Slope Threshold", | |
| value=0.005, step=0.001, format="%.3f", | |
| help=( | |
| "Minimum slope for Early/Decline. " | |
| "Increase to demand stronger momentum. " | |
| "Decrease to catch weaker turns." | |
| ) | |
| ) | |
| min_run = st.number_input( | |
| "Minimum Phase Run (months)", | |
| value=6, min_value=1, max_value=24, step=1, | |
| help=( | |
| "Median/mode filter on phases. " | |
| "Higher enforces longer runs. " | |
| "Lower allows quicker switches." | |
| ) | |
| ) | |
| run = st.button("Run Analysis", help="Compute the composite and render both sections.") | |
| # --------------------------- Run pipeline -------------------------- | |
| if run: | |
| prog = st.progress(0, text="Loading data...") | |
| try: | |
| # Load & resample | |
| with st.spinner("Loading macro series..."): | |
| df_raw_daily = load_macro_series(start_date, TODAY_PLUS_1) | |
| prog.progress(20, text="Resampling...") | |
| df = df_raw_daily.resample(freq).last().ffill() | |
| # ====== TRANSFORMS & COMPOSITE ====== | |
| yoy = lambda s: s.pct_change(12) * 100 | |
| delta12 = lambda s: s.diff(12) | |
| invert = lambda s: -s | |
| tr = df.copy() | |
| tr['LEI'] = yoy(tr['LEI']) | |
| tr['Capacity Util'] = yoy(tr['Capacity Util']) | |
| tr['BBK Leading'] = yoy(tr['BBK Leading']) | |
| for c in ['Core CPI','Core PCE','Hourly Wage','PPI','Commodities']: | |
| tr[c] = yoy(tr[c]) | |
| tr['10Y'] = invert(delta12(tr['10Y'])) | |
| tr['HY OAS'] = invert(delta12(tr['HY OAS'])) | |
| tr['StLouis FSI'] = invert(tr['StLouis FSI']) | |
| infl_cols = ['Core CPI','Core PCE','Hourly Wage','PPI','Commodities'] | |
| tr['Inflation'] = tr[infl_cols].mean(axis=1) | |
| inputs = [ | |
| 'LEI','Philly Manuf Diff','Texas Serv Diff', | |
| 'Capacity Util','BBK Leading','CFNAI 3MMA', | |
| 'Inflation','10Y','HY OAS','StLouis FSI' | |
| ] | |
| z = tr[inputs].apply(lambda s: (s - s.mean())/s.std()) | |
| comp = z.mean(axis=1) | |
| comp_sm = comp.rolling(int(smooth_window), min_periods=1).mean() | |
| slope = comp.diff(int(slope_window)) | |
| # Phase classification | |
| cond_early = (comp < -comp_thr) & (slope > slope_thr) | |
| cond_midlate = comp > comp_thr | |
| cond_decline = (comp < -comp_thr) & (slope < -slope_thr) | |
| cond_unc = comp.abs() <= comp_thr | |
| phase = pd.Series('Mid-Late', index=comp.index) | |
| phase[cond_early] = 'Early' | |
| phase[cond_decline] = 'Decline' | |
| phase[cond_unc] = 'Uncertain' | |
| code_map = {'Early':0, 'Mid-Late':1, 'Decline':2, 'Uncertain':3} | |
| inv_map = {v:k for k,v in code_map.items()} | |
| codes = phase.map(code_map) | |
| smoothed_codes = codes.rolling( | |
| window=int(min_run), center=True, min_periods=1 | |
| ).apply(lambda x: pd.Series(x).value_counts().idxmax(), raw=False) | |
| phase = smoothed_codes.round().astype(int).map(inv_map) | |
| prog.progress(55, text="Loading equity series...") | |
| spx = load_spx(start_date, TODAY_PLUS_1) | |
| spx_yoy = spx.pct_change(12) * 100 | |
| if freq != 'ME': | |
| spx_yoy = spx_yoy.resample(freq).last().ffill() | |
| # ================== SECTION 1 — COMPOSITE ================== | |
| st.header("Market Cycle Composite Indicator") | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("#### What you’re looking at") | |
| st.write( | |
| "One score that summarizes many macro and market series. " | |
| "We also show the S&P 500 YoY for context. " | |
| "Colored bands mark the phase we infer at each date." | |
| ) | |
| st.markdown("#### How we build the score") | |
| st.write("1) Put all series on the same timeline.") | |
| st.write("We keep the last value each period and fill gaps with the last known value.") | |
| st.latex(r"X^{(F)}_{t} = X_{\tau(t)},\;\; \tau(t)=\text{last timestamp in period }t") | |
| st.latex(r"\tilde{X}_t = \begin{cases} X_t, & \text{if observed}\\ \tilde{X}_{t-1}, & \text{if missing}\end{cases}") | |
| st.write("2) Convert each series to a comparable change.") | |
| st.write("Most series use year-over-year percent change:") | |
| st.latex(r"\mathrm{YoY}(X_t)=100\left(\frac{X_t}{X_{t-12}}-1\right)") | |
| st.write("Rates and spreads use 12-month differences (and we flip the sign when higher is worse):") | |
| st.latex(r"\Delta_{12}(X_t)=X_t-X_{t-12},\qquad \tilde{X}_t=-\Delta_{12}(X_t)") | |
| st.write("Stress metrics are inverted so higher stress lowers the score:") | |
| st.latex(r"\tilde{X}_t=-X_t") | |
| st.write("3) Build an inflation block from five series and average them:") | |
| st.latex(r"\mathrm{Inflation}_t=\frac{1}{5}\sum_{i=1}^{5}X_{i,t}") | |
| st.write("4) Standardize each input so all have the same scale.") | |
| st.latex(r"Z_{i,t}=\frac{X_{i,t}-\mu_i}{\sigma_i}") | |
| st.write("5) Average the standardized inputs to get the composite.") | |
| st.latex(r"C_t=\frac{1}{N}\sum_{i=1}^{N}Z_{i,t}") | |
| st.write("6) Smooth the composite to cut noise.") | |
| st.latex(r"\bar{C}_t=\frac{1}{W}\sum_{k=0}^{W-1}C_{t-k}") | |
| st.write("7) Measure recent direction with a simple slope.") | |
| st.latex(r"S_t=C_t-C_{t-W_s}") | |
| st.write("8) Assign a phase using two thresholds.") | |
| st.latex(r"\text{Early: } C_t<-\theta_c \ \wedge\ S_t>+\theta_s") | |
| st.latex(r"\text{Decline: } C_t<-\theta_c \ \wedge\ S_t<-\theta_s") | |
| st.latex(r"\text{Mid\text{-}Late: } C_t>+\theta_c") | |
| st.latex(r"\text{Uncertain: } |C_t|\le\theta_c") | |
| st.write("9) Stabilize phases with a centered majority vote over a short window.") | |
| st.latex(r"\hat{P}_t=\operatorname{mode}\{P_{t-k},\ldots,P_{t+k}\},\;\; m=2k+1") | |
| st.markdown("#### How the sidebar settings change results") | |
| st.write("- **Resample Frequency**: Month-end or quarter-end. Higher aggregation is smoother.") | |
| st.write("- **Composite Smoothing Window**: Larger = smoother, slower to react.") | |
| st.write("- **Slope Lookback**: Larger = slower slope, fewer flips.") | |
| st.write("- **Composite Threshold**: Larger = fewer phase changes.") | |
| st.write("- **Slope Threshold**: Larger = only strong turns count as Early/Decline.") | |
| st.write("- **Minimum Phase Run**: Larger = longer required runs, fewer whipsaws.") | |
| # Composite plot | |
| try: | |
| fig_comp = make_subplots(specs=[[{"secondary_y": True}]]) | |
| fig_comp.update_layout( | |
| template="plotly_dark", | |
| height=520, | |
| margin=dict(l=60, r=20, t=60, b=40), | |
| title_text="Market-Cycle Composite Indicator", | |
| xaxis_rangeslider_visible=False, | |
| paper_bgcolor=BG, # <— page background of the figure | |
| plot_bgcolor=BG, # <— plotting area background | |
| font=dict(color="white"), | |
| legend=dict( | |
| bgcolor="rgba(14,17,23,0)", # transparent over the same bg tone | |
| font=dict(color="white"), | |
| title_font=dict(color="white") | |
| ) | |
| ) | |
| # Phase shading | |
| mask = phase.copy() | |
| grp = (mask != mask.shift()).cumsum() | |
| for ph, color in PHASE_COLORS.items(): | |
| for _, span in mask[mask == ph].groupby(grp): | |
| x0 = pd.Timestamp(span.index[0]).to_pydatetime() | |
| x1 = pd.Timestamp(span.index[-1]).to_pydatetime() | |
| fig_comp.add_shape( | |
| type="rect", | |
| x0=x0, x1=x1, y0=0, y1=1, | |
| xref="x1", yref="paper", | |
| fillcolor=color, opacity=0.22, | |
| layer="below", line_width=0 | |
| ) | |
| # Composite and zero | |
| fig_comp.add_trace( | |
| go.Scatter( | |
| x=comp_sm.index, y=comp_sm, mode='lines', | |
| line=dict(width=2), name=f'Cycle Composite ({int(smooth_window)}m MA)' | |
| ), | |
| secondary_y=False | |
| ) | |
| fig_comp.add_hline(y=0, line_color='rgba(255,255,255,0.6)', line_width=1) | |
| # SPX YoY on secondary axis | |
| fig_comp.add_trace( | |
| go.Scatter( | |
| x=spx_yoy.index, y=spx_yoy, | |
| mode='lines', line=dict(width=2, dash='dash', color='#00d084'), | |
| name='S&P 500 YoY %' | |
| ), | |
| secondary_y=True | |
| ) | |
| # Legend keys for phases | |
| for ph, color in PHASE_COLORS.items(): | |
| fig_comp.add_trace( | |
| go.Scatter( | |
| x=[None], y=[None], mode='lines', | |
| line=dict(color=color, width=10), | |
| name=ph, showlegend=True | |
| ), | |
| secondary_y=False | |
| ) | |
| # Axes styling | |
| fig_comp.update_xaxes( | |
| tickformat='%Y', | |
| dtick="M12", | |
| title_font=dict(color="white"), | |
| tickfont=dict(color="white"), | |
| tickcolor="white", | |
| gridcolor="rgba(255,255,255,0.10)", | |
| zerolinecolor="rgba(255,255,255,0.15)", | |
| linecolor="rgba(255,255,255,0.15)", | |
| ticks="outside" | |
| ) | |
| fig_comp.update_yaxes( | |
| title_text='Composite Z-Score', | |
| secondary_y=False, | |
| title_font=dict(color="white"), | |
| tickfont=dict(color="white"), | |
| tickcolor="white", | |
| gridcolor="rgba(255,255,255,0.10)", | |
| zerolinecolor="rgba(255,255,255,0.15)", | |
| linecolor="rgba(255,255,255,0.15)", | |
| ticks="outside" | |
| ) | |
| fig_comp.update_yaxes( | |
| title_text='S&P 500 YoY %', | |
| secondary_y=True, | |
| range=[-80, 80], | |
| title_font=dict(color="white"), | |
| tickfont=dict(color="white"), | |
| tickcolor="white" | |
| ) | |
| st.plotly_chart(fig_comp, use_container_width=True) | |
| except Exception: | |
| st.error("Failed to render the composite chart.") | |
| prog.progress(85, text="Computing interpretation...") | |
| # Dynamic Interpretation (richer, more explanatory) | |
| try: | |
| phase_series = pd.Series(phase, index=comp.index) | |
| current_date = phase_series.index[-1] | |
| current_phase = phase_series.iloc[-1] | |
| current_comp = comp_sm.loc[current_date] | |
| current_comp_raw = comp.loc[current_date] | |
| current_slope = slope.loc[current_date] | |
| current_spx_yoy = spx_yoy.loc[current_date] | |
| # Percentiles (rank-based, full sample) | |
| comp_pct = float(comp_sm.dropna().rank(pct=True).loc[current_date] * 100) | |
| spx_pct = float(spx_yoy.dropna().rank(pct=True).loc[current_date] * 100) | |
| # Phase run length (periods and months) | |
| changes = phase_series.ne(phase_series.shift()) | |
| last_change_idx = changes[changes].index[-1] | |
| periods_in_phase = phase_series.index.get_loc(current_date) - phase_series.index.get_loc(last_change_idx) + 1 | |
| if freq == "QE": | |
| months_in_phase = periods_in_phase * 3 | |
| period_label = "quarters" | |
| else: | |
| months_in_phase = periods_in_phase | |
| period_label = "months" | |
| # Breadth and contributors | |
| z_now = z.loc[current_date].dropna() | |
| inputs_now = z_now.reindex(inputs).dropna() | |
| breadth_pos = float((inputs_now > 0).mean() * 100) | |
| top_pos = inputs_now.sort_values(ascending=False).head(3) | |
| top_neg = inputs_now.sort_values(ascending=True).head(3) | |
| def fmt_contrib(s): | |
| return ", ".join([f"{k} ({v:+.1f}σ)" for k, v in s.items()]) if len(s) else "n/a" | |
| # Phase-specific read-through and triggers | |
| phase_notes = [] | |
| triggers = [] | |
| if current_phase == 'Early': | |
| phase_notes += [ | |
| "Growth is turning up from a weak base.", | |
| "Leading activity improves. Credit spreads narrow.", | |
| "Rate momentum eases on a 12-month view." | |
| ] | |
| triggers += [ | |
| f"Upside: composite raw > {comp_thr:.2f}.", | |
| f"Risk: slope < -{slope_thr:.3f} or composite raw > {-comp_thr:.2f}." | |
| ] | |
| elif current_phase == 'Mid-Late': | |
| phase_notes += [ | |
| "Growth remains above average but is slowing.", | |
| "Pricing pressure and policy tightness rise.", | |
| "Quality bias tends to help risk control." | |
| ] | |
| triggers += [ | |
| f"Loss of momentum: composite raw < {comp_thr:.2f}.", | |
| f"Downside break: composite raw < {-comp_thr:.2f}." | |
| ] | |
| elif current_phase == 'Decline': | |
| phase_notes += [ | |
| "Activity contracts. Risk appetite weakens.", | |
| "Credit and liquidity conditions worsen.", | |
| "Drawdown risk is elevated relative to trend." | |
| ] | |
| triggers += [ | |
| f"Repair: slope > +{slope_thr:.3f}.", | |
| f"Exit contraction: composite raw > {-comp_thr:.2f}." | |
| ] | |
| elif current_phase == 'Uncertain': | |
| phase_notes += [ | |
| "Signals conflict. Noise is high.", | |
| "Avoid strong tilts until direction clears.", | |
| "Use position sizing and stops." | |
| ] | |
| triggers += [ | |
| f"Upside regime: composite raw > {comp_thr:.2f}.", | |
| f"Early setup: composite raw < {-comp_thr:.2f} and slope > +{slope_thr:.3f}.", | |
| f"Decline setup: composite raw < {-comp_thr:.2f} and slope < -{slope_thr:.3f}." | |
| ] | |
| else: | |
| phase_notes.append("Phase classification unavailable.") | |
| # Build markdown | |
| interp_md = f""" | |
| **As of {current_date.date()}** | |
| - Phase: **{current_phase}** for {periods_in_phase} {period_label} (~{months_in_phase} months). | |
| - Composite (smoothed): {current_comp:.2f} (p{comp_pct:.0f}). Raw: {current_comp_raw:.2f}. | |
| - Slope over {int(slope_window)}m: {current_slope:+.3f}. | |
| - S&P 500 YoY: {current_spx_yoy:.1f}% (p{spx_pct:.0f}). | |
| - Breadth: {breadth_pos:.0f}% of inputs > 0. | |
| **What drives the score now** | |
| - Positive: {fmt_contrib(top_pos)} | |
| - Negative: {fmt_contrib(top_neg)} | |
| **Phase read-through** | |
| """ + "\n".join([f"- {line}" for line in phase_notes]) + """ | |
| **Triggers to watch** | |
| """ + "\n".join([f"- {t}" for t in triggers]) | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.markdown(interp_md) | |
| except Exception: | |
| st.error("Failed to produce the interpretation.") | |
| # ================== SECTION 2 — RAW SERIES ================== | |
| prog.progress(95, text="Rendering input grid...") | |
| st.header("Macro Input Series (Resampled)") | |
| with st.expander("Methodology", expanded=False): | |
| st.write("Resample each series to the selected frequency (period end).") | |
| st.latex(r"X^{(F)}_{t} = X_{\tau(t)} \quad \text{with } \tau(t) = \text{last timestamp in period } t") | |
| st.write("Forward-fill missing observations to avoid gaps in aligned panels.") | |
| st.latex(r"\tilde{X}_t = \begin{cases} X_t, & \text{if observed} \\ \tilde{X}_{t-1}, & \text{otherwise} \end{cases}") | |
| st.write("No transforms are applied in this section. It is a clean view of inputs after resampling.") | |
| try: | |
| df_view = df.copy() | |
| n_series = len(df_view.columns) | |
| ncols = 3 | |
| nrows = math.ceil(n_series / ncols) | |
| fig_grid = make_subplots( | |
| rows=nrows, cols=ncols, | |
| shared_xaxes=False, | |
| subplot_titles=list(df_view.columns) | |
| ) | |
| xmin, xmax = df_view.index.min(), df_view.index.max() | |
| for i, col in enumerate(df_view.columns, start=1): | |
| r = (i-1)//ncols + 1 | |
| c = (i-1)%ncols + 1 | |
| fig_grid.add_trace( | |
| go.Scatter( | |
| x=df_view.index, y=df_view[col], | |
| mode='lines', | |
| line=dict(width=1.5), | |
| name=col, showlegend=False | |
| ), | |
| row=r, col=c | |
| ) | |
| for i in range(1, nrows*ncols + 1): | |
| xaxis_key = f'xaxis{i}' if i > 1 else 'xaxis' | |
| if xaxis_key in fig_grid.layout: | |
| fig_grid.layout[xaxis_key].update( | |
| range=[xmin, xmax], | |
| tickformat='%Y', | |
| tickfont=dict(color="white"), | |
| tickcolor="white" | |
| ) | |
| fig_grid.update_layout( | |
| template="plotly_dark", | |
| height=max(360, nrows*260), | |
| title_text="All Input Series", | |
| margin=dict(l=40, r=10, t=50, b=40), | |
| paper_bgcolor=BG, # <— match Streamlit dark bg | |
| plot_bgcolor=BG, # <— match Streamlit dark bg | |
| font=dict(color="white") | |
| ) | |
| for i in range(1, nrows*ncols + 1): | |
| yaxis_key = f'yaxis{i}' if i > 1 else 'yaxis' | |
| if yaxis_key in fig_grid.layout: | |
| fig_grid.layout[yaxis_key].update( | |
| tickfont=dict(color="white"), | |
| tickcolor="white" | |
| ) | |
| st.plotly_chart(fig_grid, use_container_width=True) | |
| except Exception: | |
| st.error("Failed to render the input grid.") | |
| prog.progress(100, text="Done.") | |
| except Exception: | |
| st.error("Processing failed. Adjust parameters and try again.") | |
| else: | |
| st.info("Set parameters in the sidebar and click **Run Analysis**.") | |
| # Hide default Streamlit style | |
| st.markdown( | |
| """ | |
| <style> | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| </style> | |
| """, | |
| unsafe_allow_html=True | |
| ) |