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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +537 -525
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # app.py — Market Breadth & Momentum
2
  import os
3
  import io
4
  import time
@@ -14,13 +13,10 @@ 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
@@ -35,6 +31,12 @@ st.markdown(
35
  "the McClellan Oscillator, and cross-section momentum heatmaps."
36
  )
37
 
 
 
 
 
 
 
38
  # ----------------------------- Sidebar -----------------------------
39
  with st.sidebar:
40
  st.header("Parameters")
@@ -123,9 +125,68 @@ with st.sidebar:
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
 
@@ -193,7 +254,6 @@ def _fetch_one(orig_ticker: str, start: str, end: str):
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()
@@ -223,7 +283,6 @@ def build_close_parallel(tickers: list[str], start: str, end: str, max_workers:
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
@@ -254,541 +313,494 @@ 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)")
 
 
1
  import os
2
  import io
3
  import time
 
13
  import streamlit as st
14
  from plotly.subplots import make_subplots
15
  import plotly.graph_objects as go
 
 
16
 
17
  # ----------------------------- Helpers & Caching -----------------------------
18
  API_KEY = os.getenv("FMP_API_KEY")
19
 
 
20
  MAX_WORKERS = 32
21
  RATE_BACKOFF_MAX = 300
22
  JITTER_SEC = 0.2
 
31
  "the McClellan Oscillator, and cross-section momentum heatmaps."
32
  )
33
 
34
+ # ---------- session state for sticky results ----------
35
+ if "run_id" not in st.session_state:
36
+ st.session_state.run_id = None
37
+ if "last_params" not in st.session_state:
38
+ st.session_state.last_params = None
39
+
40
  # ----------------------------- Sidebar -----------------------------
41
  with st.sidebar:
42
  st.header("Parameters")
 
125
  help="Return horizon for the percentile momentum heatmap."
126
  )
127
 
128
+ # Buttons: run persists, clear removes results
129
+ colA, colB = st.columns(2)
130
+ with colA:
131
+ run_clicked = st.button("Run Analysis", type="primary", use_container_width=True)
132
+ with colB:
133
+ clear_clicked = st.button("Clear Results", type="secondary", use_container_width=True)
134
+
135
+ if run_clicked:
136
+ # freeze a snapshot of params used for this run
137
+ st.session_state.last_params = dict(
138
+ start_date=start_date,
139
+ end_date=end_date,
140
+ sma_fast=int(sma_fast),
141
+ sma_slow=int(sma_slow),
142
+ vwap_weeks=int(vwap_weeks),
143
+ ad_smooth=int(ad_smooth),
144
+ mo_span_fast=int(mo_span_fast),
145
+ mo_span_slow=int(mo_span_slow),
146
+ mo_signal_span=int(mo_signal_span),
147
+ rebase_days=int(rebase_days),
148
+ rebase_base=float(rebase_base),
149
+ y_pad=int(y_pad),
150
+ heat_last_days=int(heat_last_days),
151
+ mom_look=int(mom_look),
152
+ )
153
+ # mark that results should be shown (and re-shown on reruns)
154
+ st.session_state.run_id = f"{time.time():.0f}"
155
+
156
+ if clear_clicked:
157
+ st.session_state.run_id = None
158
+ st.session_state.last_params = None
159
+
160
+ # If there are no results yet, show a hint and stop rendering heavy stuff.
161
+ if not st.session_state.run_id:
162
+ st.info("Set your parameters and click **Run Analysis**. Results will persist until you press **Clear Results**.")
163
+ st.stop()
164
+
165
+ # Use the frozen parameters from the last run so the view doesn’t “shift” on rerun.
166
+ P = st.session_state.last_params or {}
167
+ start_date = P.get("start_date", datetime(2015, 1, 1).date())
168
+ end_date = P.get("end_date", (datetime.today().date() + timedelta(days=1)))
169
+ sma_fast = P.get("sma_fast", 50)
170
+ sma_slow = P.get("sma_slow", 200)
171
+ vwap_weeks = P.get("vwap_weeks", 200)
172
+ ad_smooth = P.get("ad_smooth", 30)
173
+ mo_span_fast = P.get("mo_span_fast", 19)
174
+ mo_span_slow = P.get("mo_span_slow", 39)
175
+ mo_signal_span = P.get("mo_signal_span", 9)
176
+ rebase_days = P.get("rebase_days", 365)
177
+ rebase_base = P.get("rebase_base", 100.0)
178
+ y_pad = P.get("y_pad", 3)
179
+ heat_last_days = P.get("heat_last_days", 60)
180
+ mom_look = P.get("mom_look", 30)
181
+
182
+ st.caption(
183
+ f"Showing results for **Start** {start_date} → **End** {end_date} | "
184
+ f"50/200 MAs = {sma_fast}/{sma_slow} | VWAP weeks = {vwap_weeks} | "
185
+ f"AD smooth = {ad_smooth} | MO = {mo_span_fast}/{mo_span_slow} (signal {mo_signal_span}) | "
186
+ f"Rebase {rebase_days}d @ {rebase_base} | Heatmap {heat_last_days}d | Momentum lookback {mom_look}d."
187
+ )
188
 
