# 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 ------------------------------- @st.cache_data(show_spinner=False) 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 @st.cache_data(show_spinner=False) 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( """ """, unsafe_allow_html=True )