QuantumLearner commited on
Commit
6f144b8
·
verified ·
1 Parent(s): d42d4da

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1350 -34
app.py CHANGED
@@ -1,38 +1,1354 @@
1
- import altair as alt
 
 
 
 
 
 
 
 
 
2
  import numpy as np
3
  import pandas as pd
 
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
9
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
10
- forums](https://discuss.streamlit.io).
11
- In the meantime, below is an example of what you can do with just a few lines of code:
12
- """
13
-
14
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
15
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
16
-
17
- indices = np.linspace(0, 1, num_points)
18
- theta = 2 * np.pi * num_turns * indices
19
- radius = indices
20
-
21
- x = radius * np.cos(theta)
22
- y = radius * np.sin(theta)
23
-
24
- df = pd.DataFrame({
25
- "x": x,
26
- "y": y,
27
- "idx": indices,
28
- "rand": np.random.randn(num_points),
29
- })
30
-
31
- st.altair_chart(alt.Chart(df, height=700, width=700)
32
- .mark_point(filled=True)
33
- .encode(
34
- x=alt.X("x", axis=None),
35
- y=alt.Y("y", axis=None),
36
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
37
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
38
- ))
 
1
+ # app.py Market Breadth & Momentum
2
+ import os
3
+ import io
4
+ import time
5
+ import math
6
+ import random
7
+ import threading
8
+ import concurrent.futures as cf
9
+ from datetime import datetime, timedelta
10
+
11
  import numpy as np
12
  import pandas as pd
13
+ import requests
14
  import streamlit as st
15
+ from plotly.subplots import make_subplots
16
+ import plotly.graph_objects as go
17
+ import os
18
+
19
+
20
+ # ----------------------------- Helpers & Caching -----------------------------
21
+ API_KEY = os.getenv("FMP_API_KEY")
22
+
23
+
24
+ MAX_WORKERS = 32
25
+ RATE_BACKOFF_MAX = 300
26
+ JITTER_SEC = 0.2
27
+
28
+ # ----------------------------- Page config -----------------------------
29
+ st.set_page_config(page_title="Market Breadth & Momentum", layout="wide")
30
+ st.title("Market Breadth & Momentum")
31
+
32
+ st.markdown(
33
+ "Tracks index trend, participation, and momentum across constituents. "
34
+ "Shows breadth strength (% above key averages), advance–decline behavior, new highs/lows, "
35
+ "the McClellan Oscillator, and cross-section momentum heatmaps."
36
+ )
37
+
38
+ # ----------------------------- Sidebar -----------------------------
39
+ with st.sidebar:
40
+ st.header("Parameters")
41
+
42
+ with st.expander("Data Window", expanded=False):
43
+ default_start = datetime(2015, 1, 1).date()
44
+ default_end = (datetime.today().date() + timedelta(days=1))
45
+ start_date = st.date_input(
46
+ "Start date",
47
+ value=default_start,
48
+ min_value=datetime(2000, 1, 1).date(),
49
+ max_value=default_end,
50
+ help="Earlier start = more history but slower load. Later start = faster."
51
+ )
52
+ end_date = st.date_input(
53
+ "End date",
54
+ value=default_end,
55
+ min_value=default_start,
56
+ max_value=default_end,
57
+ help="End date is set to today + 1 by default to include the latest close."
58
+ )
59
+
60
+ with st.expander("Breadth Settings", expanded=False):
61
+ sma_fast = st.number_input(
62
+ "Fast MA (days)",
63
+ value=50, min_value=20, max_value=200, step=5,
64
+ help="Used for % above fast MA and index fast MA. Higher = slower, fewer flips."
65
+ )
66
+ sma_slow = st.number_input(
67
+ "Slow MA (days)",
68
+ value=200, min_value=100, max_value=400, step=10,
69
+ help="Used for % above slow MA and index slow MA. Higher = slower, longer trend focus."
70
+ )
71
+ vwap_weeks = st.number_input(
72
+ "VWAP lookback (weeks)",
73
+ value=200, min_value=52, max_value=520, step=4,
74
+ help="Anchored weekly VWAP for the index. Higher = more inertia."
75
+ )
76
+ ad_smooth = st.number_input(
77
+ "Adv/Decl smoothing (days)",
78
+ value=30, min_value=5, max_value=90, step=5,
79
+ help="Smooths advancing/declining counts. Higher = steadier lines."
80
+ )
81
+ mo_span_fast = st.number_input(
82
+ "McClellan fast EMA (days)",
83
+ value=19, min_value=5, max_value=30, step=1,
84
+ help="Fast EMA for McClellan Oscillator. Smaller = more sensitive."
85
+ )
86
+ mo_span_slow = st.number_input(
87
+ "McClellan slow EMA (days)",
88
+ value=39, min_value=10, max_value=60, step=1,
89
+ help="Slow EMA for McClellan Oscillator. Larger = smoother baseline."
90
+ )
91
+ mo_signal_span = st.number_input(
92
+ "McClellan signal EMA (days)",
93
+ value=9, min_value=3, max_value=20, step=1,
94
+ help="Signal line for the oscillator. Crosses indicate momentum turns."
95
+ )
96
+
97
+ with st.expander("Rebased Comparison", expanded=False):
98
+ rebase_days = st.number_input(
99
+ "Window (trading days)",
100
+ value=365, min_value=60, max_value=1000, step=5,
101
+ help="Back window for rebased comparison. Longer = more context, smaller features."
102
+ )
103
+ rebase_base = st.number_input(
104
+ "Base level",
105
+ value=100, min_value=1, max_value=1000, step=1,
106
+ help="Starting level for rebased lines."
107
+ )
108
+ y_pad = st.slider(
109
+ "Y-range padding",
110
+ min_value=1, max_value=8, value=3, step=1,
111
+ help="Higher padding widens the log-scale y-range."
112
+ )
113
+
114
+ with st.expander("Heatmaps", expanded=False):
115
+ heat_last_days = st.number_input(
116
+ "Daily return heatmap window (days)",
117
+ value=60, min_value=20, max_value=252, step=5,
118
+ help="Number of recent sessions for the daily return heatmap."
119
+ )
120
+ mom_look = st.number_input(
121
+ "Momentum lookback (days)",
122
+ value=30, min_value=10, max_value=252, step=5,
123
+ help="Return horizon for the percentile momentum heatmap."
124
+ )
125
+
126
+ run_btn = st.button("Run Analysis", type="primary")
127
+
128
+
129
+ def _to_vendor(sym: str) -> str:
130
+ return sym.replace("-", ".")
131
+
132
+ _thread_local = threading.local()
133
+ def _session():
134
+ s = getattr(_thread_local, "session", None)
135
+ if s is None:
136
+ s = requests.Session()
137
+ adapter = requests.adapters.HTTPAdapter(pool_connections=MAX_WORKERS, pool_maxsize=MAX_WORKERS)
138
+ s.mount("https://", adapter)
139
+ _thread_local.session = s
140
+ return s
141
+
142
+ def _get_json(url: str, params: dict, timeout=60, backoff=5):
143
+ sess = _session()
144
+ while True:
145
+ r = sess.get(url, params=params, timeout=timeout)
146
+ if r.status_code == 429:
147
+ time.sleep(backoff)
148
+ backoff = min(backoff * 2, RATE_BACKOFF_MAX)
149
+ continue
150
+ r.raise_for_status()
151
+ return r.json()
152
+
153
+ def _parse_hist_payload(payload):
154
+ item = payload if isinstance(payload, dict) else (payload[0] if payload else {})
155
+ sym = item.get("symbol")
156
+ hist = item.get("historical") or []
157
+ if not sym or not hist:
158
+ return None, None
159
+ dfh = pd.DataFrame(hist)
160
+ if "date" not in dfh or "adjClose" not in dfh:
161
+ return None, None
162
+ s = (
163
+ dfh[["date", "adjClose"]]
164
+ .dropna()
165
+ .assign(date=lambda x: pd.to_datetime(x["date"]))
166
+ .set_index("date")["adjClose"]
167
+ .rename(sym)
168
+ )
169
+ return sym, s
170
+
171
+ @st.cache_data(show_spinner=False)
172
+ def fetch_sp500_table():
173
+ url = "https://financialmodelingprep.com/api/v3/sp500_constituent"
174
+ params = {"apikey": API_KEY}
175
+ payload = _get_json(url, params)
176
+ tab = pd.DataFrame(payload)
177
+ tab = tab.rename(columns={"symbol": "Symbol", "name": "Security"})
178
+ return tab[["Symbol", "Security"]].dropna()
179
+
180
+ def _fetch_one(orig_ticker: str, start: str, end: str):
181
+ time.sleep(random.random() * JITTER_SEC)
182
+ t_vendor = _to_vendor(orig_ticker)
183
+ url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{t_vendor}"
184
+ params = {"from": start, "to": end, "apikey": API_KEY}
185
+ try:
186
+ payload = _get_json(url, params)
187
+ sym, s = _parse_hist_payload(payload)
188
+ if s is None or s.empty:
189
+ return orig_ticker, None, "no data"
190
+ return orig_ticker, s.rename(t_vendor), None
191
+ except Exception as e:
192
+ return orig_ticker, None, str(e)
193
+
194
+ @st.cache_data(show_spinner=False)
195
+ def build_close_parallel(tickers: list[str], start: str, end: str, max_workers: int = MAX_WORKERS):
196
+ n = len(tickers)
197
+ series_dict = {}
198
+ missing = {}
199
+ lock = threading.Lock()
200
+
201
+ def _task(t):
202
+ orig, s, err = _fetch_one(t, start, end)
203
+ with lock:
204
+ if err:
205
+ missing[orig] = err
206
+ else:
207
+ series_dict[s.name] = s
208
+
209
+ with cf.ThreadPoolExecutor(max_workers=max_workers) as ex:
210
+ futures = [ex.submit(_task, t) for t in tickers]
211
+ for _ in cf.as_completed(futures):
212
+ pass
213
+
214
+ if not series_dict:
215
+ return pd.DataFrame(), missing
216
+
217
+ df = pd.DataFrame(series_dict).sort_index()
218
+ df.index.name = "date"
219
+ f_to_o = {_to_vendor(t): t for t in tickers}
220
+ close = df.rename(columns=f_to_o)
221
+ close = close[[t for t in tickers if t in close.columns]]
222
+ return close, missing
223
+
224
+ @st.cache_data(show_spinner=False)
225
+ def fetch_index_ohlcv(start: str, end: str):
226
+ # ^GSPC
227
+ url = "https://financialmodelingprep.com/api/v3/historical-price-full/index/%5EGSPC"
228
+ params = {"from": start, "to": end, "apikey": API_KEY}
229
+ backoff = 5
230
+ while True:
231
+ r = requests.get(url, params=params, timeout=60)
232
+ if r.status_code == 429:
233
+ time.sleep(backoff)
234
+ backoff = min(backoff * 2, 300)
235
+ continue
236
+ r.raise_for_status()
237
+ payload = r.json()
238
+ if isinstance(payload, dict) and "historical" in payload:
239
+ hist = payload["historical"]
240
+ elif isinstance(payload, list) and payload and "historical" in payload[0]:
241
+ hist = payload[0]["historical"]
242
+ else:
243
+ hist = payload
244
+ idx_df = (
245
+ pd.DataFrame(hist)[["date", "close", "volume"]]
246
+ .assign(date=lambda x: pd.to_datetime(x["date"]))
247
+ .set_index("date")
248
+ .sort_index()
249
+ .rename(columns={"close": "Close", "volume": "Volume"})
250
+ )
251
+ return idx_df
252
+
253
+ def _safe_last(s):
254
+ s = s.dropna()
255
+ return s.iloc[-1] if len(s) else np.nan
256
+
257
+ # ----------------------------- Run -----------------------------
258
+ if run_btn:
259
+ with st.spinner("Loading tickers…"):
260
+ try:
261
+ spx_table = fetch_sp500_table()
262
+ except Exception:
263
+ st.error("Ticker table request failed. Try again later.")
264
+ st.stop()
265
+
266
+ tickers = spx_table["Symbol"].tolist()
267
+ st.caption(f"Constituents loaded: {len(tickers)}")
268
+
269
+ start_str = pd.to_datetime(start_date).strftime("%Y-%m-%d")
270
+ end_str = pd.to_datetime(end_date).strftime("%Y-%m-%d")
271
+
272
+ with st.spinner("Fetching historical prices (parallel)…"):
273
+ close, missing = build_close_parallel(tickers, start_str, end_str)
274
+ if close.empty:
275
+ st.error("No price data returned. Reduce the date range and retry.")
276
+ st.stop()
277
+
278
+ if missing:
279
+ st.warning(f"No data for {min(20, len(missing))} symbols (showing up to 20).")
280
+
281
+ clean_close = close.copy()
282
+
283
+ with st.spinner("Fetching index data…"):
284
+ try:
285
+ idx_df = fetch_index_ohlcv(
286
+ start=clean_close.index[0].strftime("%Y-%m-%d"),
287
+ end=end_str
288
+ )
289
+ except Exception:
290
+ st.error("Index data request failed. Try again later.")
291
+ st.stop()
292
+
293
+ idx = idx_df["Close"].reindex(clean_close.index).ffill()
294
+ idx_volume = idx_df["Volume"].reindex(clean_close.index).ffill()
295
+
296
+ # ===================== SECTION 1 — Breadth Dashboard =====================
297
+ st.header("Breadth Dashboard")
298
+
299
+ with st.expander("Methodology", expanded=False):
300
+ # Overview
301
+ st.write("This panel tracks trend, participation, and momentum for a broad equity universe.")
302
+ st.write("Use it to judge trend quality, spot divergences, and gauge risk bias.")
303
+
304
+ # 1) Price trend (MAs, VWAP)
305
+ st.write("**Price trend**")
306
+ st.write("Simple moving averages (n days):")
307
+ st.latex(r"\mathrm{SMA}_{n}(t)=\frac{1}{n}\sum_{k=0}^{n-1}P_{t-k}")
308
+ st.write("Approximate 200-week VWAP (using ~5 trading days per week):")
309
+ st.latex(r"\mathrm{VWAP}_{200w}(t)=\frac{\sum_{k=0}^{N-1}P_{t-k}V_{t-k}}{\sum_{k=0}^{N-1}V_{t-k}},\quad N\approx200\times5")
310
+ st.write("Price above both MAs and fast>slow = strong trend.")
311
+ st.write("Price below both MAs and fast<slow = weak trend.")
312
+
313
+ # 2) Participation breadth (% above MAs)
314
+ st.write("**Participation breadth**")
315
+ st.write("Share above n-day MA:")
316
+ st.latex(r"\%\,\text{Above}_n(t)=100\cdot\frac{\#\{i:\ P_{i,t}>\mathrm{SMA}_{n,i}(t)\}}{N}")
317
+ st.write("Zones: 0–20 weak, 20–50 neutral, 50–80 strong.")
318
+ st.write("Higher shares mean broad support for the trend.")
319
+
320
+ # 3) Advance–Decline line
321
+ st.write("**Advance–Decline (A/D) line**")
322
+ st.latex(r"A_t=\#\{i:\ P_{i,t}>P_{i,t-1}\},\quad D_t=\#\{i:\ P_{i,t}<P_{i,t-1}\}")
323
+ st.latex(r"\mathrm{ADLine}_t=\sum_{u\le t}(A_u-D_u)")
324
+ st.write("Rising A/D confirms uptrends. Falling A/D warns of narrow leadership.")
325
+
326
+ # 4) Net new 52-week highs
327
+ st.write("**Net new 52-week highs**")
328
+ st.latex(r"H_{i,t}^{52}=\max_{u\in[t-251,t]}P_{i,u},\quad L_{i,t}^{52}=\min_{u\in[t-251,t]}P_{i,u}")
329
+ st.latex(r"\text{NewHighs}_t=\sum_i \mathbf{1}\{P_{i,t}=H_{i,t}^{52}\},\quad \text{NewLows}_t=\sum_i \mathbf{1}\{P_{i,t}=L_{i,t}^{52}\}")
330
+ st.latex(r"\text{NetHighs}_t=\text{NewHighs}_t-\text{NewLows}_t")
331
+ st.write("Positive and persistent net highs support trend durability.")
332
+
333
+ # 5) Smoothed advancing vs declining counts
334
+ st.write("**Advancing vs declining (smoothed)**")
335
+ st.latex(r"\overline{A}_t=\frac{1}{w}\sum_{k=0}^{w-1}A_{t-k},\quad \overline{D}_t=\frac{1}{w}\sum_{k=0}^{w-1}D_{t-k}")
336
+ st.write("Advancers > decliners over the window = constructive breadth.")
337
+
338
+ # 6) McClellan Oscillator
339
+ st.write("**McClellan Oscillator (MO)**")
340
+ st.latex(r"E^{(n)}_t=\text{EMA}_n(A_t-D_t)")
341
+ st.latex(r"\mathrm{MO}_t=E^{(19)}_t-E^{(39)}_t")
342
+ st.write("Zero-line up-cross = improving momentum. Down-cross = fading momentum.")
343
+ st.write("A 9-day EMA of MO can act as a signal line.")
344
+
345
+ # Practical reads
346
+ st.write("**Practical use**")
347
+ st.write("- Broad strength: % above 200-day ≥ 50% supports trends.")
348
+ st.write("- Divergences: index near highs without A/D or MO confirmation = caution.")
349
+ st.write("- Breadth thrust: sharp rise in % above 50-day to ≥ 55% with a +20pt jump can mark regime turns.")
350
+ st.write("- MO near recent extremes flags stretched short-term conditions.")
351
+
352
+
353
+ # --- Compute indicators (respecting sidebar params) ---
354
+ sma_fast_idx = idx.rolling(int(sma_fast), min_periods=int(sma_fast)).mean()
355
+ sma_slow_idx = idx.rolling(int(sma_slow), min_periods=int(sma_slow)).mean()
356
+ vwap_days = int(vwap_weeks) * 5
357
+ vwap_idx = (idx * idx_volume).rolling(vwap_days, min_periods=vwap_days).sum() / \
358
+ idx_volume.rolling(vwap_days, min_periods=vwap_days).sum()
359
+
360
+ sma_fast_all = clean_close.rolling(int(sma_fast), min_periods=int(sma_fast)).mean()
361
+ sma_slow_all = clean_close.rolling(int(sma_slow), min_periods=int(sma_slow)).mean()
362
+ pct_above_fast = (clean_close > sma_fast_all).sum(axis=1) / clean_close.shape[1] * 100
363
+ pct_above_slow = (clean_close > sma_slow_all).sum(axis=1) / clean_close.shape[1] * 100
364
+
365
+ advances = (clean_close.diff() > 0).sum(axis=1)
366
+ declines = (clean_close.diff() < 0).sum(axis=1)
367
+ ad_line = (advances - declines).cumsum()
368
+
369
+ window = int(ad_smooth)
370
+ avg_adv = advances.rolling(window, min_periods=window).mean()
371
+ avg_decl = declines.rolling(window, min_periods=window).mean()
372
+
373
+ high52 = clean_close.rolling(252, min_periods=252).max()
374
+ low52 = clean_close.rolling(252, min_periods=252).min()
375
+ new_highs = (clean_close == high52).sum(axis=1)
376
+ new_lows = (clean_close == low52).sum(axis=1)
377
+ net_highs = new_highs - new_lows
378
+ sma10_net_hi = net_highs.rolling(10, min_periods=10).mean()
379
+
380
+ net_adv = (advances - declines).astype("float64")
381
+ ema_fast = net_adv.ewm(span=int(mo_span_fast), adjust=False).mean()
382
+ ema_slow = net_adv.ewm(span=int(mo_span_slow), adjust=False).mean()
383
+ mc_osc = (ema_fast - ema_slow).rename("MO")
384
+ mo_pos = mc_osc.clip(lower=0)
385
+ mo_neg = mc_osc.clip(upper=0)
386
+
387
+ bound = float(np.nanpercentile(np.abs(mc_osc.dropna()), 99)) if mc_osc.notna().sum() else 20.0
388
+ bound = max(20.0, math.ceil(bound / 10.0) * 10.0)
389
+
390
+ # --- Plot (6 rows) ---
391
+ # --- Plot (6 rows) — dynamic date ticks on zoom, dark theme, white labels ---
392
+ fig = make_subplots(
393
+ rows=6, cols=1, shared_xaxes=True, vertical_spacing=0.03,
394
+ subplot_titles=(
395
+ "S&P 500 Price / Fast MA / Slow MA / Weekly VWAP",
396
+ f"% Above {int(sma_fast)}d & {int(sma_slow)}d",
397
+ "Advance–Decline Line",
398
+ "Net New 52-Week Highs (bar) + 10d SMA",
399
+ f"Advancing vs Declining ({int(window)}d MA)",
400
+ f"McClellan Oscillator ({int(mo_span_fast)},{int(mo_span_slow)})"
401
+ )
402
+ )
403
+
404
+ # Enforce dark template + white annotation titles
405
+ fig.update_layout(template="plotly_dark", font=dict(color="white"))
406
+ if hasattr(fig.layout, "annotations"):
407
+ for a in fig.layout.annotations:
408
+ a.font = dict(color="white", size=12)
409
+
410
+ # Row 1: Price + MAs + VWAP
411
+ fig.add_trace(go.Scatter(x=idx.index, y=idx, name="S&P 500"), row=1, col=1)
412
+ fig.add_trace(go.Scatter(x=sma_fast_idx.index, y=sma_fast_idx, name=f"{int(sma_fast)}-day MA"), row=1, col=1)
413
+ fig.add_trace(go.Scatter(x=sma_slow_idx.index, y=sma_slow_idx, name=f"{int(sma_slow)}-day MA"), row=1, col=1)
414
+ fig.add_trace(go.Scatter(x=vwap_idx.index, y=vwap_idx, name=f"{int(vwap_weeks)}-week VWAP"), row=1, col=1)
415
+
416
+ # Row 2: % Above MAs + zones
417
+ fig.add_hrect(y0=0, y1=20, line_width=0, fillcolor="red", opacity=0.3, row=2, col=1)
418
+ fig.add_hrect(y0=20, y1=50, line_width=0, fillcolor="yellow", opacity=0.3, row=2, col=1)
419
+ fig.add_hrect(y0=50, y1=80, line_width=0, fillcolor="green", opacity=0.3, row=2, col=1)
420
+ fig.add_trace(go.Scatter(x=pct_above_fast.index, y=pct_above_fast, name=f"% Above {int(sma_fast)}d"), row=2, col=1)
421
+ fig.add_trace(go.Scatter(x=pct_above_slow.index, y=pct_above_slow, name=f"% Above {int(sma_slow)}d"), row=2, col=1)
422
+ fig.add_annotation(x=0, xref="paper", y=10, yref="y2", text="Weak", showarrow=False, align="left", font=dict(color="white"))
423
+ fig.add_annotation(x=0, xref="paper", y=35, yref="y2", text="Neutral", showarrow=False, align="left", font=dict(color="white"))
424
+ fig.add_annotation(x=0, xref="paper", y=65, yref="y2", text="Strong", showarrow=False, align="left", font=dict(color="white"))
425
+
426
+ # Row 3: A/D Line
427
+ fig.add_trace(go.Scatter(x=ad_line.index, y=ad_line, name="A/D Line"), row=3, col=1)
428
+
429
+ # Row 4: Net new highs + SMA
430
+ fig.add_trace(go.Bar(x=net_highs.index, y=net_highs, name="Net New Highs", opacity=0.5), row=4, col=1)
431
+ fig.add_trace(go.Scatter(x=sma10_net_hi.index, y=sma10_net_hi, name="10-day SMA"), row=4, col=1)
432
+
433
+ # Row 5: Adv vs Decl (smoothed)
434
+ fig.add_trace(go.Scatter(x=avg_adv.index, y=avg_adv, name=f"Adv ({int(window)}d MA)"), row=5, col=1)
435
+ fig.add_trace(go.Scatter(x=avg_decl.index, y=avg_decl, name=f"Dec ({int(window)}d MA)"), row=5, col=1)
436
+
437
+ # Row 6: McClellan Oscillator histogram
438
+ fig.add_trace(
439
+ go.Bar(
440
+ x=mo_pos.index, y=mo_pos, name="MO +",
441
+ marker=dict(color="#2ecc71", line=dict(width=0)),
442
+ hovertemplate="MO: %{y:.1f}<br>%{x|%Y-%m-%d}<extra></extra>",
443
+ showlegend=False
444
+ ),
445
+ row=6, col=1
446
+ )
447
+ fig.add_trace(
448
+ go.Bar(
449
+ x=mo_neg.index, y=mo_neg, name="MO -",
450
+ marker=dict(color="#e74c3c", line=dict(width=0)),
451
+ hovertemplate="MO: %{y:.1f}<br>%{x|%Y-%m-%d}<extra></extra>",
452
+ showlegend=False
453
+ ),
454
+ row=6, col=1
455
+ )
456
+ fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="rgba(180,180,180,0.8)", row=6, col=1)
457
+
458
+ # Axes styling (white ticks/titles, subtle grid) for ALL subplots
459
+ fig.update_xaxes(
460
+ ticklabelmode="period", # labels at period boundaries
461
+ tickformatstops=[
462
+ # < 1 day
463
+ dict(dtickrange=[None, 24*3600*1000], value="%b %d\n%Y"),
464
+ # 1 day .. 1 week
465
+ dict(dtickrange=[24*3600*1000, 7*24*3600*1000], value="%b %d"),
466
+ # 1 week .. 1 month
467
+ dict(dtickrange=[7*24*3600*1000, "M1"], value="%b %d\n%Y"),
468
+ # 1 .. 6 months
469
+ dict(dtickrange=["M1", "M6"], value="%b %Y"),
470
+ # 6+ months
471
+ dict(dtickrange=["M6", None], value="%Y"),
472
+ ],
473
+ tickangle=0,
474
+ tickfont=dict(color="white"),
475
+ title_font=dict(color="white"),
476
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
477
+ showline=True, linecolor="rgba(255,255,255,0.4)",
478
+ rangeslider_visible=False
479
+ )
480
+ fig.update_yaxes(
481
+ tickfont=dict(color="white"),
482
+ title_font=dict(color="white"),
483
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
484
+ showline=True, linecolor="rgba(255,255,255,0.4)"
485
+ )
486
+
487
+ # Per-row y-axis titles / ranges
488
+ fig.update_yaxes(title_text="Price", row=1, col=1)
489
+ fig.update_yaxes(title_text="Percent", row=2, col=1, range=[0, 100])
490
+ fig.update_yaxes(title_text="A/D", row=3, col=1)
491
+ fig.update_yaxes(title_text="Net", row=4, col=1)
492
+ fig.update_yaxes(title_text="Count", row=5, col=1)
493
+ fig.update_yaxes(title_text="MO", row=6, col=1, range=[-bound, bound], side="right")
494
+
495
+ # Bottom x-axis title
496
+ fig.update_xaxes(title_text="Date", row=6, col=1)
497
+
498
+ # Layout / legend
499
+ fig.update_layout(
500
+ height=1350,
501
+ bargap=0.02,
502
+ barmode="relative",
503
+ legend=dict(
504
+ orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
505
+ font=dict(color="white")
506
+ ),
507
+ margin=dict(l=60, r=20, t=40, b=40),
508
+ hovermode="x unified",
509
+ font=dict(color="white"),
510
+ title=dict(font=dict(color="white"))
511
+ )
512
+
513
+ st.plotly_chart(fig, use_container_width=True)
514
+
515
+ # --- Dynamic interpretation (captured exactly as prints) ---
516
+ with st.expander("Dynamic Interpretation", expanded=False):
517
+ buf = io.StringIO()
518
+
519
+ def _last_val(s):
520
+ s = s.dropna()
521
+ return s.iloc[-1] if len(s) else np.nan
522
+
523
+ def _last_date(s):
524
+ s = s.dropna()
525
+ return s.index[-1] if len(s) else None
526
+
527
+ def _pct(a, b):
528
+ if not np.isfinite(a) or not np.isfinite(b) or b == 0:
529
+ return np.nan
530
+ return (a - b) / b * 100.0
531
+
532
+ def _fmt_pct(x):
533
+ return "n/a" if not np.isfinite(x) else f"{x:.1f}%"
534
+
535
+ def _fmt_num(x):
536
+ return "n/a" if not np.isfinite(x) else f"{x:,.2f}"
537
+
538
+ # Values
539
+ as_of = _last_date(idx)
540
+
541
+ px = _last_val(idx)
542
+ ma50 = _last_val(sma_fast_idx)
543
+ ma200 = _last_val(sma_slow_idx)
544
+ vwap200 = _last_val(vwap_idx)
545
+
546
+ p50 = float(_last_val(pct_above_fast))
547
+ p200 = float(_last_val(pct_above_slow))
548
+
549
+ ad_now = _last_val(ad_line)
550
+ nh_now = int(_last_val(new_highs)) if np.isfinite(_last_val(new_highs)) else 0
551
+ nh_sma = float(_last_val(sma10_net_hi))
552
+
553
+ avg_adv_last = float(_last_val(avg_adv))
554
+ avg_decl_last = float(_last_val(avg_decl))
555
+
556
+ _ema19 = net_adv.ewm(span=int(mo_span_fast), adjust=False).mean()
557
+ _ema39 = net_adv.ewm(span=int(mo_span_slow), adjust=False).mean()
558
+ mc_osc2 = (_ema19 - _ema39).rename("MO")
559
+ mc_signal = mc_osc2.ewm(span=int(mo_signal_span), adjust=False).mean().rename("Signal")
560
+
561
+ mo_last = float(_last_val(mc_osc2))
562
+ mo_prev = float(_last_val(mc_osc2.shift(1)))
563
+ mo_5ago = float(_last_val(mc_osc2.shift(5)))
564
+ mo_slope5 = mo_last - mo_5ago
565
+ mo_sig_last = float(_last_val(mc_signal))
566
+ mo_sig_prev = float(_last_val(mc_signal.shift(1)))
567
+
568
+ mo_roll = mc_osc2.rolling(252, min_periods=126)
569
+ mo_mean = mo_roll.mean()
570
+ mo_std = mo_roll.std()
571
+ mo_z = (mc_osc2 - mo_mean) / mo_std
572
+ mo_z_last = float(_last_val(mo_z))
573
+
574
+ mo_abs = np.abs(mc_osc2.dropna())
575
+ if len(mo_abs) >= 20:
576
+ mo_ext = float(np.nanpercentile(mo_abs.tail(252), 90))
577
+ else:
578
+ mo_ext = np.nan
579
+
580
+ look_fast = 10
581
+ look_mid = 20
582
+ look_div = 63
583
+
584
+ ma50_slope = _last_val(sma_fast_idx.diff(look_fast))
585
+ ma200_slope = _last_val(sma_slow_idx.diff(look_mid))
586
+ p50_chg = p50 - float(_last_val(pct_above_fast.shift(look_fast)))
587
+ p200_chg = p200 - float(_last_val(pct_above_slow.shift(look_fast)))
588
+ ad_mom = ad_now - float(_last_val(ad_line.shift(look_mid)))
589
+
590
+ d50 = _pct(px, ma50)
591
+ d200 = _pct(px, ma200)
592
+ dvw = _pct(px, vwap200)
593
+ h63 = float(_last_val(idx.rolling(look_div).max()))
594
+ dd63 = _pct(px, h63) if np.isfinite(h63) else np.nan
595
+
596
+ ad_63h = float(_last_val(ad_line.rolling(look_div).max()))
597
+ mo_63h = float(_last_val(mc_osc2.rolling(look_div).max()))
598
+ near_high_px = np.isfinite(h63) and np.isfinite(px) and px >= 0.995 * h63
599
+ near_high_ad = np.isfinite(ad_63h) and np.isfinite(ad_now) and ad_now >= 0.995 * ad_63h
600
+ near_high_mo = np.isfinite(mo_63h) and np.isfinite(mo_last) and mo_last >= 0.95 * mo_63h
601
+
602
+ breadth_thrust = (p50 >= 55) and (p50_chg >= 20)
603
+
604
+ score = 0
605
+ score += 1 if px > ma50 else 0
606
+ score += 1 if px > ma200 else 0
607
+ score += 1 if ma50 > ma200 else 0
608
+ score += 1 if ma50_slope > 0 else 0
609
+ score += 1 if p50 >= 50 else 0
610
+ score += 1 if p200 >= 50 else 0
611
+ score += 1 if ad_mom > 0 else 0
612
+ score += 1 if nh_now > 0 and nh_sma >= 0 else 0
613
+ score += 1 if avg_adv_last > avg_decl_last else 0
614
+ score += 1 if (mo_last > 0 and mo_slope5 > 0) else 0
615
+
616
+ if score >= 8:
617
+ regime = "Risk-on bias"
618
+ elif score >= 5:
619
+ regime = "Mixed bias"
620
+ else:
621
+ regime = "Risk-off bias"
622
+
623
+ print(f"=== Market breadth narrative — {as_of.date() if as_of is not None else 'N/A'} ===", file=buf)
624
+
625
+ # [Trend]
626
+ print("\n[Trend]", file=buf)
627
+ if np.isfinite(px) and np.isfinite(ma50) and np.isfinite(ma200):
628
+ print(
629
+ "The index is {px}, the 50-day is {ma50}, and the 200-day is {ma200}. "
630
+ "Price runs {d50} vs the 50-day and {d200} vs the 200-day. "
631
+ "The 50-day changed by {m50s} over {f} sessions and the 200-day changed by {m200s} over {m} sessions."
632
+ .format(
633
+ px=_fmt_num(px), ma50=_fmt_num(ma50), ma200=_fmt_num(ma200),
634
+ d50=_fmt_pct(d50), d200=_fmt_pct(d200),
635
+ m50s=f"{ma50_slope:+.2f}" if np.isfinite(ma50_slope) else "n/a",
636
+ m200s=f"{ma200_slope:+.2f}" if np.isfinite(ma200_slope) else "n/a",
637
+ f=look_fast, m=look_mid
638
+ ), file=buf
639
+ )
640
+ if np.isfinite(vwap200):
641
+ print("The index is {dvw} versus the 200-week VWAP.".format(dvw=_fmt_pct(dvw)), file=buf)
642
+ if np.isfinite(dd63):
643
+ print("Distance from the 3-month high is {dd}.".format(dd=_fmt_pct(dd63)), file=buf)
644
+ if px > ma50 and ma50 > ma200:
645
+ print("Structure is bullish: price above both averages and the fast above the slow.", file=buf)
646
+ elif px < ma50 and ma50 < ma200:
647
+ print("Structure is bearish: price below both averages and the fast below the slow.", file=buf)
648
+ else:
649
+ print("Structure is mixed: levels are not aligned.", file=buf)
650
+ else:
651
+ print("Trend inputs are incomplete.", file=buf)
652
+
653
+ # [Participation]
654
+ print("\n[Participation]", file=buf)
655
+ if np.isfinite(p50) and np.isfinite(p200):
656
+ print(
657
+ "{p50} of members sit above the 50-day and {p200} above the 200-day. "
658
+ "The 50-day share moved {p50chg} over {f} sessions, and the 200-day share moved {p200chg}."
659
+ .format(
660
+ p50=f"{p50:.1f}%", p200=f"{p200:.1f}%",
661
+ p50chg=f"{p50_chg:+.1f} pts", p200chg=f"{p200_chg:+.1f} pts", f=look_fast
662
+ ), file=buf
663
+ )
664
+ if p50 < 20 and p200 < 20:
665
+ print("Participation is very weak across both horizons.", file=buf)
666
+ elif p50 < 50 and p200 < 50:
667
+ print("Participation is weak; leadership is narrow.", file=buf)
668
+ elif p50 >= 50 and p200 < 50:
669
+ print("Short-term breadth improved, long-term base still soft.", file=buf)
670
+ elif p50 >= 50 and p200 >= 50:
671
+ print("Participation is broad and supportive.", file=buf)
672
+ if breadth_thrust:
673
+ print("The 50-day breadth jump qualifies as a breadth thrust.", file=buf)
674
+ else:
675
+ print("Breadth percentages are missing.", file=buf)
676
+
677
+ # [Advance–Decline]
678
+ print("\n[Advance–Decline]", file=buf)
679
+ if np.isfinite(ad_now):
680
+ print(
681
+ "A/D momentum over {m} sessions is {admom:+.0f}. "
682
+ "Price is {pxnear} a 3-month high and A/D is {adnear} the same mark."
683
+ .format(
684
+ m=look_mid, admom=ad_mom,
685
+ pxnear="near" if near_high_px else "not near",
686
+ adnear="near" if near_high_ad else "not near"
687
+ ), file=buf
688
+ )
689
+ if near_high_px and not near_high_ad:
690
+ print("Price tested highs without A/D confirmation.", file=buf)
691
+ elif near_high_px and near_high_ad:
692
+ print("Price and A/D both near recent highs.", file=buf)
693
+ elif (not near_high_px) and near_high_ad:
694
+ print("A/D improved while price lagged.", file=buf)
695
+ else:
696
+ print("No short-term confirmation signal.", file=buf)
697
+ else:
698
+ print("A/D data is unavailable.", file=buf)
699
+
700
+ # [McClellan Oscillator]
701
+ print("\n[McClellan Oscillator]", file=buf)
702
+ if np.isfinite(mo_last):
703
+ zero_cross_up = (mo_prev < 0) and (mo_last >= 0)
704
+ zero_cross_down = (mo_prev > 0) and (mo_last <= 0)
705
+ sig_cross_up = (mo_prev <= mo_sig_prev) and (mo_last > mo_sig_last)
706
+ sig_cross_down = (mo_prev >= mo_sig_prev) and (mo_last < mo_sig_last)
707
+ near_extreme = np.isfinite(mo_ext) and (abs(mo_last) >= 0.9 * mo_ext)
708
+
709
+ print(
710
+ "MO prints {mo:+.1f} with a 9-day signal at {sig:+.1f}. "
711
+ "Five-day slope is {slope:+.1f}. Z-score over 1y is {z}."
712
+ .format(
713
+ mo=mo_last, sig=mo_sig_last, slope=mo_slope5,
714
+ z=f"{mo_z_last:.2f}" if np.isfinite(mo_z_last) else "n/a"
715
+ ), file=buf
716
+ )
717
+
718
+ if zero_cross_up:
719
+ print("Bullish zero-line cross: momentum turned positive.", file=buf)
720
+ if zero_cross_down:
721
+ print("Bearish zero-line cross: momentum turned negative.", file=buf)
722
+ if sig_cross_up:
723
+ print("Bullish signal cross: MO moved above its 9-day signal.", file=buf)
724
+ if sig_cross_down:
725
+ print("Bearish signal cross: MO fell below its 9-day signal.", file=buf)
726
+
727
+ if near_extreme:
728
+ tag = "positive" if mo_last > 0 else "negative"
729
+ print(f"MO is near a recent {tag} extreme by distribution.", file=buf)
730
+ elif np.isfinite(mo_ext):
731
+ print(f"Recent absolute extreme band is about ±{mo_ext:.0f}.", file=buf)
732
+
733
+ if near_high_px and not near_high_mo:
734
+ print("Price near short-term highs without a matching MO high.", file=buf)
735
+ if (not near_high_px) and near_high_mo:
736
+ print("MO near a short-term high while price lags.", file=buf)
737
+ else:
738
+ print("MO series is unavailable.", file=buf)
739
+
740
+ # [New Highs vs Lows]
741
+ print("\n[New Highs vs Lows]", file=buf)
742
+ if np.isfinite(nh_sma):
743
+ if nh_now > 0 and nh_sma >= 0:
744
+ print("Net new highs are positive and the 10-day trend is non-negative.", file=buf)
745
+ elif nh_now < 0 and nh_sma <= 0:
746
+ print("Net new lows dominate and the 10-day trend is negative.", file=buf)
747
+ else:
748
+ print("Daily print and 10-day trend disagree; signal is mixed.", file=buf)
749
+ else:
750
+ print("High/low series is incomplete.", file=buf)
751
+
752
+ # [Advancing vs Declining]
753
+ print("\n[Advancing vs Declining]", file=buf)
754
+ if np.isfinite(avg_adv_last) and np.isfinite(avg_decl_last):
755
+ spread = avg_adv_last - avg_decl_last
756
+ print(
757
+ "On a {w}-day smoothing window, advancers average {adv:.0f} and decliners {dec:.0f}. Net spread is {spr:+.0f}."
758
+ .format(w=window, adv=avg_adv_last, dec=avg_decl_last, spr=spread), file=buf
759
+ )
760
+ if spread > 0:
761
+ print("The spread favors advancers.", file=buf)
762
+ elif spread < 0:
763
+ print("The spread favors decliners.", file=buf)
764
+ else:
765
+ print("Advancers and decliners are balanced.", file=buf)
766
+ else:
767
+ print("Smoothed A/D data is missing.", file=buf)
768
+
769
+ # [Aggregate]
770
+ print("\n[Aggregate]", file=buf)
771
+ print("Composite score is {score}/10 → {regime}.".format(score=score, regime=regime), file=buf)
772
+ if regime == "Risk-on bias":
773
+ if p200 >= 60 and ma200_slope > 0 and mo_last > 0:
774
+ print("Long-term breadth and MO agree; pullbacks above the 50-day tend to be buyable.", file=buf)
775
+ else:
776
+ print("Tone is supportive; watch the 200-day and MO zero-line for confirmation.", file=buf)
777
+ elif regime == "Mixed bias":
778
+ print("Signals diverge; manage size and tighten risk until MO and breadth align.", file=buf)
779
+ else:
780
+ if p200 <= 40 and ma200_slope < 0 and mo_last < 0:
781
+ print("Weak long-term breadth with negative MO argues for caution.", file=buf)
782
+ else:
783
+ print("Bias leans defensive until breadth steadies and MO turns up.", file=buf)
784
+
785
+ # [What to monitor]
786
+ print("\n[What to monitor]", file=buf)
787
+ print("Watch the 200-day breadth around 50% for confirmation of durable trends.", file=buf)
788
+ print("Track MO zero-line and signal crosses during price tests of resistance.", file=buf)
789
+ print("Look for steady positive net new highs over a 10-day window.", file=buf)
790
+
791
+ st.text(buf.getvalue())
792
+
793
+ # ===================== SECTION 2 — Rebased Comparison =====================
794
+ st.header("Rebased Comparison (Last N sessions)")
795
+
796
+ with st.expander("Methodology", expanded=False):
797
+ st.write("Compares stock paths on a common scale and highlights leadership vs laggards.")
798
+ st.write("Use it to judge breadth, concentration, and dispersion over the selected window.")
799
+
800
+ st.write("**Rebasing (start = B)**")
801
+ st.latex(r"R_{i,t}= \frac{P_{i,t}}{P_{i,t_0}}\times B")
802
+ st.write("Each line shows cumulative performance since the window start.")
803
+ st.write("The index is rebased the same way for reference.")
804
+
805
+ st.write("**Log scale**")
806
+ st.write("We plot the y-axis in log scale so equal percent moves look equal.")
807
+ st.write("Y-range uses robust bounds (1st–99th percentiles) with padding.")
808
+
809
+ st.write("**Leaders and laggards**")
810
+ st.latex(r"\text{Perf}_{i}=R_{i,T}")
811
+ st.write("Leaders are highest Perf at T. Laggards are lowest.")
812
+ st.write("MAG7 are highlighted if present.")
813
+
814
+ st.write("**Equal-weight summaries**")
815
+ st.latex(r"\text{EWAvg}_T=\frac{1}{M}\sum_{i=1}^{M}R_{i,T}")
816
+ st.latex(r"\text{Median}_T=\operatorname{median}\{R_{i,T}\}")
817
+ st.latex(r"\%\text{Up}_T=100\cdot \frac{1}{M}\sum_{i=1}^{M}\mathbf{1}[R_{i,T}>B]")
818
+ st.latex(r"\%\text{BeatIdx}_T=100\cdot \frac{1}{M}\sum_{i=1}^{M}\mathbf{1}[R_{i,T}>R_{\text{idx},T}]")
819
+ st.write("These give a breadth read relative to the index and to flat (B).")
820
+
821
+ st.write("**Dispersion (cross-section)**")
822
+ st.latex(r"\sigma_T=\operatorname{stdev}\{R_{i,T}\},\quad \text{IQR}_T=Q_{0.75}-Q_{0.25}")
823
+ st.write("High dispersion means large performance spread across names.")
824
+
825
+ st.write("**Concentration (top N share of gains)**")
826
+ st.latex(r"\text{TopNShare}_T=\frac{\sum_{i\in \text{Top}N}(R_{i,T}-B)}{\sum_{j=1}^{M}(R_{j,T}-B)}\times 100")
827
+ st.write("Large TopNShare implies leadership is concentrated.")
828
+
829
+ st.write("**Correlation to index (optional diagnostic)**")
830
+ st.latex(r"\rho_i=\operatorname{corr}\big(\Delta \ln P_{i,t},\, \Delta \ln P_{\text{idx},t}\big)")
831
+ st.write("Lower median correlation favors stock picking. High correlation means beta drives moves.")
832
+
833
+ st.write("**Practical reads**")
834
+ st.write("- Broad advance: many lines above the index and %BeatIdx high.")
835
+ st.write("- Concentration risk: TopNShare large while most lines trail the index.")
836
+ st.write("- Rotation/dispersion: high cross-section std and lower median correlation.")
837
+ st.write("- Leadership quality: leaders holding gains on a log scale with limited drawdowns.")
838
+
839
+ n_days = int(rebase_days)
840
+ base = float(rebase_base)
841
+
842
+ recent = clean_close.iloc[-n_days:].dropna(axis=1, how="any")
843
+ if recent.empty:
844
+ st.warning("Not enough overlapping history for the rebased comparison window.")
845
+ else:
846
+ first = recent.iloc[0]
847
+ mask = (first > 0) & np.isfinite(first)
848
+ rebased = (recent.loc[:, mask] / first[mask]) * base
849
+
850
+ perf = rebased.iloc[-1].dropna()
851
+ mag7_all = ["AAPL","MSFT","AMZN","META","GOOGL","NVDA","TSLA"]
852
+ mag7 = [t for t in mag7_all if t in rebased.columns]
853
+ non_mag = perf.drop(index=mag7, errors="ignore")
854
+ top5 = non_mag.nlargest(min(5, len(non_mag))).index.tolist()
855
+ worst5 = non_mag.nsmallest(min(5, len(non_mag))).index.tolist()
856
+
857
+ mag_colors = {
858
+ "AAPL":"#00bfff","MSFT":"#3cb44b","AMZN":"#ffe119",
859
+ "META":"#4363d8","GOOGL":"#f58231","NVDA":"#911eb4","TSLA":"#46f0f0"
860
+ }
861
+
862
+ spx = idx.reindex(rebased.index).dropna()
863
+ spx_rebased = spx / spx.iloc[0] * base
864
+
865
+ def hover_tmpl(name: str) -> str:
866
+ return "%{y:.2f}<br>%{x|%Y-%m-%d}<extra>" + name + "</extra>"
867
+
868
+ fig2 = go.Figure()
869
+ for t in rebased.columns:
870
+ fig2.add_trace(go.Scatter(
871
+ x=rebased.index, y=rebased[t], name=t, mode="lines",
872
+ line=dict(width=1, color="rgba(160,160,160,0.4)"),
873
+ hovertemplate=hover_tmpl(t), showlegend=False
874
+ ))
875
+ for t in mag7:
876
+ fig2.add_trace(go.Scatter(
877
+ x=rebased.index, y=rebased[t], name=t, mode="lines",
878
+ line=dict(width=2, color=mag_colors.get(t, "#ffffff")),
879
+ hovertemplate=hover_tmpl(t)
880
+ ))
881
+ for t in top5:
882
+ fig2.add_trace(go.Scatter(
883
+ x=rebased.index, y=rebased[t], name=f"Top {t}", mode="lines",
884
+ line=dict(width=2, color="lime"),
885
+ hovertemplate=hover_tmpl(t), showlegend=False
886
+ ))
887
+ for t in worst5:
888
+ fig2.add_trace(go.Scatter(
889
+ x=rebased.index, y=rebased[t], name=f"Worst {t}", mode="lines",
890
+ line=dict(width=2, color="red", dash="dash"),
891
+ hovertemplate=hover_tmpl(t), showlegend=False
892
+ ))
893
+ fig2.add_trace(go.Scatter(
894
+ x=spx_rebased.index, y=spx_rebased.values, name="S&P 500 (rebased)", mode="lines",
895
+ line=dict(width=3, color="white"), hovertemplate=hover_tmpl("S&P 500")
896
+ ))
897
+
898
+ vals = pd.concat([rebased.stack(), pd.Series(spx_rebased.values, index=spx_rebased.index)])
899
+ vals = vals.replace([np.inf, -np.inf], np.nan).dropna()
900
+ vals = vals[vals > 0]
901
+ y_range = None
902
+ if len(vals) > 10:
903
+ qlo, qhi = vals.quantile([0.01, 0.99])
904
+ y_min = max(1e-2, qlo / y_pad)
905
+ y_max = max(y_min * 1.1, qhi * y_pad)
906
+ y_range = [np.log10(y_min), np.log10(y_max)]
907
+
908
+ fig2.update_yaxes(type="log", range=y_range, title=f"Rebased Price (start = {int(base)})")
909
+ fig2.update_xaxes(title="Date")
910
+ fig2.update_layout(
911
+ template="plotly_dark",
912
+ height=700,
913
+ margin=dict(l=60, r=30, t=70, b=90),
914
+ title=f"Price Level Comparison (Rebased, Log Scale) — Last {n_days} Sessions",
915
+ legend=dict(orientation="h", y=-0.18, yanchor="top", x=0, xanchor="left"),
916
+ hovermode="closest",
917
+ font=dict(color="white")
918
+ )
919
+ st.plotly_chart(fig2, use_container_width=True)
920
+
921
+ with st.expander("Dynamic Interpretation", expanded=False):
922
+ buf2 = io.StringIO()
923
+
924
+ def _fmt_pct(x):
925
+ return "n/a" if pd.isna(x) else f"{x:.1f}%"
926
+
927
+ def _fmt_num(x):
928
+ return "n/a" if pd.isna(x) else f"{x:,.2f}"
929
+
930
+ if rebased.empty or spx_rebased.empty:
931
+ print("No data for interpretation.", file=buf2)
932
+ else:
933
+ as_of = rebased.index[-1].date()
934
+ perf_last = rebased.iloc[-1].dropna()
935
+ spx_last = float(spx_rebased.iloc[-1])
936
+ n_names = len(perf_last)
937
+ eq_avg = float(perf_last.mean())
938
+ eq_med = float(perf_last.median())
939
+ pct_pos = float((perf_last > base).mean() * 100)
940
+ pct_beat = float((perf_last > spx_last).mean() * 100)
941
+ disp_std = float(perf_last.std(ddof=0))
942
+ iqr_lo, iqr_hi = float(perf_last.quantile(0.25)), float(perf_last.quantile(0.75))
943
+ iqr_w = iqr_hi - iqr_lo
944
+
945
+ mag7_in = [t for t in mag7 if t in perf_last.index]
946
+ rest_idx = perf_last.index.difference(mag7_in)
947
+ mag7_mean = float(perf_last[mag7_in].mean()) if len(mag7_in) else np.nan
948
+ rest_mean = float(perf_last[rest_idx].mean()) if len(rest_idx) else np.nan
949
+ mag7_beat = float((perf_last[mag7_in] > spx_last).mean() * 100) if len(mag7_in) else np.nan
950
+
951
+ gains_all = float((perf_last - base).sum())
952
+ topN = 10
953
+ top_contrib = np.nan
954
+ if abs(gains_all) > 1e-9:
955
+ top_contrib = float((perf_last.sort_values(ascending=False).head(topN) - base).sum() / gains_all * 100)
956
+
957
+ rets = rebased.pct_change().replace([np.inf, -np.inf], np.nan).dropna(how="all")
958
+ spx_r = pd.Series(spx_rebased, index=spx_rebased.index).pct_change()
959
+ corr_to_spx = rets.corrwith(spx_r, axis=0).dropna()
960
+ corr_med = float(corr_to_spx.median()) if len(corr_to_spx) else np.nan
961
+ low_corr_share = float((corr_to_spx < 0.3).mean() * 100) if len(corr_to_spx) else np.nan
962
+
963
+ spx_chg = spx_last - base
964
+ k = min(5, n_names)
965
+ leaders = perf_last.sort_values(ascending=False).head(k)
966
+ laggards = perf_last.sort_values(ascending=True).head(k)
967
+
968
+ print(f"=== Rebased performance read — {as_of} (window: {n_days} sessions) ===\n", file=buf2)
969
+ print("[Market]", file=buf2)
970
+ print(f"S&P 500 is {_fmt_pct(spx_chg)} over the window.", file=buf2)
971
+ print(f"Equal-weight average is {_fmt_pct(eq_avg - base)}, median is {_fmt_pct(eq_med - base)}.", file=buf2)
972
+ if np.isfinite(eq_avg) and np.isfinite(spx_last):
973
+ gap = (eq_avg - spx_last)
974
+ side = "above" if gap >= 0 else "below"
975
+ print(f"Equal-weight sits {_fmt_pct(abs(gap))} {side} the index.", file=buf2)
976
+ print("", file=buf2)
977
+
978
+ print("[Breadth]", file=buf2)
979
+ print(f"{_fmt_pct(pct_pos)} of names are up. {_fmt_pct(pct_beat)} beat the index.", file=buf2)
980
+ print(f"Dispersion std is {_fmt_num(disp_std)} points on the rebased scale.", file=buf2)
981
+ print(f"IQR width is {_fmt_num(iqr_w)} points ({_fmt_num(iqr_lo)} to {_fmt_num(iqr_hi)}).", file=buf2)
982
+ if pct_pos >= 70 and pct_beat >= 55:
983
+ print("Rally is broad. Leadership is shared across many names.", file=buf2)
984
+ elif pct_pos <= 35 and pct_beat <= 45:
985
+ print("Rally is narrow or absent. Leadership is concentrated.", file=buf2)
986
+ else:
987
+ print("Breadth is mixed. The tape can rotate quickly.", file=buf2)
988
+ print("", file=buf2)
989
+
990
+ print("[Concentration]", file=buf2)
991
+ if np.isfinite(top_contrib):
992
+ print(f"Top {topN} names explain {_fmt_pct(top_contrib)} of equal-weight gains.", file=buf2)
993
+ if len(mag7_in):
994
+ print(f"MAG7 equal-weight is {_fmt_pct(mag7_mean - base)}. Rest is {_fmt_pct(rest_mean - base)}.", file=buf2)
995
+ if np.isfinite(mag7_beat):
996
+ print(f"{_fmt_pct(mag7_beat)} of MAG7 beat the index.", file=buf2)
997
+ else:
998
+ print("MAG7 tickers are not all present in this window.", file=buf2)
999
+ print("", file=buf2)
1000
+
1001
+ print("[Correlation]", file=buf2)
1002
+ if len(corr_to_spx):
1003
+ print(f"Median correlation to the index is {_fmt_num(corr_med)}.", file=buf2)
1004
+ print(f"{_fmt_pct(low_corr_share)} of names show low correlation (<0.30).", file=buf2)
1005
+ if np.isfinite(corr_med) and corr_med < 0.5:
1006
+ print("Factor dispersion is high. Stock picking matters more.", file=buf2)
1007
+ elif np.isfinite(corr_med) and corr_med > 0.8:
1008
+ print("Common beta dominates. Moves are index-driven.", file=buf2)
1009
+ else:
1010
+ print("Correlation sits in a middle zone. Rotation can continue.", file=buf2)
1011
+ else:
1012
+ print("Not enough data to compute correlations.", file=buf2)
1013
+ print("", file=buf2)
1014
+
1015
+ print("[Leaders]", file=buf2)
1016
+ for t, v in leaders.items():
1017
+ print(f" {t}: {_fmt_pct(v - base)}", file=buf2)
1018
+
1019
+ print("\n[Laggards]", file=buf2)
1020
+ for t, v in laggards.items():
1021
+ print(f" {t}: {_fmt_pct(v - base)}", file=buf2)
1022
+
1023
+ print("\n[What to monitor]", file=buf2)
1024
+ print("Watch the gap between equal-weight and index. A widening gap signals concentration risk.", file=buf2)
1025
+ print("Track the share beating the index. Sustained readings above 55% support trend durability.", file=buf2)
1026
+ print("Watch median correlation. Falling correlation favors dispersion and relative value setups.", file=buf2)
1027
+
1028
+ st.text(buf2.getvalue())
1029
+
1030
+ # ===================== SECTION 3 — Daily Return Heatmap =====================
1031
+ st.header("Daily Return Heatmap")
1032
+
1033
+ with st.expander("Methodology", expanded=False):
1034
+ st.write("Shows daily % returns for all names over the selected window. Highlights broad up/down days, dispersion, and leadership.")
1035
+ st.write("Use it to spot synchronized moves, stress days, and rotation across the universe.")
1036
+
1037
+ st.write("**Daily return (per name)**")
1038
+ st.latex(r"r_{i,t}=\frac{P_{i,t}}{P_{i,t-1}}-1")
1039
+
1040
+ st.write("**Heatmap values**")
1041
+ st.write("Cells display r_{i,t}. Tickers are sorted by the most recent day’s return so leaders/laggards are obvious.")
1042
+
1043
+ st.write("**Robust color scale (cap extremes)**")
1044
+ st.latex(r"c=\operatorname{P95}\left(\left|r_{i,t}\right|\right)\ \text{over the window}")
1045
+ st.latex(r"\text{color range}=[-c,\,+c],\quad \text{midpoint}=0")
1046
+ st.write("Capping avoids a few outliers overpowering the color scale.")
1047
+
1048
+ st.write("**Breadth and dispersion (how to read)**")
1049
+ st.latex(r"\text{Up share}_t=100\cdot \frac{1}{N}\sum_{i=1}^{N}\mathbf{1}[r_{i,t}>0]")
1050
+ st.latex(r"\sigma_{\text{cs},t}=\operatorname{stdev}\{r_{i,t}\}_{i=1}^{N}")
1051
+ st.write("- High up share with low dispersion = uniform risk-on.")
1052
+ st.write("- Mixed colors with high dispersion = rotation and factor spread.")
1053
+ st.write("- Clusters of red/green by industry often flag sector moves.")
1054
+
1055
+ st.write("**Large-move counts (quick context)**")
1056
+ st.latex(r"\text{BigUp}_t=\sum_{i}\mathbf{1}[r_{i,t}\ge \tau],\quad \text{BigDn}_t=\sum_{i}\mathbf{1}[r_{i,t}\le -\tau]")
1057
+ st.latex(r"\tau=2\% \ \text{(default)}")
1058
+ st.write("A jump in BigUp/BigDn signals a thrust or a shock day.")
1059
+
1060
+ st.write("**Short-horizon follow-through**")
1061
+ st.latex(r"\bar{r}_{i,t}^{(w)}=\frac{1}{w}\sum_{k=0}^{w-1} r_{i,t-k},\quad w=5")
1062
+ st.write("A broad rise in 5-day averages supports continuation; a fade warns of stall.")
1063
+
1064
+ st.write("**Practical reads**")
1065
+ st.write("- Many greens, low dispersion: beta tailwind; index setups work.")
1066
+ st.write("- Greens + high dispersion: stock picking/sector tilts matter.")
1067
+ st.write("- Reds concentrated in a few groups: rotate risk, not necessarily de-risk.")
1068
+ st.write("- Extreme red breadth with spikes in dispersion: watch liquidity and reduce gross.")
1069
+
1070
+
1071
+ # Daily returns last N days
1072
+ ret_daily = clean_close.pct_change().iloc[1:]
1073
+ ret_window = int(heat_last_days)
1074
+ ret_last = ret_daily.iloc[-ret_window:]
1075
+ if ret_last.empty:
1076
+ st.warning("Not enough data for the daily return heatmap.")
1077
+ else:
1078
+ order = ret_last.iloc[-1].sort_values(ascending=True).index
1079
+ ret_last = ret_last[order]
1080
+
1081
+ abs_max = np.nanpercentile(np.abs(ret_last.values), 95)
1082
+ z = ret_last.T.values
1083
+ x = ret_last.index
1084
+ y = list(order)
1085
+
1086
+ n_dates = len(x)
1087
+ step = max(1, n_dates // 10)
1088
+ xtick_vals = x[::step]
1089
+ xtick_texts = [ts.strftime("%Y-%m-%d") for ts in xtick_vals]
1090
+
1091
+ fig_hm = go.Figure(go.Heatmap(
1092
+ z=z, x=x, y=y,
1093
+ colorscale="RdYlGn",
1094
+ zmin=-abs_max, zmax=abs_max, zmid=0,
1095
+ colorbar=dict(title="Daily Return", tickformat=".0%"),
1096
+ hovertemplate="%{y}<br>%{x|%Y-%m-%d}<br>%{z:.2%}<extra></extra>"
1097
+ ))
1098
+
1099
+ height = max(800, min(3200, 18 * len(y)))
1100
+ fig_hm.update_layout(
1101
+ template="plotly_dark",
1102
+ title=f"Last {ret_window}-Day Daily Return Heatmap",
1103
+ height=height,
1104
+ margin=dict(l=100, r=40, t=60, b=60),
1105
+ font=dict(color="white")
1106
+ )
1107
+ fig_hm.update_yaxes(title="Tickers (sorted by latest daily return)", tickfont=dict(size=8))
1108
+ fig_hm.update_xaxes(title="Date", tickmode="array", tickvals=xtick_vals, ticktext=xtick_texts, tickangle=45)
1109
+ st.plotly_chart(fig_hm, use_container_width=True)
1110
+
1111
+ with st.expander("Dynamic Interpretation", expanded=False):
1112
+ buf3 = io.StringIO()
1113
+
1114
+ def _pct(x):
1115
+ return "n/a" if pd.isna(x) else f"{x*100:.1f}%"
1116
+
1117
+ def _pp(x):
1118
+ return "n/a" if pd.isna(x) else f"{x*100:.2f}%"
1119
+
1120
+ if ret_last.empty:
1121
+ print("No data for interpretation.", file=buf3)
1122
+ else:
1123
+ as_of = ret_last.index[-1].date()
1124
+ last = ret_last.iloc[-1]
1125
+ N = last.shape[0]
1126
+ up = int((last > 0).sum())
1127
+ dn = int((last < 0).sum())
1128
+ flat = int(N - up - dn)
1129
+ mean = float(last.mean()); med = float(last.median())
1130
+ std = float(last.std(ddof=0))
1131
+ q25 = float(last.quantile(0.25)); q75 = float(last.quantile(0.75))
1132
+ iqr = q75 - q25
1133
+ thr = 0.02
1134
+ big_up = int((last >= thr).sum())
1135
+ big_dn = int((last <= -thr).sum())
1136
+ w = min(5, len(ret_last))
1137
+ avg_w = ret_last.tail(w).mean()
1138
+ pct_pos_w = float((avg_w > 0).mean())
1139
+ cs_std = ret_last.std(axis=1, ddof=0)
1140
+ today_std = float(cs_std.iloc[-1])
1141
+ disp_pct = float((cs_std <= today_std).mean())
1142
+ k = min(10, N)
1143
+ leaders = last.sort_values(ascending=False).head(k)
1144
+ laggards = last.sort_values(ascending=True ).head(k)
1145
+
1146
+ def _streak(s, max_look=20):
1147
+ v = s.tail(max_look).to_numpy(dtype=float)
1148
+ sign = np.sign(v); sign[np.isnan(sign)] = 0
1149
+ if len(sign) == 0 or sign[-1] == 0:
1150
+ return 0
1151
+ tgt = sign[-1]; cnt = 0
1152
+ for x in sign[::-1]:
1153
+ if x == tgt: cnt += 1
1154
+ else: break
1155
+ return int(cnt if tgt > 0 else -cnt)
1156
+
1157
+ streaks = {t: _streak(ret_last[t]) for t in set(leaders.index).union(laggards.index)}
1158
+
1159
+ print(f"=== Daily return heatmap read — {as_of} (last {len(ret_last)} sessions) ===", file=buf3)
1160
+ print("\n[Today]", file=buf3)
1161
+ print(f"Up: {up}/{N} ({_pct(up/N)}). Down: {dn}/{N} ({_pct(dn/N)}). Flat: {flat}.", file=buf3)
1162
+ print(f"Mean: {_pp(mean)}. Median: {_pp(med)}. Std: {_pp(std)}. IQR: {_pp(iqr)}.", file=buf3)
1163
+ print(f"Moves ≥ {int(thr*100)}%: +{big_up}. Moves ≤ -{int(thr*100)}%: {big_dn}.", file=buf3)
1164
+
1165
+ print("\n[Recent breadth]", file=buf3)
1166
+ print(f"{_pct(pct_pos_w)} of names have a positive average over the last {w} sessions.", file=buf3)
1167
+
1168
+ print("\n[Dispersion]", file=buf3)
1169
+ print(f"Cross-section std today: {_pp(today_std)} (window percentile ~{disp_pct*100:.0f}th).", file=buf3)
1170
+
1171
+ print("\n[Leaders today]", file=buf3)
1172
+ for t, v in leaders.items():
1173
+ stv = streaks.get(t, 0)
1174
+ lab = ("flat" if stv == 0 else (f"{stv}d up" if stv > 0 else f"{-stv}d down"))
1175
+ print(f" {t}: {_pp(v)} ({lab})", file=buf3)
1176
+
1177
+ print("\n[Laggards today]", file=buf3)
1178
+ for t, v in laggards.items():
1179
+ stv = streaks.get(t, 0)
1180
+ lab = ("flat" if stv == 0 else (f"{stv}d up" if stv > 0 else f"{-stv}d down"))
1181
+ print(f" {t}: {_pp(v)} ({lab})", file=buf3)
1182
+
1183
+ print("\n[What to monitor]", file=buf3)
1184
+ print("Watch big-move counts and the 5-day positive share for follow-through.", file=buf3)
1185
+ print("Track dispersion; elevated dispersion favors relative moves over index moves.", file=buf3)
1186
+
1187
+ st.text(buf3.getvalue())
1188
+
1189
+ # ===================== SECTION 4 — Percentile Momentum Heatmap =====================
1190
+ st.header("Percentile Momentum Heatmap")
1191
+
1192
+ with st.expander("Methodology", expanded=False):
1193
+ st.write("Ranks each stock’s medium-horizon return against the cross-section each day.")
1194
+ st.write("Use it to spot broad momentum, rotation, and persistence.")
1195
+
1196
+ st.write("**n-day return (per name)**")
1197
+ st.latex(r"r^{(n)}_{i,t}=\frac{P_{i,t}}{P_{i,t-n}}-1")
1198
+
1199
+ st.write("**Cross-sectional percentile (per day)**")
1200
+ st.latex(r"p_{i,t}=\frac{\operatorname{rank}\!\left(r^{(n)}_{i,t}\right)}{N}")
1201
+ st.write("0 means worst in the universe that day. 1 means best.")
1202
+ st.write("The heatmap shows p_{i,t}. Rows are sorted by the latest percentile.")
1203
+
1204
+ st.write("**Breadth buckets (how to read)**")
1205
+ st.latex(r"\text{Top\,20\%}_t=\frac{1}{N}\sum_{i}\mathbf{1}[p_{i,t}\ge 0.80]")
1206
+ st.latex(r"\text{Bottom\,20\%}_t=\frac{1}{N}\sum_{i}\mathbf{1}[p_{i,t}\le 0.20]")
1207
+ st.write("High Top-20% share signals broad upside momentum. High Bottom-20% share signals broad weakness.")
1208
+
1209
+ st.write("**Momentum shift vs a short lookback**")
1210
+ st.latex(r"\Delta p_i=p_{i,T}-p_{i,T-w}")
1211
+ st.write("Improving names: Δp_i > 0. Weakening names: Δp_i < 0.")
1212
+
1213
+ st.write("**Persistence (top/bottom quintile)**")
1214
+ st.latex(r"\text{TopQ}_{i}=\sum_{k=0}^{w-1}\mathbf{1}[p_{i,T-k}\ge 0.80]")
1215
+ st.latex(r"\text{BotQ}_{i}=\sum_{k=0}^{w-1}\mathbf{1}[p_{i,T-k}\le 0.20]")
1216
+ st.write("Names with TopQ = w held leadership. BotQ = w stayed weak.")
1217
+
1218
+ st.write("**Practical reads**")
1219
+ st.write("- Rising median percentile and high Top-20% share: trend has breadth.")
1220
+ st.write("- Mixed median with both tails active: rotation/dispersion regime.")
1221
+ st.write("- Persistent top-quintile list: candidates for follow-through.")
1222
+ st.write("- Persistent bottom-quintile list: candidates for mean-reversion checks.")
1223
+
1224
+
1225
+ look_days = int(mom_look)
1226
+ ret_n = clean_close.pct_change(look_days)
1227
+ ret_n = ret_n.iloc[look_days:]
1228
+ if ret_n.empty:
1229
+ st.warning("Not enough data for the momentum heatmap.")
1230
+ else:
1231
+ perc = ret_n.rank(axis=1, pct=True)
1232
+ order2 = perc.iloc[-1].sort_values(ascending=True).index
1233
+ perc = perc[order2]
1234
+
1235
+ z = perc.T.values
1236
+ x = perc.index
1237
+ y = list(order2)
1238
+
1239
+ n_dates = len(x)
1240
+ step = max(1, n_dates // 10)
1241
+ xtick_vals = x[::step]
1242
+ xtick_texts = [ts.strftime("%Y-%m-%d") for ts in xtick_vals]
1243
+
1244
+ fig_pm = go.Figure(go.Heatmap(
1245
+ z=z, x=x, y=y,
1246
+ colorscale="Viridis",
1247
+ zmin=0, zmax=1,
1248
+ colorbar=dict(title="Return Percentile"),
1249
+ hovertemplate="%{y}<br>%{x|%Y-%m-%d}<br>%{z:.0%}<extra></extra>"
1250
+ ))
1251
+
1252
+ height = max(800, min(3200, 18 * len(y)))
1253
+ fig_pm.update_layout(
1254
+ template="plotly_dark",
1255
+ title=f"{look_days}-Day Return Percentile Heatmap",
1256
+ height=height,
1257
+ margin=dict(l=110, r=40, t=60, b=60),
1258
+ font=dict(color="white")
1259
+ )
1260
+ fig_pm.update_yaxes(title="Tickers (sorted by latest %ile)", tickfont=dict(size=8))
1261
+ fig_pm.update_xaxes(title="Date", tickmode="array", tickvals=xtick_vals, ticktext=xtick_texts, tickangle=45)
1262
+ st.plotly_chart(fig_pm, use_container_width=True)
1263
+
1264
+ with st.expander("Dynamic Interpretation", expanded=False):
1265
+ buf4 = io.StringIO()
1266
+ if perc.empty or ret_n.empty:
1267
+ print("No data for interpretation.", file=buf4)
1268
+ else:
1269
+ as_of = perc.index[-1].date()
1270
+ last_p = perc.iloc[-1].astype(float)
1271
+ last_r = ret_n.iloc[-1].astype(float)
1272
+
1273
+ N = int(last_p.shape[0])
1274
+ mean_p = float(last_p.mean()); med_p = float(last_p.median())
1275
+ q25 = float(last_p.quantile(0.25)); q75 = float(last_p.quantile(0.75))
1276
+ iqr_w = q75 - q25
1277
+
1278
+ top10 = float((last_p >= 0.90).mean() * 100)
1279
+ top20 = float((last_p >= 0.80).mean() * 100)
1280
+ mid40 = float(((last_p > 0.40) & (last_p < 0.60)).mean() * 100)
1281
+ bot20 = float((last_p <= 0.20).mean() * 100)
1282
+ bot10 = float((last_p <= 0.10).mean() * 100)
1283
+
1284
+ pct_up = float((last_r > 0).mean() * 100)
1285
+
1286
+ look = min(5, len(perc))
1287
+ delta = (last_p - perc.iloc[-look].astype(float)).dropna()
1288
+ improving = float((delta > 0).mean() * 100)
1289
+ weakening = float((delta < 0).mean() * 100)
1290
+ delta_med = float(delta.median())
1291
+
1292
+ k = min(10, N)
1293
+ leaders = last_p.sort_values(ascending=False).head(k)
1294
+ laggards = last_p.sort_values(ascending=True ).head(k)
1295
+
1296
+ window_p = 5
1297
+ top_quint = (perc.tail(window_p) >= 0.80).sum()
1298
+ bot_quint = (perc.tail(window_p) <= 0.20).sum()
1299
+ persistent_up = top_quint[top_quint == window_p].index.tolist()
1300
+ persistent_dn = bot_quint[bot_quint == window_p].index.tolist()
1301
+
1302
+ print(f"=== {look_days}-day momentum read — {as_of} ===", file=buf4)
1303
+ print("\n[Snapshot]", file=buf4)
1304
+ print(f"Names: {N}. Up on window: {pct_up:.1f}%.", file=buf4)
1305
+ print(f"Mean percentile: {mean_p:.2f}. Median: {med_p:.2f}.", file=buf4)
1306
+ print(f"IQR: {q25:.2f}–{q75:.2f} (width {iqr_w:.2f}).", file=buf4)
1307
+
1308
+ print("\n[Breadth]", file=buf4)
1309
+ print(f"Top 10%: {top10:.1f}%. Top 20%: {top20:.1f}%.", file=buf4)
1310
+ print(f"Middle 40–60%: {mid40:.1f}%.", file=buf4)
1311
+ print(f"Bottom 20%: {bot20:.1f}%. Bottom 10%: {bot10:.1f}%.", file=buf4)
1312
+
1313
+ print("\n[Shift]", file=buf4)
1314
+ print(f"Improving vs {look} days ago: {improving:.1f}%. Weakening: {weakening:.1f}%.", file=buf4)
1315
+ print(f"Median percentile change: {delta_med:+.2f}.", file=buf4)
1316
+
1317
+ print("\n[Leaders]", file=buf4)
1318
+ for t, v in leaders.items():
1319
+ print(f" {t}: {v:.2f}", file=buf4)
1320
+
1321
+ print("\n[Laggards]", file=buf4)
1322
+ for t, v in laggards.items():
1323
+ print(f" {t}: {v:.2f}", file=buf4)
1324
+
1325
+ print("\n[Persistence]", file=buf4)
1326
+ if persistent_up:
1327
+ up_list = ", ".join(persistent_up[:15]) + ("…" if len(persistent_up) > 15 else "")
1328
+ print(f"Top-quintile {window_p} days: {up_list}", file=buf4)
1329
+ else:
1330
+ print("No names stayed in the top quintile.", file=buf4)
1331
+ if persistent_dn:
1332
+ dn_list = ", ".join(persistent_dn[:15]) + ("…" if len(persistent_dn) > 15 else "")
1333
+ print(f"Bottom-quintile {window_p} days: {dn_list}", file=buf4)
1334
+ else:
1335
+ print("No names stayed in the bottom quintile.", file=buf4)
1336
+
1337
+ print("\n[Focus]", file=buf4)
1338
+ print("Watch the top-quintile share. Rising share supports continuation.", file=buf4)
1339
+ print("Track the median percentile. Sustained readings above 0.60 show broad momentum.", file=buf4)
1340
+ print("Use persistence lists for follow-through and mean-reversion checks.", file=buf4)
1341
+
1342
+ st.text(buf4.getvalue())
1343
+
1344
 
1345
+ # Hide default Streamlit style
1346
+ st.markdown(
1347
+ """
1348
+ <style>
1349
+ #MainMenu {visibility: hidden;}
1350
+ footer {visibility: hidden;}
1351
+ </style>
1352
+ """,
1353
+ unsafe_allow_html=True
1354
+ )