189
+ # ----------------------------- Networking helpers -----------------------------
190
  def _to_vendor(sym: str) -> str:
191
  return sym.replace("-", ".")
192
 
 
254
 
255
  @st.cache_data(show_spinner=False)
256
  def build_close_parallel(tickers: list[str], start: str, end: str, max_workers: int = MAX_WORKERS):
 
257
  series_dict = {}
258
  missing = {}
259
  lock = threading.Lock()
 
283
 
284
  @st.cache_data(show_spinner=False)
285
  def fetch_index_ohlcv(start: str, end: str):
 
286
  url = "https://financialmodelingprep.com/api/v3/historical-price-full/index/%5EGSPC"
287
  params = {"from": start, "to": end, "apikey": API_KEY}
288
  backoff = 5
 
313
  s = s.dropna()
314
  return s.iloc[-1] if len(s) else np.nan
315
 
316
+ # ----------------------------- Run (sticky) -----------------------------
317
+ with st.spinner("Loading tickers…"):
318
+ try:
319
+ spx_table = fetch_sp500_table()
320
+ except Exception:
321
+ st.error("Ticker table request failed. Try again later.")
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  st.stop()
323
 
324
+ tickers = spx_table["Symbol"].tolist()
325
+ st.caption(f"Constituents loaded: {len(tickers)}")
326
 
327
+ start_str = pd.to_datetime(start_date).strftime("%Y-%m-%d")
328
+ end_str = pd.to_datetime(end_date).strftime("%Y-%m-%d")
329
 
330
+ with st.spinner("Fetching historical prices (parallel)…"):
331
+ close, missing = build_close_parallel(tickers, start_str, end_str)
332
+ if close.empty:
333
+ st.error("No price data returned. Reduce the date range and retry.")
334
+ st.stop()
 
 
 
 
335
 
336
+ if missing:
337
+ st.warning(f"No data for {min(20, len(missing))} symbols (showing up to 20).")
338
 
339
+ clean_close = close.copy()
 
340
 
