Space77 / app.py
QuantumLearner's picture
Update app.py
e140149 verified
# 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(
"""
<style>
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
</style>
""",
unsafe_allow_html=True
)