341
+ with st.spinner("Fetching index data…"):
342
+ try:
343
+ idx_df = fetch_index_ohlcv(
344
+ start=clean_close.index[0].strftime("%Y-%m-%d"),
345
+ end=end_str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  )
347
+ except Exception:
348
+ st.error("Index data request failed. Try again later.")
349
+ st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
+ idx = idx_df["Close"].reindex(clean_close.index).ffill()
352
+ idx_volume = idx_df["Volume"].reindex(clean_close.index).ffill()
353
+
354
+ # ===================== SECTION 1 — Breadth Dashboard =====================
355
+ st.header("Breadth Dashboard")
356
+ # ( the rest of your original analysis/plotting code is unchanged …)
357
+ # NOTE: everything below stays exactly the same as your original file.
358
+
359
+ # ---------------------- [KEEP YOUR ORIGINAL CODE BELOW THIS LINE] ----------------------
360
+ # 1) Methodology expander
361
+ with st.expander("Methodology", expanded=False):
362
+ # Overview
363
+ st.write("This panel tracks trend, participation, and momentum for a broad equity universe.")
364
+ st.write("Use it to judge trend quality, spot divergences, and gauge risk bias.")
365
+ # 1) Price trend (MAs, VWAP)
366
+ st.write("**Price trend**")
367
+ st.latex(r"\mathrm{SMA}_{n}(t)=\frac{1}{n}\sum_{k=0}^{n-1}P_{t-k}")
368
+ st.write("Approximate 200-week VWAP (using ~5 trading days per week):")
369
+ 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")
370
+ st.write("Price above both MAs and fast>slow = strong trend.")
371
+ st.write("Price below both MAs and fast<slow = weak trend.")
372
+ # 2) Participation breadth (% above MAs)
373
+ st.write("**Participation breadth**")
374
+ st.write("Share above n-day MA:")
375
+ st.latex(r"\%\,\text{Above}_n(t)=100\cdot\frac{\#\{i:\ P_{i,t}>\mathrm{SMA}_{n,i}(t)\}}{N}")
376
+ st.write("Zones: 0–20 weak, 20–50 neutral, 50–80 strong.")
377
+ st.write("Higher shares mean broad support for the trend.")
378
+ # 3) A/D line
379
+ st.write("**Advance–Decline (A/D) line**")
380
+ st.latex(r"A_t=\#\{i:\ P_{i,t}>P_{i,t-1}\},\quad D_t=\#\{i:\ P_{i,t}<P_{i,t-1}\}")
381
+ st.latex(r"\mathrm{ADLine}_t=\sum_{u\le t}(A_u-D_u)")
382
+ st.write("Rising A/D confirms uptrends. Falling A/D warns of narrow leadership.")
383
+ # 4) Net new 52-week highs
384
+ st.write("**Net new 52-week highs**")
385
+ 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}")
386
+ 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}\}")
387
+ st.latex(r"\text{NetHighs}_t=\text{NewHighs}_t-\text{NewLows}_t")
388
+ st.write("Positive and persistent net highs support trend durability.")
389
+ # 5) Smoothed advancing vs declining counts
390
+ st.write("**Advancing vs declining (smoothed)**")
391
+ 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}")
392
+ st.write("Advancers > decliners over the window = constructive breadth.")
393
+ # 6) McClellan Oscillator
394
+ st.write("**McClellan Oscillator (MO)**")
395
+ st.latex(r"E^{(n)}_t=\text{EMA}_n(A_t-D_t)")
396
+ st.latex(r"\mathrm{MO}_t=E^{(19)}_t-E^{(39)}_t")
397
+ st.write("Zero-line up-cross = improving momentum. Down-cross = fading momentum.")
398
+ st.write("A 9-day EMA of MO can act as a signal line.")
399
+ # Practical reads
400
+ st.write("**Practical use**")
401
+ st.write("- Broad strength: % above 200-day ≥ 50% supports trends.")
402
+ st.write("- Divergences: index near highs without A/D or MO confirmation = caution.")
403
+ st.write("- Breadth thrust: sharp rise in % above 50-day to ≥ 55% with a +20pt jump can mark regime turns.")
404
+ st.write("- MO near recent extremes flags stretched short-term conditions.")
405
+
406
+ # --- Compute indicators (respecting sidebar params) ---
407
+ sma_fast_idx = idx.rolling(int(sma_fast), min_periods=int(sma_fast)).mean()
408
+ sma_slow_idx = idx.rolling(int(sma_slow), min_periods=int(sma_slow)).mean()
409
+ vwap_days = int(vwap_weeks) * 5
410
+ vwap_idx = (idx * idx_volume).rolling(vwap_days, min_periods=vwap_days).sum() / \
411
+ idx_volume.rolling(vwap_days, min_periods=vwap_days).sum()
412
+
413
+ sma_fast_all = clean_close.rolling(int(sma_fast), min_periods=int(sma_fast)).mean()
414
+ sma_slow_all = clean_close.rolling(int(sma_slow), min_periods=int(sma_slow)).mean()
415
+ pct_above_fast = (clean_close > sma_fast_all).sum(axis=1) / clean_close.shape[1] * 100
416
+ pct_above_slow = (clean_close > sma_slow_all).sum(axis=1) / clean_close.shape[1] * 100
417
+
418
+ advances = (clean_close.diff() > 0).sum(axis=1)
419
+ declines = (clean_close.diff() < 0).sum(axis=1)
420
+ ad_line = (advances - declines).cumsum()
421
+
422
+ window = int(ad_smooth)
423
+ avg_adv = advances.rolling(window, min_periods=window).mean()
424
+ avg_decl = declines.rolling(window, min_periods=window).mean()
425
+
426
+ high52 = clean_close.rolling(252, min_periods=252).max()
427
+ low52 = clean_close.rolling(252, min_periods=252).min()
428
+ new_highs = (clean_close == high52).sum(axis=1)
429
+ new_lows = (clean_close == low52).sum(axis=1)
430
+ net_highs = new_highs - new_lows
431
+ sma10_net_hi = net_highs.rolling(10, min_periods=10).mean()
432
+
433
+ net_adv = (advances - declines).astype("float64")
434
+ ema_fast = net_adv.ewm(span=int(mo_span_fast), adjust=False).mean()
435
+ ema_slow = net_adv.ewm(span=int(mo_span_slow), adjust=False).mean()
436
+ mc_osc = (ema_fast - ema_slow).rename("MO")
437
+ mo_pos = mc_osc.clip(lower=0)
438
+ mo_neg = mc_osc.clip(upper=0)
439
+
440
+ bound = float(np.nanpercentile(np.abs(mc_osc.dropna()), 99)) if mc_osc.notna().sum() else 20.0
441
+ bound = max(20.0, math.ceil(bound / 10.0) * 10.0)
442
+
443
+ # --- Plot (6 rows) ---
444
+ fig = make_subplots(
445
+ rows=6, cols=1, shared_xaxes=True, vertical_spacing=0.03,
446
+ subplot_titles=(
447
+ "S&P 500 Price / Fast MA / Slow MA / Weekly VWAP",
448
+ f"% Above {int(sma_fast)}d & {int(sma_slow)}d",
449
+ "Advance–Decline Line",
450
+ "Net New 52-Week Highs (bar) + 10d SMA",
451
+ f"Advancing vs Declining ({int(window)}d MA)",
452
+ f"McClellan Oscillator ({int(mo_span_fast)},{int(mo_span_slow)})"
453
  )
454
+ )
455
+ fig.update_layout(template="plotly_dark", font=dict(color="white"))
456
+ if hasattr(fig.layout, "annotations"):
457
+ for a in fig.layout.annotations:
458
+ a.font = dict(color="white", size=12)
459
+
460
+ # Row 1
461
+ fig.add_trace(go.Scatter(x=idx.index, y=idx, name="S&P 500"), row=1, col=1)
462
+ 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)
463
+ 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)
464
+ fig.add_trace(go.Scatter(x=vwap_idx.index, y=vwap_idx, name=f"{int(vwap_weeks)}-week VWAP"), row=1, col=1)
465
+
466
+ # Row 2
467
+ fig.add_hrect(y0=0, y1=20, line_width=0, fillcolor="red", opacity=0.3, row=2, col=1)
468
+ fig.add_hrect(y0=20, y1=50, line_width=0, fillcolor="yellow", opacity=0.3, row=2, col=1)
469
+ fig.add_hrect(y0=50, y1=80, line_width=0, fillcolor="green", opacity=0.3, row=2, col=1)
470
+ 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)
471
+ 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)
472
+ fig.add_annotation(x=0, xref="paper", y=10, yref="y2", text="Weak", showarrow=False, align="left", font=dict(color="white"))
473
+ fig.add_annotation(x=0, xref="paper", y=35, yref="y2", text="Neutral", showarrow=False, align="left", font=dict(color="white"))
474
+ fig.add_annotation(x=0, xref="paper", y=65, yref="y2", text="Strong", showarrow=False, align="left", font=dict(color="white"))
475
+
476
+ # Row 3
477
+ fig.add_trace(go.Scatter(x=ad_line.index, y=ad_line, name="A/D Line"), row=3, col=1)
478
+
479
+ # Row 4
480
+ fig.add_trace(go.Bar(x=net_highs.index, y=net_highs, name="Net New Highs", opacity=0.5), row=4, col=1)
481
+ fig.add_trace(go.Scatter(x=sma10_net_hi.index, y=sma10_net_hi, name="10-day SMA"), row=4, col=1)
482
+
483
+ # Row 5
484
+ fig.add_trace(go.Scatter(x=avg_adv.index, y=avg_adv, name=f"Adv ({int(window)}d MA)"), row=5, col=1)
485
+ fig.add_trace(go.Scatter(x=avg_decl.index, y=avg_decl, name=f"Dec ({int(window)}d MA)"), row=5, col=1)
486
+
487
+ # Row 6
488
+ fig.add_trace(
489
+ go.Bar(x=mo_pos.index, y=mo_pos, name="MO +",
490
+ marker=dict(color="#2ecc71", line=dict(width=0)),
491
+ hovertemplate="MO: %{y:.1f}<br>%{x|%Y-%m-%d}<extra></extra>",
492
+ showlegend=False),
493
+ row=6, col=1
494
+ )
495
+ fig.add_trace(
496
+ go.Bar(x=mo_neg.index, y=mo_neg, name="MO -",
497
+ marker=dict(color="#e74c3c", line=dict(width=0)),
498
+ hovertemplate="MO: %{y:.1f}<br>%{x|%Y-%m-%d}<extra></extra>",
499
+ showlegend=False),
500
+ row=6, col=1
501
+ )
502
+ fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="rgba(180,180,180,0.8)", row=6, col=1)
503
+
504
+ fig.update_xaxes(
505
+ ticklabelmode="period",
506
+ tickformatstops=[
507
+ dict(dtickrange=[None, 24*3600*1000], value="%b %d\n%Y"),
508
+ dict(dtickrange=[24*3600*1000, 7*24*3600*1000], value="%b %d"),
509
+ dict(dtickrange=[7*24*3600*1000, "M1"], value="%b %d\n%Y"),
510
+ dict(dtickrange=["M1", "M6"], value="%b %Y"),
511
+ dict(dtickrange=["M6", None], value="%Y"),
512
+ ],
513
+ tickangle=0,
514
+ tickfont=dict(color="white"),
515
+ title_font=dict(color="white"),
516
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
517
+ showline=True, linecolor="rgba(255,255,255,0.4)",
518
+ rangeslider_visible=False
519
+ )
520
+ fig.update_yaxes(
521
+ tickfont=dict(color="white"),
522
+ title_font=dict(color="white"),
523
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
524
+ showline=True, linecolor="rgba(255,255,255,0.4)"
525
+ )
526
+ fig.update_yaxes(title_text="Price", row=1, col=1)
527
+ fig.update_yaxes(title_text="Percent", row=2, col=1, range=[0, 100])
528
+ fig.update_yaxes(title_text="A/D", row=3, col=1)
529
+ fig.update_yaxes(title_text="Net", row=4, col=1)
530
+ fig.update_yaxes(title_text="Count", row=5, col=1)
531
+ fig.update_yaxes(title_text="MO", row=6, col=1, range=[-bound, bound], side="right")
532
+ fig.update_xaxes(title_text="Date", row=6, col=1)
533
+
534
+ fig.update_layout(
535
+ height=1350,
536
+ bargap=0.02,
537
+ barmode="relative",
538
+ legend=dict(
539
+ orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
540
+ font=dict(color="white")
541
+ ),
542
+ margin=dict(l=60, r=20, t=40, b=40),
543
+ hovermode="x unified",
544
+ font=dict(color="white"),
545
+ title=dict(font=dict(color="white"))
546
+ )
547
+ st.plotly_chart(fig, use_container_width=True)
548
+
549
+ # === Dynamic Interpretation (unchanged) ===
550
+ with st.expander("Dynamic Interpretation", expanded=False):
551
+ buf = io.StringIO()
552
+ def _last_val(s):
553
+ s = s.dropna()
554
+ return s.iloc[-1] if len(s) else np.nan
555
+ def _last_date(s):
556
+ s = s.dropna()
557
+ return s.index[-1] if len(s) else None
558
+ def _pct(a, b):
559
+ if not np.isfinite(a) or not np.isfinite(b) or b == 0:
560
+ return np.nan
561
+ return (a - b) / b * 100.0
562
+ def _fmt_pct(x):
563
+ return "n/a" if not np.isfinite(x) else f"{x:.1f}%"
564
+ def _fmt_num(x):
565
+ return "n/a" if not np.isfinite(x) else f"{x:,.2f}"
566
+
567
+ as_of = _last_date(idx)
568
+ px = _last_val(idx)
569
+ ma50 = _last_val(sma_fast_idx)
570
+ ma200 = _last_val(sma_slow_idx)
571
+ vwap200 = _last_val(vwap_idx)
572
+ p50 = float(_last_val(pct_above_fast))
573
+ p200 = float(_last_val(pct_above_slow))
574
+ ad_now = _last_val(ad_line)
575
+ nh_now = int(_last_val(new_highs)) if np.isfinite(_last_val(new_highs)) else 0
576
+ nh_sma = float(_last_val(sma10_net_hi))
577
+ avg_adv_last = float(_last_val(avg_adv))
578
+ avg_decl_last = float(_last_val(avg_decl))
579
+ _ema19 = net_adv.ewm(span=int(mo_span_fast), adjust=False).mean()
580
+ _ema39 = net_adv.ewm(span=int(mo_span_slow), adjust=False).mean()
581
+ mc_osc2 = (_ema19 - _ema39).rename("MO")
582
+ mc_signal = mc_osc2.ewm(span=int(mo_signal_span), adjust=False).mean().rename("Signal")
583
+ mo_last = float(_last_val(mc_osc2))
584
+ mo_prev = float(_last_val(mc_osc2.shift(1)))
585
+ mo_5ago = float(_last_val(mc_osc2.shift(5)))
586
+ mo_slope5 = mo_last - mo_5ago
587
+ mo_sig_last = float(_last_val(mc_signal))
588
+ mo_sig_prev = float(_last_val(mc_signal.shift(1)))
589
+ mo_roll = mc_osc2.rolling(252, min_periods=126)
590
+ mo_mean = mo_roll.mean()
591
+ mo_std = mo_roll.std()
592
+ mo_z = (mc_osc2 - mo_mean) / mo_std
593
+ mo_z_last = float(_last_val(mo_z))
594
+ mo_abs = np.abs(mc_osc2.dropna())
595
+ if len(mo_abs) >= 20:
596
+ mo_ext = float(np.nanpercentile(mo_abs.tail(252), 90))
597
+ else:
598
+ mo_ext = np.nan
599
+ look_fast = 10
600
+ look_mid = 20
601
+ look_div = 63
602
+ ma50_slope = _last_val(sma_fast_idx.diff(look_fast))
603
+ ma200_slope = _last_val(sma_slow_idx.diff(look_mid))
604
+ p50_chg = p50 - float(_last_val(pct_above_fast.shift(look_fast)))
605
+ p200_chg = p200 - float(_last_val(pct_above_slow.shift(look_fast)))
606
+ ad_mom = ad_now - float(_last_val(ad_line.shift(look_mid)))
607
+ d50 = _pct(px, ma50)
608
+ d200 = _pct(px, ma200)
609
+ dvw = _pct(px, vwap200)
610
+ h63 = float(_last_val(idx.rolling(look_div).max()))
611
+ dd63 = _pct(px, h63) if np.isfinite(h63) else np.nan
612
+ ad_63h = float(_last_val(ad_line.rolling(look_div).max()))
613
+ mo_63h = float(_last_val(mc_osc2.rolling(look_div).max()))
614
+ near_high_px = np.isfinite(h63) and np.isfinite(px) and px >= 0.995 * h63
615
+ near_high_ad = np.isfinite(ad_63h) and np.isfinite(ad_now) and ad_now >= 0.995 * ad_63h
616
+ near_high_mo = np.isfinite(mo_63h) and np.isfinite(mo_last) and mo_last >= 0.95 * mo_63h
617
+ breadth_thrust = (p50 >= 55) and (p50_chg >= 20)
618
+ score = 0
619
+ score += 1 if px > ma50 else 0
620
+ score += 1 if px > ma200 else 0
621
+ score += 1 if ma50 > ma200 else 0
622
+ score += 1 if ma50_slope > 0 else 0
623
+ score += 1 if p50 >= 50 else 0
624
+ score += 1 if p200 >= 50 else 0
625
+ score += 1 if ad_mom > 0 else 0
626
+ score += 1 if nh_now > 0 and nh_sma >= 0 else 0
627
+ score += 1 if avg_adv_last > avg_decl_last else 0
628
+ score += 1 if (mo_last > 0 and mo_slope5 > 0) else 0
629
+ if score >= 8:
630
+ regime = "Risk-on bias"
631
+ elif score >= 5:
632
+ regime = "Mixed bias"
633
+ else:
634
+ regime = "Risk-off bias"
635
+
636
+ print(f"=== Market breadth narrative — {as_of.date() if as_of is not None else 'N/A'} ===", file=buf)
637
+ # [Trend]
638
+ print("\n[Trend]", file=buf)
639
+ if np.isfinite(px) and np.isfinite(ma50) and np.isfinite(ma200):
640
+ print(
641
+ "The index is {px}, the 50-day is {ma50}, and the 200-day is {ma200}. "
642
+ "Price runs {d50} vs the 50-day and {d200} vs the 200-day. "
643
+ "The 50-day changed by {m50s} over {f} sessions and the 200-day changed by {m200s} over {m} sessions."
644
+ .format(
645
+ px=_fmt_num(px), ma50=_fmt_num(ma50), ma200=_fmt_num(ma200),
646
+ d50=_fmt_pct(d50), d200=_fmt_pct(d200),
647
+ m50s=f"{ma50_slope:+.2f}" if np.isfinite(ma50_slope) else "n/a",
648
+ m200s=f"{ma200_slope:+.2f}" if np.isfinite(ma200_slope) else "n/a",
649
+ f=look_fast, m=look_mid
650
+ ), file=buf
651
+ )
652
+ if np.isfinite(vwap200):
653
+ print("The index is {dvw} versus the 200-week VWAP.".format(dvw=_fmt_pct(dvw)), file=buf)
654
+ if np.isfinite(dd63):
655
+ print("Distance from the 3-month high is {dd}.".format(dd=_fmt_pct(dd63)), file=buf)
656
+ if px > ma50 and ma50 > ma200:
657
+ print("Structure is bullish: price above both averages and the fast above the slow.", file=buf)
658
+ elif px < ma50 and ma50 < ma200:
659
+ print("Structure is bearish: price below both averages and the fast below the slow.", file=buf)
660
  else:
661
+ print("Structure is mixed: levels are not aligned.", file=buf)
662
+ else:
663
+ print("Trend inputs are incomplete.", file=buf)
664
+
665
+ # [Participation]
666
+ print("\n[Participation]", file=buf)
667
+ if np.isfinite(p50) and np.isfinite(p200):
668
+ print(
669
+ "{p50} of members sit above the 50-day and {p200} above the 200-day. "
670
+ "The 50-day share moved {p50chg} over {f} sessions, and the 200-day share moved {p200chg}."
671
+ .format(
672
+ p50=f"{p50:.1f}%", p200=f"{p200:.1f}%",
673
+ p50chg=f"{p50_chg:+.1f} pts", p200chg=f"{p200_chg:+.1f} pts", f=look_fast
674
+ ), file=buf
675
+ )
676
+ if p50 < 20 and p200 < 20:
677
+ print("Participation is very weak across both horizons.", file=buf)
678
+ elif p50 < 50 and p200 < 50:
679
+ print("Participation is weak; leadership is narrow.", file=buf)
680
+ elif p50 >= 50 and p200 < 50:
681
+ print("Short-term breadth improved, long-term base still soft.", file=buf)
682
+ elif p50 >= 50 and p200 >= 50:
683
+ print("Participation is broad and supportive.", file=buf)
684
+ if breadth_thrust:
685
+ print("The 50-day breadth jump qualifies as a breadth thrust.", file=buf)
686
+ else:
687
+ print("Breadth percentages are missing.", file=buf)
688
+
689
+ # [Advance–Decline]
690
+ print("\n[Advance–Decline]", file=buf)
691
+ if np.isfinite(ad_now):
692
+ print(
693
+ "A/D momentum over {m} sessions is {admom:+.0f}. "
694
+ "Price is {pxnear} a 3-month high and A/D is {adnear} the same mark."
695
+ .format(
696
+ m=look_mid, admom=ad_mom,
697
+ pxnear="near" if near_high_px else "not near",
698
+ adnear="near" if near_high_ad else "not near"
699
+ ), file=buf
700
+ )
701
+ if near_high_px and not near_high_ad:
702
+ print("Price tested highs without A/D confirmation.", file=buf)
703
+ elif near_high_px and near_high_ad:
704
+ print("Price and A/D both near recent highs.", file=buf)
705
+ elif (not near_high_px) and near_high_ad:
706
+ print("A/D improved while price lagged.", file=buf)
707
  else:
708
+ print("No short-term confirmation signal.", file=buf)
709
+ else:
710
+ print("A/D data is unavailable.", file=buf)
711
+
712
+ # [McClellan Oscillator]
713
+ print("\n[McClellan Oscillator]", file=buf)
714
+ if np.isfinite(mo_last):
715
+ zero_cross_up = (mo_prev < 0) and (mo_last >= 0)
716
+ zero_cross_down = (mo_prev > 0) and (mo_last <= 0)
717
+ sig_cross_up = (mo_prev <= mo_sig_prev) and (mo_last > mo_sig_last)
718
+ sig_cross_down = (mo_prev >= mo_sig_prev) and (mo_last < mo_sig_last)
719
+ near_extreme = np.isfinite(mo_ext) and (abs(mo_last) >= 0.9 * mo_ext)
720
+
721
+ print(
722
+ "MO prints {mo:+.1f} with a 9-day signal at {sig:+.1f}. "
723
+ "Five-day slope is {slope:+.1f}. Z-score over 1y is {z}."
724
+ .format(
725
+ mo=mo_last, sig=mo_sig_last, slope=mo_slope5,
726
+ z=f"{mo_z_last:.2f}" if np.isfinite(mo_z_last) else "n/a"
727
+ ), file=buf
728
+ )
729
+
730
+ if zero_cross_up:
731
+ print("Bullish zero-line cross: momentum turned positive.", file=buf)
732
+ if zero_cross_down:
733
+ print("Bearish zero-line cross: momentum turned negative.", file=buf)
734
+ if sig_cross_up:
735
+ print("Bullish signal cross: MO moved above its 9-day signal.", file=buf)
736
+ if sig_cross_down:
737
+ print("Bearish signal cross: MO fell below its 9-day signal.", file=buf)
738
+
739
+ if near_extreme:
740
+ tag = "positive" if mo_last > 0 else "negative"
741
+ print(f"MO is near a recent {tag} extreme by distribution.", file=buf)
742
+ elif np.isfinite(mo_ext):
743
+ print(f"Recent absolute extreme band is about ±{mo_ext:.0f}.", file=buf)
744
+
745
+ if near_high_px and not near_high_mo:
746
+ print("Price near short-term highs without a matching MO high.", file=buf)
747
+ if (not near_high_px) and near_high_mo:
748
+ print("MO near a short-term high while price lags.", file=buf)
749
+ else:
750
+ print("MO series is unavailable.", file=buf)
751
+
752
+ # [New Highs vs Lows]
753
+ print("\n[New Highs vs Lows]", file=buf)
754
+ if np.isfinite(nh_sma):
755
+ if nh_now > 0 and nh_sma >= 0:
756
+ print("Net new highs are positive and the 10-day trend is non-negative.", file=buf)
757
+ elif nh_now < 0 and nh_sma <= 0:
758
+ print("Net new lows dominate and the 10-day trend is negative.", file=buf)
759
  else:
760
+ print("Daily print and 10-day trend disagree; signal is mixed.", file=buf)
761
+ else:
762
+ print("High/low series is incomplete.", file=buf)
763
+
764
+ # [Advancing vs Declining]
765
+ print("\n[Advancing vs Declining]", file=buf)
766
+ if np.isfinite(avg_adv_last) and np.isfinite(avg_decl_last):
767
+ spread = avg_adv_last - avg_decl_last
768
+ print(
769
+ "On a {w}-day smoothing window, advancers average {adv:.0f} and decliners {dec:.0f}. Net spread is {spr:+.0f}."
770
+ .format(w=window, adv=avg_adv_last, dec=avg_decl_last, spr=spread), file=buf
771
+ )
772
+ if spread > 0:
773
+ print("The spread favors advancers.", file=buf)
774
+ elif spread < 0:
775
+ print("The spread favors decliners.", file=buf)
776
  else:
777
+ print("Advancers and decliners are balanced.", file=buf)
778
+ else:
779
+ print("Smoothed A/D data is missing.", file=buf)
780
+
781
+ # [Aggregate]
782
+ print("\n[Aggregate]", file=buf)
783
+ print("Composite score is {score}/10 → {regime}.".format(score=score, regime=regime), file=buf)
784
+ if regime == "Risk-on bias":
785
+ if p200 >= 60 and ma200_slope > 0 and mo_last > 0:
786
+ print("Long-term breadth and MO agree; pullbacks above the 50-day tend to be buyable.", file=buf)
 
 
 
 
 
 
787
  else:
788
+ print("Tone is supportive; watch the 200-day and MO zero-line for confirmation.", file=buf)
789
+ elif regime == "Mixed bias":
790
+ print("Signals diverge; manage size and tighten risk until MO and breadth align.", file=buf)
791
+ else:
792
+ if p200 <= 40 and ma200_slope < 0 and mo_last < 0:
793
+ print("Weak long-term breadth with negative MO argues for caution.", file=buf)
 
 
 
 
 
 
794
  else:
795
+ print("Bias leans defensive until breadth steadies and MO turns up.", file=buf)
 
 
 
796
 
797
+ # [What to monitor]
798
+ print("\n[What to monitor]", file=buf)
799
+ print("Watch the 200-day breadth around 50% for confirmation of durable trends.", file=buf)
800
+ print("Track MO zero-line and signal crosses during price tests of resistance.", file=buf)
801
+ print("Look for steady positive net new highs over a 10-day window.", file=buf)
802
 
803
+ st.text(buf.getvalue())
804
 
805
  # ===================== SECTION 2 — Rebased Comparison =====================
806
  st.header("Rebased Comparison (Last N sessions)")