Trebor1009 commited on
Commit
659f3c7
Β·
verified Β·
1 Parent(s): 0bbbdc1

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +486 -361
src/streamlit_app.py CHANGED
@@ -1226,21 +1226,25 @@ def render_rotation_map():
1226
  # TAB 6: COMBINED SCANNER (RRG + Bottom Scanner)
1227
  # =========================================================
1228
 
1229
- # Quality / liquidity constants (applied as-of signal date, no look-ahead)
1230
- PRICE_FLOOR = 5.0 # min stock price
1231
- MIN_DVOL = 1_000_000 # min 20-day avg dollar volume
1232
- DEDUP_DAYS = 63 # cooldown before same ticker can re-flag
1233
-
1234
- def _rrg(price_series: pd.Series, benchmark: pd.Series, window: int) -> pd.DataFrame:
1235
- """
1236
- Compute RS-Ratio and RS-Momentum centred at 100.
1237
- price_series and benchmark must be aligned (same index).
1238
- """
1239
- rs = price_series / (benchmark + 1e-9)
1240
- rs_ratio = 100 + ((rs - rs.rolling(window).mean()) /
1241
- (rs.rolling(window).std() + 1e-9)) * 5
1242
- rs_mom = 100 + ((rs_ratio - rs_ratio.rolling(window).mean()) /
1243
- (rs_ratio.rolling(window).std() + 1e-9)) * 5
 
 
 
 
1244
  return pd.DataFrame({"RS_Ratio": rs_ratio, "RS_Momentum": rs_mom})
1245
 
1246
 
@@ -1251,256 +1255,354 @@ def _quadrant(ratio: float, mom: float) -> str:
1251
  else: return "Improving"
1252
 
1253
 
1254
- QUAD_COLORS = {
1255
- "Leading": "#27ae60",
1256
- "Weakening": "#e67e22",
1257
- "Lagging": "#e74c3c",
1258
- "Improving": "#2980b9",
1259
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1260
 
1261
- QUAD_ICONS = {
1262
- "Leading": "🟒", "Weakening": "🟠", "Lagging": "πŸ”΄", "Improving": "πŸ”΅"
1263
- }
 
 
 
 
 
 
1264
 
1265
 
1266
- # ── per-ticker analysis ────────────────────────────────────────────────────────
1267
 
1268
  def analyze_combined(
1269
- ticker: str,
1270
- daily_df: pd.DataFrame,
1271
- benchmark_daily: pd.DataFrame,
1272
  weekly_window: int = 13,
1273
  daily_window: int = 63,
1274
- require_weakening_only: bool = False,
1275
  ) -> dict | None:
1276
  """
1277
- Returns a dict of signals or None if data is insufficient.
1278
-
1279
- Parameters
1280
- ----------
1281
- ticker : ticker symbol (for labels)
1282
- daily_df : OHLCV DataFrame (daily, from yfinance), indexed by Date
1283
- benchmark_daily : close-price Series for the benchmark (same date range)
1284
- weekly_window : z-score window in weeks (default 13 β‰ˆ 1 quarter)
1285
- daily_window : z-score window in days (default 63 β‰ˆ 1 quarter)
1286
- require_weakening_only : if True, only flag Weakening (not Lagging); closer to Gemini's
1287
- 79.6% win-rate config but far fewer signals
1288
  """
1289
- if daily_df is None or len(daily_df) < max(weekly_window * 5, daily_window) + 5:
 
 
 
1290
  return None
1291
 
1292
- close = daily_df["Close"].astype(float).dropna()
1293
- vol = daily_df["Volume"].astype(float)
1294
 
1295
- # ── quality gate ────────────────────────────────────────────────────────
1296
  current_price = float(close.iloc[-1])
1297
- dvol_20 = float(close.iloc[-20:].mean() * vol.iloc[-20:].mean()) if len(close) >= 20 else 0.0
1298
- quality_ok = current_price >= PRICE_FLOOR and dvol_20 >= MIN_DVOL
1299
-
1300
- # ── large-cap flag (proxy: 20D avg dollar vol β‰₯ $50M) ──────────────────
1301
- large_cap = dvol_20 >= 50_000_000
 
 
 
 
 
 
 
1302
 
1303
- # ── align benchmark ──────────────────────────────────────────────────────
1304
- bm = benchmark_daily.reindex(close.index, method="ffill").astype(float)
1305
  if bm.isna().all():
1306
  return None
1307
 
1308
- # ── DAILY RRG ───────────────────────────────────────────────────────────
1309
- rrg_d = _rrg(close, bm, daily_window)
1310
- dx = rrg_d["RS_Ratio"].diff()
1311
- dy = rrg_d["RS_Momentum"].diff()
1312
  turn_2d = bool(
 
1313
  dx.iloc[-1] > 0 and dy.iloc[-1] > 0 and
1314
  dx.iloc[-2] > 0 and dy.iloc[-2] > 0
1315
  )
1316
 
1317
- # history of daily direction for the trail chart
1318
- daily_trail = rrg_d.tail(10).copy()
1319
- daily_trail["dx"] = dx.tail(10)
1320
- daily_trail["dy"] = dy.tail(10)
1321
-
1322
- # ── WEEKLY RRG ──────────────────────────────────────────────────────────
1323
- close_w = close.resample("W-FRI").last().dropna()
1324
- bm_w = bm.resample("W-FRI").last().reindex(close_w.index, method="ffill")
1325
  if len(close_w) < weekly_window + 2:
1326
  return None
1327
  rrg_w = _rrg(close_w, bm_w, weekly_window)
1328
  w_ratio = float(rrg_w["RS_Ratio"].iloc[-1])
1329
  w_mom = float(rrg_w["RS_Momentum"].iloc[-1])
1330
- weekly_quad = _quadrant(w_ratio, w_mom)
1331
  prev_weekly_quad = _quadrant(
1332
  float(rrg_w["RS_Ratio"].iloc[-2]),
1333
  float(rrg_w["RS_Momentum"].iloc[-2]),
1334
  )
1335
 
1336
- # ── SIGNAL LOGIC ─────────────────────────────────────────────────────────
1337
- # Backtest best configs:
1338
- # "Lagging or Weakening" + 2d turn β†’ 54.5% win (all-cap)
1339
- # "Weakening only" + 2d turn β†’ 60.7% win (all-cap), 79.6% large-cap
1340
- target_quads = ["Weakening"] if require_weakening_only else ["Lagging", "Weakening"]
1341
- rrg_entry = weekly_quad in target_quads and turn_2d
1342
-
1343
- # ── OPTIONAL: combine with oversold RSI / SMA stretch ────────────────────
1344
- sma20 = close.rolling(20).mean()
1345
- delta = close.diff()
1346
- gain = delta.where(delta > 0, 0).ewm(alpha=1/14, adjust=False).mean()
1347
- loss = (-delta.where(delta < 0, 0)).ewm(alpha=1/14, adjust=False).mean()
1348
- rsi = float(100 - 100 / (1 + gain.iloc[-1] / (loss.iloc[-1] + 1e-9)))
1349
  stretch = float((close.iloc[-1] - sma20.iloc[-1]) / (sma20.iloc[-1] + 1e-9) * 100)
1350
 
1351
- # weekly RRG trail (last 6 weeks for the mini chart)
1352
- weekly_trail = rrg_w.tail(7).copy()
 
 
 
1353
 
1354
  return {
1355
- "ticker": ticker,
1356
- "price": round(current_price, 2),
1357
- "dvol_20m": round(dvol_20 / 1e6, 1),
1358
- "quality_ok": quality_ok,
1359
- "large_cap": large_cap,
1360
- "rsi": round(rsi, 1),
1361
- "stretch": round(stretch, 2),
 
 
1362
  # weekly RRG
1363
- "weekly_quad": weekly_quad,
1364
- "prev_weekly_quad":prev_weekly_quad,
1365
- "w_ratio": round(w_ratio, 2),
1366
- "w_mom": round(w_mom, 2),
1367
  # daily RRG
1368
- "d_ratio": round(float(rrg_d["RS_Ratio"].iloc[-1]), 2),
1369
- "d_mom": round(float(rrg_d["RS_Momentum"].iloc[-1]), 2),
1370
- "dx_today": round(float(dx.iloc[-1]), 3),
1371
- "dy_today": round(float(dy.iloc[-1]), 3),
1372
- "turn_2d": turn_2d,
1373
- # combined
1374
- "rrg_entry": rrg_entry,
1375
- "signal_met": rrg_entry and quality_ok,
1376
- # trail data for charts
1377
- "daily_trail": daily_trail,
1378
- "weekly_trail": weekly_trail,
1379
- "rrg_w_full": rrg_w,
1380
- "rrg_d_full": rrg_d,
1381
- "close": close,
 
 
1382
  }
1383
 
1384
 
1385
- # ── mini RRG chart (plotly, matches the Rotation Map tab style) ──────────────
1386
 
1387
  def mini_rrg_chart(result: dict, ticker: str) -> go.Figure:
1388
- wt = result["weekly_trail"]
1389
- dt = result["daily_trail"]
 
1390
 
1391
  fig = go.Figure()
1392
 
1393
- # background quadrant shading
1394
- for x0, x1, y0, y1, color, label in [
1395
- (100, 112, 100, 112, "rgba(39,174,96,0.08)", "Leading"),
1396
- (100, 112, 88, 100, "rgba(230,126,34,0.08)", "Weakening"),
1397
- (88, 100, 88, 100, "rgba(231,76,60,0.08)", "Lagging"),
1398
- (88, 100, 100, 112, "rgba(41,128,185,0.08)", "Improving"),
1399
  ]:
1400
- fig.add_shape(type="rect", x0=x0, x1=x1, y0=y0, y1=y1,
1401
- fillcolor=color, line_width=0, layer="below")
1402
- fig.add_annotation(x=(x0+x1)/2, y=(y0+y1)/2, text=label,
1403
- font=dict(size=10, color=color.replace("0.08","0.35")),
1404
  showarrow=False)
1405
 
1406
- # weekly trail
1407
  fig.add_trace(go.Scatter(
1408
  x=wt["RS_Ratio"].tolist(), y=wt["RS_Momentum"].tolist(),
1409
- mode="lines+markers", name="Weekly RRG (6w)",
1410
- line=dict(color="#1f77b4", width=2),
1411
- marker=dict(size=[6]*len(wt), color=["#1f77b4"]*len(wt)),
1412
  text=[str(d.date()) for d in wt.index],
1413
  ))
1414
- # highlight latest weekly point
 
1415
  fig.add_trace(go.Scatter(
1416
  x=[wt["RS_Ratio"].iloc[-1]], y=[wt["RS_Momentum"].iloc[-1]],
1417
- mode="markers+text", name=f"Weekly now ({result['weekly_quad']})",
1418
- marker=dict(size=14, color=QUAD_COLORS.get(result["weekly_quad"], "gray"),
1419
- symbol="diamond"),
1420
  text=[ticker], textposition="top center", textfont=dict(size=11),
1421
  ))
1422
-
1423
- # daily trail (last 10 bars, smaller)
1424
  fig.add_trace(go.Scatter(
1425
  x=dt["RS_Ratio"].tolist(), y=dt["RS_Momentum"].tolist(),
1426
- mode="lines+markers", name="Daily RRG (10d)",
1427
- line=dict(color="orange", width=1.5, dash="dot"),
1428
- marker=dict(size=5, color="orange"),
1429
  ))
1430
 
1431
- fig.add_hline(y=100, line_dash="dash", line_color="gray", opacity=0.4)
1432
- fig.add_vline(x=100, line_dash="dash", line_color="gray", opacity=0.4)
 
 
 
 
1433
 
1434
  fig.update_layout(
1435
- title=f"{ticker} β€” Dual-TF RRG (weekly: {result['weekly_quad']})",
 
1436
  xaxis_title="RS-Ratio",
1437
  yaxis_title="RS-Momentum",
1438
- height=380,
1439
  template="plotly_white",
1440
- legend=dict(orientation="h", yanchor="bottom", y=1.02),
1441
- margin=dict(l=40, r=20, t=60, b=40),
1442
  )
1443
  return fig
1444
 
1445
 
1446
- # ── single-ticker detailed chart (matplotlib) ────────────────────────────────
1447
 
1448
  def detail_chart(result: dict, ticker: str):
1449
- close = result["close"]
1450
- rrg_d = result["rrg_d_full"]
1451
- dx = rrg_d["RS_Ratio"].diff()
1452
- dy = rrg_d["RS_Momentum"].diff()
1453
-
1454
- fig, axes = plt.subplots(3, 1, figsize=(13, 10), sharex=False,
1455
- gridspec_kw={"height_ratios": [2.5, 1, 1]})
1456
-
1457
- # Panel 1: price + 20-day SMA
1458
  recent = close.iloc[-120:]
1459
- sma20 = close.rolling(20).mean().iloc[-120:]
1460
- axes[0].plot(recent.index, recent.values, color="black", lw=1.5, label="Price")
1461
- axes[0].plot(recent.index, sma20.values, color="blue", lw=0.9, alpha=0.6, label="SMA20")
1462
- axes[0].set_title(f"{ticker} | Price + RRG Signals", fontweight="bold", fontsize=13)
1463
- axes[0].legend(fontsize=9); axes[0].grid(True, alpha=0.15)
1464
 
1465
- # shade last 2 bars green if turn_2d
 
 
 
 
 
1466
  if result["turn_2d"]:
1467
- axes[0].axvspan(recent.index[-2], recent.index[-1],
1468
- color="limegreen", alpha=0.18, label="2-day turn")
1469
 
1470
- # Panel 2: daily RS-Ratio with direction markers
 
 
 
 
 
1471
  rd = rrg_d["RS_Ratio"].iloc[-120:]
1472
- axes[1].plot(rd.index, rd.values, color="#1f77b4", lw=1.5)
1473
- axes[1].axhline(100, color="gray", ls="--", lw=0.8)
1474
  pos_d = dx.iloc[-120:] > 0
1475
- axes[1].fill_between(rd.index, rd.values, 100,
1476
- where=pos_d.values, alpha=0.2, color="green", label="RS-Ratio > yesterday")
1477
- axes[1].fill_between(rd.index, rd.values, 100,
1478
- where=~pos_d.values, alpha=0.15, color="red", label="RS-Ratio < yesterday")
1479
- axes[1].set_title("Daily RS-Ratio (green = gaining vs benchmark, red = losing)", fontsize=10)
1480
- axes[1].legend(fontsize=8); axes[1].grid(True, alpha=0.15)
1481
-
1482
- # Panel 3: daily RS-Momentum
 
 
1483
  rm = rrg_d["RS_Momentum"].iloc[-120:]
1484
  pos_m = dy.iloc[-120:] > 0
1485
- axes[2].plot(rm.index, rm.values, color="purple", lw=1.5)
1486
- axes[2].axhline(100, color="gray", ls="--", lw=0.8)
1487
- axes[2].fill_between(rm.index, rm.values, 100,
1488
- where=pos_m.values, alpha=0.2, color="green", label="Momentum rising")
1489
- axes[2].fill_between(rm.index, rm.values, 100,
1490
- where=~pos_m.values, alpha=0.15, color="red", label="Momentum falling")
1491
- axes[2].set_title("Daily RS-Momentum (green = 2d+ both rising = entry signal)", fontsize=10)
1492
- axes[2].legend(fontsize=8); axes[2].grid(True, alpha=0.15)
 
1493
 
1494
  plt.tight_layout()
1495
  st.pyplot(fig)
1496
  plt.close(fig)
1497
 
1498
 
1499
- # ── cached data helpers ───────────────────────────────────────────────────────
1500
 
1501
  @st.cache_data(ttl=4 * 3600, show_spinner=False)
1502
- def _fetch_benchmark(benchmark_ticker: str) -> pd.Series:
1503
- raw = yf.download(benchmark_ticker, period="5y", interval="1d", progress=False)
1504
  if isinstance(raw.columns, pd.MultiIndex):
1505
  raw.columns = raw.columns.get_level_values(0)
1506
  return raw["Close"].dropna().astype(float)
@@ -1514,273 +1616,296 @@ def _fetch_ticker(ticker: str) -> pd.DataFrame:
1514
  return raw.dropna()
1515
 
1516
 
 
 
1517
  @st.cache_data(ttl=12 * 3600, show_spinner=False)
1518
  def run_combined_scan(
1519
- tickers_tuple: tuple,
1520
  benchmark_ticker: str,
1521
- weekly_window: int,
1522
- daily_window: int,
1523
- require_weakening: bool,
1524
- require_bs: bool,
1525
- rsi_thresh: float,
1526
- stretch_thresh: float,
1527
  require_large_cap: bool,
1528
  ) -> pd.DataFrame:
1529
- bm = _fetch_benchmark(benchmark_ticker)
1530
  results = []
1531
- batch_size = 10
1532
- n_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
1533
- progress = st.progress(0, text="Scanning…")
 
1534
 
1535
- for b, i in enumerate(range(0, len(tickers_tuple), batch_size)):
1536
- batch = list(tickers_tuple[i:i + batch_size])
1537
  try:
1538
- data = yf.download(batch, period="5y", interval="1d",
1539
  group_by="ticker", progress=False, threads=True)
1540
  except Exception:
1541
- progress.progress((b+1)/n_batches)
1542
- continue
1543
 
1544
- for ticker in batch:
1545
  try:
1546
  raw = data[ticker] if isinstance(data.columns, pd.MultiIndex) else data
1547
  df = raw.dropna()
1548
- r = analyze_combined(ticker, df, bm, weekly_window, daily_window,
1549
- require_weakening)
1550
  if r is None or not r["signal_met"]:
1551
  continue
1552
  if require_large_cap and not r["large_cap"]:
1553
  continue
1554
- if require_bs and (r["rsi"] >= rsi_thresh or r["stretch"] >= stretch_thresh):
 
 
 
 
 
 
1555
  continue
 
 
1556
  results.append({
1557
- "Ticker": ticker,
1558
- "Price": r["price"],
1559
- "20D $vol (M)": r["dvol_20m"],
1560
- "Large Cap": "βœ…" if r["large_cap"] else "β€”",
1561
- "RSI": r["rsi"],
1562
- "Stretch %": r["stretch"],
1563
- "Wk Quad": QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"],
1564
- "Prev Wk Quad": QUAD_ICONS.get(r["prev_weekly_quad"],"") + " " + r["prev_weekly_quad"],
1565
- "W RS-Ratio": r["w_ratio"],
1566
- "W RS-Mom": r["w_mom"],
1567
- "D RS-Ratio": r["d_ratio"],
1568
- "D RS-Mom": r["d_mom"],
1569
- "dx today": r["dx_today"],
1570
- "dy today": r["dy_today"],
1571
- "2d Turn": "βœ…" if r["turn_2d"] else "❌",
 
 
1572
  })
1573
  except Exception:
1574
  continue
1575
 
1576
- progress.progress((b+1)/n_batches, text=f"Batch {b+1}/{n_batches}…")
1577
 
1578
- progress.empty()
1579
- return pd.DataFrame(results)
 
 
 
 
 
 
 
 
 
1580
 
1581
 
1582
- # ── MAIN RENDER FUNCTION ──────────────────────────────────────────────────────
1583
 
1584
  def render_combined_scanner():
1585
- st.title("🎯 Combined Scanner β€” RRG + Bottom Scanner")
1586
 
1587
- # ── BACKTEST SUMMARY CALLOUT ─────────────────────────────────────────────
1588
- with st.expander("πŸ“Š Backtest findings (10-year, 2,266 tickers)", expanded=True):
1589
  st.markdown("""
1590
- **How this signal was developed:** Gemini backtested the RRG scanner on the XL? sector
1591
- ETFs and found ~75% win rate at 6 months when the weekly RRG was in Weakening/Lagging
1592
- AND the daily RRG showed 2 consecutive days of positive RS-Ratio AND RS-Momentum movement.
1593
- This tab reproduces and extends that finding on 2,266 individual stocks.
1594
-
1595
- | Signal | n trades | 63D win% | 63D mean | xs t-stat | CAGR | Sharpe |
1596
- |--------|----------|----------|----------|-----------|------|--------|
1597
- | EW Buy & Hold | β€” | β€” | β€” | β€” | **31.8%** | **0.79** |
1598
- | RRG only (Lag/Weak + 2d turn) | 46,528 | 54.5% | +3.1% | βˆ’12.1 | 20.3% | 0.65 |
1599
- | Bottom Scanner only | 30,527 | 55.1% | +3.3% | βˆ’10.2 | 14.0% | 0.51 |
1600
- | **COMBINED (all-cap)** | 15,617 | 55.1% | +4.0% | **βˆ’5.2** | 3.8% | 0.23 |
1601
- | **COMBINED (large-cap β‰₯$50M $vol)** | 4,841 | **60.5%** | +5.8% | **+0.3 βœ“** | 8.1% | 0.37 |
1602
- | **BS + Weakening only (large-cap)** | 228 | **79.6%** | +14.8% | βˆ’1.2 | 5.1% | 0.55 |
1603
-
1604
- **How to use this:** The combined signal is best used as a **high-conviction watchlist**,
1605
- not a systematic buy-everything strategy. The large-cap Weakening+BS combo (79.6% win rate)
1606
- is the closest to Gemini's XL-ETF finding β€” but n=228 over 10 years means only ~20–25
1607
- trades per year, each with CI [73.9%–84.4%] so treat it as a lean, not a certainty.
1608
-
1609
- **Why it still underperforms B&H in CAGR:** the signal is relatively rare (it selects
1610
- ~10–15% of signals vs B&H which holds everything). Idle capital earns 0% in the simulation.
1611
- The real use is as a *filter on top of an existing watchlist*, not a standalone system.
 
 
 
 
 
1612
  """)
1613
 
1614
- # ── SIDEBAR ──────────────────────────────────────────────────────────────
1615
  with st.sidebar:
1616
  st.markdown("---")
1617
- st.header("Combined Scanner")
1618
- benchmark_sym = st.text_input("Benchmark", value="SPY", key="cs_bm").strip().upper()
1619
- weekly_win = st.slider("Weekly z-score window (weeks)", 8, 26, 13, 1, key="cs_ww")
1620
- daily_win = st.slider("Daily z-score window (days)", 21, 126, 63, 1, key="cs_dw")
1621
- st.markdown("**RRG filters**")
1622
- require_wk = st.checkbox("Weakening only (stricter, closer to Gemini's 75%)",
1623
- value=False, key="cs_wk")
1624
- st.markdown("**Bottom Scanner overlay (optional)**")
1625
- use_bs = st.checkbox("Require oversold RSI + stretch", value=False, key="cs_bs")
1626
- rsi_thr = st.slider("RSI threshold", 20, 50, 35, 1, key="cs_rsi") if use_bs else 35
1627
- stretch_thr = st.slider("Stretch %", -10.0, 0.0, -4.0, 0.5, key="cs_str") if use_bs else -4.0
1628
- lc_only = st.checkbox("Large-cap only (β‰₯$50M 20D $vol)", value=False, key="cs_lc")
1629
- st.caption(
1630
- "βœ… Large-cap filter strongly recommended: all-cap COMBINED xs t=-5.2 vs "
1631
- "large-cap xs t=+0.3 (the only marginally positive alpha configuration)"
1632
- )
1633
-
1634
- # ── SINGLE TICKER ────────────────────────────────────────────────────────
1635
  st.subheader("Analyze a single ticker")
1636
- col_a, col_b = st.columns([2, 1])
1637
- single = col_a.text_input("Ticker", value="XLF", key="cs_single").strip().upper()
1638
- analyze_btn = col_b.button("Analyze", key="cs_analyze")
1639
 
1640
- if analyze_btn and single:
1641
  with st.spinner(f"Fetching {single} + {benchmark_sym}…"):
1642
- bm = _fetch_benchmark(benchmark_sym)
1643
- df = _fetch_ticker(single)
1644
 
1645
- r = analyze_combined(single, df, bm, weekly_win, daily_win, require_wk)
1646
 
1647
  if r is None:
1648
- st.error("Not enough data (need 5y of daily bars).")
1649
  else:
1650
- # metric cards
1651
- c1, c2, c3, c4, c5, c6 = st.columns(6)
1652
- c1.metric("Price", f"${r['price']}")
1653
- c2.metric("Quality", "βœ…" if r["quality_ok"] else "❌ Fail")
1654
- c3.metric("Large Cap", "βœ…" if r["large_cap"] else "β€”")
1655
- c4.metric("Weekly Quad",
1656
- QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"])
1657
- c5.metric("2-day Turn", "βœ… YES" if r["turn_2d"] else "❌ NO",
 
 
1658
  delta=f"dx={r['dx_today']:+.2f} dy={r['dy_today']:+.2f}")
1659
- c6.metric("Signal", "🎯 ACTIVE" if r["signal_met"] else "⬜ inactive")
1660
-
1661
- if r["signal_met"]:
1662
- wq = r["weekly_quad"]
1663
- # Show the relevant backtest win rate
1664
- if r["large_cap"] and wq == "Weakening":
1665
- wr_note = "**79.6% historical 63D win rate** (large-cap + Weakening, n=228, CI 73.9–84.4%) β€” closest to Gemini's 75% XL-ETF finding."
1666
- elif r["large_cap"]:
1667
- wr_note = "**60.5% historical 63D win rate** (large-cap COMBINED, n=4,841, CI 59.1–61.9%)."
 
 
 
 
 
1668
  else:
1669
- wr_note = "**55.1% historical 63D win rate** (all-cap COMBINED, n=15,617, CI 54.3–55.9%)."
1670
- st.success(f"🎯 Signal active β€” {wq} quadrant with 2-day daily turn. {wr_note}")
1671
- else:
1672
  reasons = []
1673
- if not r["quality_ok"]: reasons.append("quality gate (price/liquidity)")
1674
- if r["weekly_quad"] not in (["Weakening"] if require_wk else ["Lagging","Weakening"]):
1675
- reasons.append(f"weekly quadrant is {r['weekly_quad']} (need {'Weakening' if require_wk else 'Lagging or Weakening'})")
1676
- if not r["turn_2d"]: reasons.append("no 2-day daily turn yet")
1677
- st.info("Signal not active: " + " | ".join(reasons) if reasons else "Conditions not met.")
 
 
1678
 
1679
- # RSI / stretch context
1680
- st.caption(f"RSI {r['rsi']} | SMA20 stretch {r['stretch']}% | "
1681
- f"20D $vol ${r['dvol_20m']:.1f}M")
1682
-
1683
- # mini RRG chart
1684
  st.plotly_chart(mini_rrg_chart(r, single), use_container_width=True)
1685
-
1686
- # detail chart
1687
  with st.expander("πŸ“ˆ Detailed price + daily RRG panels"):
1688
  detail_chart(r, single)
1689
 
1690
- # ── FULL SCAN ────────────────────────────────────────────────────────────
1691
  st.markdown("---")
1692
  st.subheader("Full universe scan")
1693
 
1694
  universe = get_clean_premarket_universe()
1695
 
 
 
 
1696
  st.caption(
1697
- f"Universe: {len(universe)} tickers. Each ticker fetches 5y of daily data β€” "
1698
- "this scan takes 5–10 minutes on first run, then cached 12 hours. "
1699
- "The large-cap filter (sidebar) cuts runtime ~75% and gives the best signal quality."
1700
  )
1701
 
1702
- if st.button("πŸ” Run Combined Scan", key="cs_scan"):
1703
  if not universe:
1704
  st.error("No tickers loaded.")
1705
  else:
1706
- results_df = run_combined_scan(
1707
  tuple(universe), benchmark_sym, weekly_win, daily_win,
1708
- require_wk, use_bs, rsi_thr, stretch_thr, lc_only,
1709
  )
1710
- st.session_state["cs_results"] = results_df
1711
 
1712
- if "cs_results" in st.session_state:
1713
- df = st.session_state["cs_results"]
1714
- if df.empty:
1715
- st.info("No tickers cleared all filters. Try relaxing the settings.")
1716
  else:
1717
- st.success(f"{len(df)} ticker(s) met all conditions.")
1718
-
1719
- # tag with expected win rate
1720
- def expected_wr(row):
1721
- lc = row.get("Large Cap") == "βœ…"
1722
- wk = "Weakening" in str(row.get("Wk Quad",""))
1723
- if lc and wk:
1724
- return "~79.6% (LC+Weak)"
1725
- elif lc:
1726
- return "~60.5% (LC+Combo)"
1727
- else:
1728
- return "~55.1% (All-cap)"
1729
- df["Expected Win%"] = df.apply(expected_wr, axis=1)
 
1730
 
1731
  st.dataframe(
1732
- df.sort_values("20D $vol (M)", ascending=False).reset_index(drop=True),
1733
  use_container_width=True, hide_index=True,
1734
  )
1735
 
1736
- # chart picker
1737
- pick = st.selectbox("View RRG chart for:", df["Ticker"].tolist(), key="cs_pick")
1738
  if pick:
1739
  with st.spinner(f"Fetching {pick}…"):
1740
  bm = _fetch_benchmark(benchmark_sym)
1741
  dfp = _fetch_ticker(pick)
1742
- rp = analyze_combined(pick, dfp, bm, weekly_win, daily_win, require_wk)
1743
  if rp:
 
 
 
 
1744
  st.plotly_chart(mini_rrg_chart(rp, pick), use_container_width=True)
1745
  with st.expander("πŸ“ˆ Detailed panels"):
1746
  detail_chart(rp, pick)
1747
 
1748
- # ── HOW TO USE THIS WITH THE ROTATION MAP ────────────────────────────────
1749
  st.markdown("---")
1750
- with st.expander("πŸ’‘ How to use Combined Scanner with the Rotation Map tab"):
1751
- st.markdown("""
1752
- **Workflow (the 'watchlist funnel' approach):**
1753
-
1754
- 1. **Rotation Map tab** β†’ set your sector ETF basket β†’ note which ETFs sit in **Weakening**
1755
- with their tail starting to curl toward Improving. These are the *sectors* with
1756
- potential rotation setups.
1757
-
1758
- 2. **Bottom Scanner tab** β†’ run a scan of the holdings within those sectors. Hits
1759
- give you a shortlist of oversold individual names inside a potentially-turning sector.
1760
-
1761
- 3. **Combined Scanner tab** β†’ paste those tickers into single-ticker mode here and
1762
- check the dual-TF RRG. You're looking for:
1763
- - Weekly: **Weakening** (strong but losing steam) or **Lagging** (weak but starting to turn)
1764
- - Daily: **2 consecutive days with positive dx AND dy** (the early rotation signal)
1765
-
1766
- 4. **Check the Rotation Map's own trail for those stocks** (add them to the custom ticker
1767
- list in Rotation Map) β€” does the tail show the same early curl from Weakening β†’ Improving?
1768
-
1769
- **Why this combination matters:**
1770
- - Bottom Scanner alone: xs t-stat β‰ˆ βˆ’10 (signal underperforms peers)
1771
- - RRG alone: xs t-stat β‰ˆ βˆ’12 (worse β€” very broad signal)
1772
- - Combined large-cap: xs t-stat β‰ˆ **+0.3** (the only config with marginal positive alpha)
1773
- - The intersection removes most of the "falling knives" the BS alone catches
1774
- - Adding the large-cap / Weakening filter brings win rates to 60–79%, matching
1775
- Gemini's XL-ETF observation on individual stocks too
1776
-
1777
- **Honest caveat:** even at 79.6% (large-cap Weakening+BS, n=228 over 10 years),
1778
- the 95% CI is [73.9%–84.4%]. The equity-curve CAGR (5.1%) still underperforms
1779
- the equal-weight benchmark (31.8%) because active holding periods are sparse.
1780
- Use as a filter, not a standalone system.
1781
  """)
1782
 
1783
-
1784
  # =========================================================
1785
  # MAIN LAYOUT
1786
  # =========================================================
 
1226
  # TAB 6: COMBINED SCANNER (RRG + Bottom Scanner)
1227
  # =========================================================
1228
 
1229
+ # Quality / liquidity constants
1230
+ PRICE_FLOOR = 5.0
1231
+ MIN_DVOL = 1_000_000
1232
+ LARGE_CAP_DVOL = 50_000_000
1233
+ DEDUP_DAYS = 63
1234
+
1235
+ PRICE_FLOOR = 5.0
1236
+ MIN_DVOL = 1_000_000 # $1M 20-day avg dollar volume β€” quality gate
1237
+ LARGE_CAP_DVOL = 50_000_000 # $50M for large-cap classification
1238
+ DEDUP_DAYS = 63
1239
+
1240
+ # ── RRG maths ─────────────────────────────────────────────────────────────────
1241
+
1242
+ def _rrg(price: pd.Series, benchmark: pd.Series, window: int) -> pd.DataFrame:
1243
+ rs = price / (benchmark + 1e-9)
1244
+ rs_ratio = 100 + ((rs - rs.rolling(window).mean()) /
1245
+ (rs.rolling(window).std() + 1e-9)) * 5
1246
+ rs_mom = 100 + ((rs_ratio - rs_ratio.rolling(window).mean()) /
1247
+ (rs_ratio.rolling(window).std() + 1e-9)) * 5
1248
  return pd.DataFrame({"RS_Ratio": rs_ratio, "RS_Momentum": rs_mom})
1249
 
1250
 
 
1255
  else: return "Improving"
1256
 
1257
 
1258
+ QUAD_COLORS = {"Leading": "#27ae60", "Weakening": "#e67e22",
1259
+ "Lagging": "#e74c3c", "Improving": "#2980b9"}
1260
+ QUAD_ICONS = {"Leading": "🟒", "Weakening": "🟠",
1261
+ "Lagging": "πŸ”΄", "Improving": "πŸ”΅"}
1262
+
1263
+
1264
+ # ── Signal tier classification (core backtest findings) ───────────────────────
1265
+
1266
+ def classify_tier(rsi: float, stretch: float, pct_from_52w: float) -> dict:
1267
+ """
1268
+ Returns tier (1=best / 2=good / 3=weak), estimated win%, alpha quality note,
1269
+ and a display label β€” all calibrated from the 10-year backtest.
1270
+
1271
+ Tiers are only meaningful within LC Weakening + 2d turn entries.
1272
+ pct_from_52w: percentage below 52-week high (negative = below, 0 = at ATH).
1273
+ """
1274
+
1275
+ # ── Step 1: RSI Γ— Stretch grid win rate ──────────────────────────────────
1276
+ if rsi < 40:
1277
+ base_win = 75.3
1278
+ rsi_note = "Deep oversold RSI (<40) β€” strongest RSI bucket"
1279
+ tier = 1
1280
+ elif rsi < 55 and stretch < -10:
1281
+ base_win = 80.0
1282
+ rsi_note = "Extreme stretch + moderate RSI ⚠️ n=50 in backtest, treat carefully"
1283
+ tier = 1
1284
+ elif rsi < 55 and stretch < -5:
1285
+ base_win = 71.0
1286
+ rsi_note = "Extended below SMA20 β€” solid historical edge"
1287
+ tier = 1
1288
+ elif rsi < 55 and 3 <= stretch < 10:
1289
+ base_win = 67.1
1290
+ rsi_note = "RSI 40-55 + moderate positive stretch β€” strong bucket"
1291
+ tier = 1
1292
+ elif rsi < 55:
1293
+ base_win = 60.5
1294
+ rsi_note = "RSI 40-55, mild stretch"
1295
+ tier = 2
1296
+ elif rsi < 65 and stretch >= 10:
1297
+ base_win = 67.6
1298
+ rsi_note = "RSI 55-65 + extended stretch >10% β€” high momentum"
1299
+ tier = 1
1300
+ elif rsi < 65 and 3 <= stretch < 10:
1301
+ base_win = 64.7
1302
+ rsi_note = "β˜… RSI 55-65 + stretch 3-10% β€” best alpha quality (xs_t -0.48)"
1303
+ tier = 2
1304
+ elif rsi < 65:
1305
+ base_win = 59.7
1306
+ rsi_note = "RSI 55-65, mild stretch"
1307
+ tier = 2
1308
+ elif rsi < 80 and stretch >= 10:
1309
+ base_win = 67.9
1310
+ rsi_note = "β˜…β˜… RSI 65-80 + stretch >10% β€” only config with positive xs_t (+1.54)"
1311
+ tier = 1
1312
+ elif rsi < 80 and 3 <= stretch < 10:
1313
+ base_win = 53.9
1314
+ rsi_note = "βœ— RSI 65-75 + stretch 3-10% β€” weakest bucket, near-random"
1315
+ tier = 3
1316
+ elif rsi < 80:
1317
+ base_win = 57.3
1318
+ rsi_note = "RSI 65-80, mild stretch β€” below average"
1319
+ tier = 3
1320
+ else:
1321
+ base_win = 60.8
1322
+ rsi_note = "RSI >80 β€” overbought territory"
1323
+ tier = 2
1324
+
1325
+ # ── Step 2: 52-week high proximity adjustment ─────────────────────────────
1326
+ if pct_from_52w > -5:
1327
+ ath_adj = -4.0 # near ATH historically weak (57% win rate)
1328
+ ath_note = f"⚠️ Within 5% of 52w high β€” historically {57:.0f}% win (near-random)"
1329
+ tier = max(tier, 2) # cannot be tier 1 near ATH
1330
+ elif pct_from_52w > -10:
1331
+ ath_adj = -2.0
1332
+ ath_note = f"{abs(pct_from_52w):.0f}% below 52w high β€” slightly below sweet spot"
1333
+ elif pct_from_52w > -20:
1334
+ ath_adj = +2.0 # 10-20% below ATH: 66.3% sweet spot
1335
+ ath_note = f"βœ… {abs(pct_from_52w):.0f}% below 52w high β€” historical sweet spot (66%)"
1336
+ elif pct_from_52w > -35:
1337
+ ath_adj = +4.0 # 20-35% below ATH: 69.3%
1338
+ ath_note = f"βœ… {abs(pct_from_52w):.0f}% below 52w high β€” strongest ATH bucket (69%)"
1339
+ else:
1340
+ ath_adj = +3.0 # >35% below: 68.5%
1341
+ ath_note = f"{abs(pct_from_52w):.0f}% below 52w high β€” deep drawdown recovery zone"
1342
+
1343
+ estimated_win = round(base_win + ath_adj, 1)
1344
+
1345
+ # Tier label
1346
+ tier_label = {1: "πŸ† Tier 1", 2: "βœ… Tier 2", 3: "⚠️ Tier 3"}.get(tier, "β€”")
1347
 
1348
+ return {
1349
+ "tier": tier,
1350
+ "tier_label": tier_label,
1351
+ "estimated_win": estimated_win,
1352
+ "rsi_note": rsi_note,
1353
+ "ath_note": ath_note,
1354
+ "base_win": base_win,
1355
+ "ath_adj": ath_adj,
1356
+ }
1357
 
1358
 
1359
+ # ── Per-ticker analysis ────────────────────────────────────────────────────────
1360
 
1361
  def analyze_combined(
1362
+ ticker: str,
1363
+ daily_df: pd.DataFrame,
1364
+ benchmark_bm: pd.Series,
1365
  weekly_window: int = 13,
1366
  daily_window: int = 63,
1367
+ target_quads: list = None,
1368
  ) -> dict | None:
1369
  """
1370
+ Returns signal dict or None if data insufficient.
1371
+ target_quads: list of weekly quadrants to accept, default ['Weakening'].
 
 
 
 
 
 
 
 
 
1372
  """
1373
+ if target_quads is None:
1374
+ target_quads = ["Weakening"]
1375
+
1376
+ if daily_df is None or len(daily_df) < max(weekly_window * 5, daily_window, 252) + 5:
1377
  return None
1378
 
1379
+ close = daily_df["Close"].astype(float).dropna()
1380
+ volume = daily_df["Volume"].astype(float)
1381
 
1382
+ # ── Quality gate ─────────────────────────────────────────────────────────
1383
  current_price = float(close.iloc[-1])
1384
+ dvol_20 = float(close.iloc[-20:].mean() * volume.iloc[-20:].mean()) if len(close) >= 20 else 0.0
1385
+ quality_ok = current_price >= PRICE_FLOOR and dvol_20 >= MIN_DVOL
1386
+ large_cap = dvol_20 >= LARGE_CAP_DVOL
1387
+
1388
+ # ── 52-week high proximity ────────────────────────────────────────────────
1389
+ if len(close) >= 252:
1390
+ high_52w = float(close.iloc[-252:].max())
1391
+ elif len(close) >= 126:
1392
+ high_52w = float(close.iloc[-126:].max())
1393
+ else:
1394
+ high_52w = float(close.max())
1395
+ pct_from_52w = (current_price / high_52w - 1) * 100 # 0 = at ATH, -20 = 20% below
1396
 
1397
+ # ── Benchmark alignment ───────────────────────────────────────────────────
1398
+ bm = benchmark_bm.reindex(close.index, method="ffill").astype(float)
1399
  if bm.isna().all():
1400
  return None
1401
 
1402
+ # ── Daily RRG ─────────────────────────────────────────────────────────────
1403
+ rrg_d = _rrg(close, bm, daily_window)
1404
+ dx = rrg_d["RS_Ratio"].diff()
1405
+ dy = rrg_d["RS_Momentum"].diff()
1406
  turn_2d = bool(
1407
+ len(dx) >= 2 and
1408
  dx.iloc[-1] > 0 and dy.iloc[-1] > 0 and
1409
  dx.iloc[-2] > 0 and dy.iloc[-2] > 0
1410
  )
1411
 
1412
+ # ── Weekly RRG ────────────────────────────────────────────────────────────
1413
+ close_w = close.resample("W-FRI").last().dropna()
1414
+ bm_w = bm.resample("W-FRI").last().reindex(close_w.index, method="ffill")
 
 
 
 
 
1415
  if len(close_w) < weekly_window + 2:
1416
  return None
1417
  rrg_w = _rrg(close_w, bm_w, weekly_window)
1418
  w_ratio = float(rrg_w["RS_Ratio"].iloc[-1])
1419
  w_mom = float(rrg_w["RS_Momentum"].iloc[-1])
1420
+ weekly_quad = _quadrant(w_ratio, w_mom)
1421
  prev_weekly_quad = _quadrant(
1422
  float(rrg_w["RS_Ratio"].iloc[-2]),
1423
  float(rrg_w["RS_Momentum"].iloc[-2]),
1424
  )
1425
 
1426
+ # ── RSI + Stretch ─────────────────────────────────────────────────────────
1427
+ sma20 = close.rolling(20).mean()
1428
+ delta = close.diff()
1429
+ gain = delta.where(delta > 0, 0).ewm(alpha=1/14, adjust=False).mean()
1430
+ loss = (-delta.where(delta < 0, 0)).ewm(alpha=1/14, adjust=False).mean()
1431
+ rsi = float(100 - 100 / (1 + gain.iloc[-1] / (loss.iloc[-1] + 1e-9)))
 
 
 
 
 
 
 
1432
  stretch = float((close.iloc[-1] - sma20.iloc[-1]) / (sma20.iloc[-1] + 1e-9) * 100)
1433
 
1434
+ # ── Signal classification ─────────────────────────────────────────────────
1435
+ rrg_entry = weekly_quad in target_quads and turn_2d
1436
+ signal_met = rrg_entry and quality_ok
1437
+
1438
+ tier_info = classify_tier(rsi, stretch, pct_from_52w) if signal_met else None
1439
 
1440
  return {
1441
+ "ticker": ticker,
1442
+ "price": round(current_price, 2),
1443
+ "dvol_20m": round(dvol_20 / 1e6, 1),
1444
+ "quality_ok": quality_ok,
1445
+ "large_cap": large_cap,
1446
+ "rsi": round(rsi, 1),
1447
+ "stretch": round(stretch, 2),
1448
+ "pct_from_52w": round(pct_from_52w, 1),
1449
+ "high_52w": round(high_52w, 2),
1450
  # weekly RRG
1451
+ "weekly_quad": weekly_quad,
1452
+ "prev_weekly_quad": prev_weekly_quad,
1453
+ "w_ratio": round(w_ratio, 2),
1454
+ "w_mom": round(w_mom, 2),
1455
  # daily RRG
1456
+ "d_ratio": round(float(rrg_d["RS_Ratio"].iloc[-1]), 2),
1457
+ "d_mom": round(float(rrg_d["RS_Momentum"].iloc[-1]), 2),
1458
+ "dx_today": round(float(dx.iloc[-1]), 3),
1459
+ "dy_today": round(float(dy.iloc[-1]), 3),
1460
+ "turn_2d": turn_2d,
1461
+ # signal
1462
+ "rrg_entry": rrg_entry,
1463
+ "signal_met": signal_met,
1464
+ "tier_info": tier_info,
1465
+ # chart data
1466
+ "daily_trail": rrg_d.tail(10).copy(),
1467
+ "weekly_trail": rrg_w.tail(7).copy(),
1468
+ "rrg_w_full": rrg_w,
1469
+ "rrg_d_full": rrg_d,
1470
+ "close": close,
1471
+ "sma20": sma20,
1472
  }
1473
 
1474
 
1475
+ # ── Mini RRG chart (Plotly) ───────────────────────────────────────────────────
1476
 
1477
  def mini_rrg_chart(result: dict, ticker: str) -> go.Figure:
1478
+ wt = result["weekly_trail"]
1479
+ dt = result["daily_trail"]
1480
+ ti = result.get("tier_info") or {}
1481
 
1482
  fig = go.Figure()
1483
 
1484
+ # Quadrant shading
1485
+ for x0,x1,y0,y1,color,label in [
1486
+ (100,112,100,112,"rgba(39,174,96,0.08)", "Leading"),
1487
+ (100,112,88, 100,"rgba(230,126,34,0.08)", "Weakening"),
1488
+ (88, 100,88, 100,"rgba(231,76,60,0.08)", "Lagging"),
1489
+ (88, 100,100,112,"rgba(41,128,185,0.08)", "Improving"),
1490
  ]:
1491
+ fig.add_shape(type="rect",x0=x0,x1=x1,y0=y0,y1=y1,
1492
+ fillcolor=color,line_width=0,layer="below")
1493
+ fig.add_annotation(x=(x0+x1)/2,y=(y0+y1)/2,text=label,
1494
+ font=dict(size=10,color=color.replace("0.08","0.4")),
1495
  showarrow=False)
1496
 
1497
+ # Weekly trail
1498
  fig.add_trace(go.Scatter(
1499
  x=wt["RS_Ratio"].tolist(), y=wt["RS_Momentum"].tolist(),
1500
+ mode="lines+markers", name="Weekly (6w)",
1501
+ line=dict(color="#1f77b4",width=2),
1502
+ marker=dict(size=6,color="#1f77b4"),
1503
  text=[str(d.date()) for d in wt.index],
1504
  ))
1505
+ # Latest weekly point
1506
+ wq_color = QUAD_COLORS.get(result["weekly_quad"], "gray")
1507
  fig.add_trace(go.Scatter(
1508
  x=[wt["RS_Ratio"].iloc[-1]], y=[wt["RS_Momentum"].iloc[-1]],
1509
+ mode="markers+text",
1510
+ name=f"Weekly now ({result['weekly_quad']})",
1511
+ marker=dict(size=14, color=wq_color, symbol="diamond"),
1512
  text=[ticker], textposition="top center", textfont=dict(size=11),
1513
  ))
1514
+ # Daily trail
 
1515
  fig.add_trace(go.Scatter(
1516
  x=dt["RS_Ratio"].tolist(), y=dt["RS_Momentum"].tolist(),
1517
+ mode="lines+markers", name="Daily (10d)",
1518
+ line=dict(color="orange",width=1.5,dash="dot"),
1519
+ marker=dict(size=5,color="orange"),
1520
  ))
1521
 
1522
+ fig.add_hline(y=100,line_dash="dash",line_color="gray",opacity=0.4)
1523
+ fig.add_vline(x=100,line_dash="dash",line_color="gray",opacity=0.4)
1524
+
1525
+ tier_label = ti.get("tier_label","")
1526
+ est_win = ti.get("estimated_win","")
1527
+ subtitle = f"{tier_label} β€’ Est. {est_win}% win β€’ {result['pct_from_52w']:.1f}% from 52w high" if ti else ""
1528
 
1529
  fig.update_layout(
1530
+ title=f"{ticker} β€” Dual-TF RRG (weekly: {result['weekly_quad']})<br>"
1531
+ f"<sup>{subtitle}</sup>",
1532
  xaxis_title="RS-Ratio",
1533
  yaxis_title="RS-Momentum",
1534
+ height=390,
1535
  template="plotly_white",
1536
+ legend=dict(orientation="h",yanchor="bottom",y=1.02),
1537
+ margin=dict(l=40,r=20,t=80,b=40),
1538
  )
1539
  return fig
1540
 
1541
 
1542
+ # ── Detail chart (Matplotlib) ─────────────────────────────────────────────────
1543
 
1544
  def detail_chart(result: dict, ticker: str):
1545
+ close = result["close"]
1546
+ rrg_d = result["rrg_d_full"]
1547
+ sma20 = result["sma20"]
1548
+ dx = rrg_d["RS_Ratio"].diff()
1549
+ dy = rrg_d["RS_Momentum"].diff()
 
 
 
 
1550
  recent = close.iloc[-120:]
1551
+ sma_r = sma20.iloc[-120:]
1552
+
1553
+ fig, axes = plt.subplots(3,1,figsize=(13,10),sharex=False,
1554
+ gridspec_kw={"height_ratios":[2.5,1,1]})
 
1555
 
1556
+ # Price
1557
+ axes[0].plot(recent.index,recent.values,color="black",lw=1.5,label="Price")
1558
+ axes[0].plot(recent.index,sma_r.values,color="blue",lw=0.9,alpha=0.6,label="SMA20")
1559
+ if result.get("high_52w"):
1560
+ axes[0].axhline(result["high_52w"],color="gold",ls="--",lw=1,
1561
+ alpha=0.7,label=f"52w high ${result['high_52w']:.2f}")
1562
  if result["turn_2d"]:
1563
+ axes[0].axvspan(recent.index[-2],recent.index[-1],
1564
+ color="limegreen",alpha=0.18,label="2-day turn")
1565
 
1566
+ ti = result.get("tier_info") or {}
1567
+ title_extra = f" | {ti.get('tier_label','')} Est. {ti.get('estimated_win','')}% win {result['pct_from_52w']:.1f}% from 52w high" if ti else ""
1568
+ axes[0].set_title(f"{ticker}{title_extra}",fontweight="bold",fontsize=12)
1569
+ axes[0].legend(fontsize=8); axes[0].grid(True,alpha=0.15)
1570
+
1571
+ # Daily RS-Ratio
1572
  rd = rrg_d["RS_Ratio"].iloc[-120:]
 
 
1573
  pos_d = dx.iloc[-120:] > 0
1574
+ axes[1].plot(rd.index,rd.values,color="#1f77b4",lw=1.5)
1575
+ axes[1].axhline(100,color="gray",ls="--",lw=0.8)
1576
+ axes[1].fill_between(rd.index,rd.values,100,where=pos_d.values,
1577
+ alpha=0.2,color="green",label="Gaining vs RSP")
1578
+ axes[1].fill_between(rd.index,rd.values,100,where=~pos_d.values,
1579
+ alpha=0.15,color="red",label="Losing vs RSP")
1580
+ axes[1].set_title("Daily RS-Ratio vs RSP",fontsize=10)
1581
+ axes[1].legend(fontsize=8); axes[1].grid(True,alpha=0.15)
1582
+
1583
+ # Daily RS-Momentum
1584
  rm = rrg_d["RS_Momentum"].iloc[-120:]
1585
  pos_m = dy.iloc[-120:] > 0
1586
+ axes[2].plot(rm.index,rm.values,color="purple",lw=1.5)
1587
+ axes[2].axhline(100,color="gray",ls="--",lw=0.8)
1588
+ axes[2].fill_between(rm.index,rm.values,100,where=pos_m.values,
1589
+ alpha=0.2,color="green",label="Momentum rising")
1590
+ axes[2].fill_between(rm.index,rm.values,100,where=~pos_m.values,
1591
+ alpha=0.15,color="red",label="Momentum falling")
1592
+ axes[2].set_title("Daily RS-Momentum (entry = 2 consecutive green bars both panels)",
1593
+ fontsize=10)
1594
+ axes[2].legend(fontsize=8); axes[2].grid(True,alpha=0.15)
1595
 
1596
  plt.tight_layout()
1597
  st.pyplot(fig)
1598
  plt.close(fig)
1599
 
1600
 
1601
+ # ── Data fetchers ─────────────────────────────────────────────────────────────
1602
 
1603
  @st.cache_data(ttl=4 * 3600, show_spinner=False)
1604
+ def _fetch_benchmark(bm_ticker: str) -> pd.Series:
1605
+ raw = yf.download(bm_ticker, period="5y", interval="1d", progress=False)
1606
  if isinstance(raw.columns, pd.MultiIndex):
1607
  raw.columns = raw.columns.get_level_values(0)
1608
  return raw["Close"].dropna().astype(float)
 
1616
  return raw.dropna()
1617
 
1618
 
1619
+ # ── Full universe scan ────────────────────────────────────────────────────────
1620
+
1621
  @st.cache_data(ttl=12 * 3600, show_spinner=False)
1622
  def run_combined_scan(
1623
+ tickers_tuple: tuple,
1624
  benchmark_ticker: str,
1625
+ weekly_window: int,
1626
+ daily_window: int,
1627
+ target_quads: tuple,
1628
+ min_tier: int,
 
 
1629
  require_large_cap: bool,
1630
  ) -> pd.DataFrame:
1631
+ bm = _fetch_benchmark(benchmark_ticker)
1632
  results = []
1633
+ seen = {}
1634
+ batch = 10
1635
+ n_batch = max(1,(len(tickers_tuple)+batch-1)//batch)
1636
+ prog = st.progress(0, text="Scanning…")
1637
 
1638
+ for b, i in enumerate(range(0, len(tickers_tuple), batch)):
1639
+ batch_t = list(tickers_tuple[i:i+batch])
1640
  try:
1641
+ data = yf.download(batch_t, period="5y", interval="1d",
1642
  group_by="ticker", progress=False, threads=True)
1643
  except Exception:
1644
+ prog.progress((b+1)/n_batch); continue
 
1645
 
1646
+ for ticker in batch_t:
1647
  try:
1648
  raw = data[ticker] if isinstance(data.columns, pd.MultiIndex) else data
1649
  df = raw.dropna()
1650
+ r = analyze_combined(ticker, df, bm, weekly_window,
1651
+ daily_window, list(target_quads))
1652
  if r is None or not r["signal_met"]:
1653
  continue
1654
  if require_large_cap and not r["large_cap"]:
1655
  continue
1656
+ ti = r["tier_info"]
1657
+ if ti and ti["tier"] > min_tier:
1658
+ continue
1659
+ # dedup
1660
+ today = pd.Timestamp.now().normalize()
1661
+ last = seen.get(ticker)
1662
+ if last and (today - last).days < DEDUP_DAYS:
1663
  continue
1664
+ seen[ticker] = today
1665
+
1666
  results.append({
1667
+ "Tier": ti["tier_label"] if ti else "β€”",
1668
+ "Est. Win%": ti["estimated_win"] if ti else "β€”",
1669
+ "Ticker": ticker,
1670
+ "Price": r["price"],
1671
+ "$Vol 20D (M)": r["dvol_20m"],
1672
+ "Large Cap": "βœ…" if r["large_cap"] else "β€”",
1673
+ "RSI": r["rsi"],
1674
+ "Stretch %": r["stretch"],
1675
+ "% from 52w H": r["pct_from_52w"],
1676
+ "Wk Quad": QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"],
1677
+ "Prev Wk Quad": QUAD_ICONS.get(r["prev_weekly_quad"],"") + " " + r["prev_weekly_quad"],
1678
+ "W RS-Ratio": r["w_ratio"],
1679
+ "W RS-Mom": r["w_mom"],
1680
+ "dx today": r["dx_today"],
1681
+ "dy today": r["dy_today"],
1682
+ "RSI note": ti["rsi_note"] if ti else "",
1683
+ "ATH note": ti["ath_note"] if ti else "",
1684
  })
1685
  except Exception:
1686
  continue
1687
 
1688
+ prog.progress((b+1)/n_batch, text=f"Batch {b+1}/{n_batch}…")
1689
 
1690
+ prog.empty()
1691
+ if not results:
1692
+ return pd.DataFrame()
1693
+
1694
+ df_out = pd.DataFrame(results)
1695
+ # Sort: tier asc, est win% desc
1696
+ df_out["_tier_num"] = df_out["Tier"].map({"πŸ† Tier 1":1,"βœ… Tier 2":2,"⚠️ Tier 3":3}).fillna(9)
1697
+ df_out = (df_out.sort_values(["_tier_num","Est. Win%"], ascending=[True,False])
1698
+ .drop(columns=["_tier_num"])
1699
+ .reset_index(drop=True))
1700
+ return df_out
1701
 
1702
 
1703
+ # ── MAIN RENDER ───────────────────────────────────────────────────────────────
1704
 
1705
  def render_combined_scanner():
1706
+ st.title("🎯 Combined Scanner v2 β€” RRG + Backtest-Calibrated Tiers")
1707
 
1708
+ # ── Backtest findings callout ─────────────────────────────────────────────
1709
+ with st.expander("πŸ“Š Backtest findings β€” what actually drives the win rate", expanded=False):
1710
  st.markdown("""
1711
+ **10-year backtest, 3,800 large-cap Weakening + 2d turn entries (2016–2026)**
1712
+
1713
+ The blanket "79.6% win rate" figure from v1 required the Bottom Scanner oversold filter
1714
+ (RSI<35). Today's scan hits typically have RSI 55-65 β€” the correct win rate for those
1715
+ is **61-65%**, not 79.6%. This version shows the calibrated figure per entry.
1716
+
1717
+ | Configuration | Win% | Mean 63D | xs_t | Notes |
1718
+ |---|---|---|---|---|
1719
+ | RSI <40, any stretch | **75.3%** | +10.2% | β€” | Strongest RSI bucket |
1720
+ | Stretch <-10% | **80%** | +20.1% | β€” | n=50 only, treat carefully |
1721
+ | Stretch -10 to -5% | **71%** | +8.0% | β€” | Extended below SMA20 |
1722
+ | RSI 40-55 + stretch 3-10% | **67.1%** | +8.3% | -2.92 | Strong bucket |
1723
+ | RSI 55-65 + stretch >10% | **67.6%** | +11.4% | -1.23 | High momentum |
1724
+ | RSI 65-80 + stretch >10% | **67.9%** | +13.1% | **+1.54** | β˜…β˜… Only positive xs_t |
1725
+ | RSI 55-65 + stretch 3-10% | **64.7%** | +7.7% | **-0.48** | β˜… Best alpha quality |
1726
+ | Within 5% of 52w high | 57.3% | +2.5% | β€” | ⚠️ Near-random, avoid |
1727
+ | RSI 65-75 + stretch 3-10% | 53.9% | +1.2% | -5.04 | βœ— Weakest bucket |
1728
+
1729
+ **52-week high proximity is the biggest single filter:**
1730
+ - Within 5% of ATH β†’ 57% win (near-random regardless of RSI/stretch)
1731
+ - 10-20% below ATH β†’ 66% win
1732
+ - 20-35% below ATH β†’ 69% win ← sweet spot
1733
+
1734
+ **Default benchmark changed from SPY β†’ RSP (equal-weight S&P 500).**
1735
+ SPY's 35% mega-cap weighting inflates relative strength for non-tech names,
1736
+ producing Weakening signals that are actually just "not as good as Nvidia."
1737
+ RSP asks the correct question: is this stock beating the average company?
1738
  """)
1739
 
1740
+ # ── Sidebar ───────────────────────────────────────────────────────────────
1741
  with st.sidebar:
1742
  st.markdown("---")
1743
+ st.header("Combined Scanner v2")
1744
+ benchmark_sym = st.text_input("Benchmark", value="RSP", key="cs2_bm").strip().upper()
1745
+ st.caption("RSP = equal-weight S&P 500 β€” recommended over SPY (see backtest notes)")
1746
+ weekly_win = st.slider("Weekly z-score window (weeks)", 8, 26, 13, 1, key="cs2_ww")
1747
+ daily_win = st.slider("Daily z-score window (days)", 21, 126, 63, 1, key="cs2_dw")
1748
+ st.markdown("**Quadrant filter**")
1749
+ weak_only = st.checkbox("Weakening only (excludes Lagging)", value=True, key="cs2_wk")
1750
+ target_quads = ("Weakening",) if weak_only else ("Weakening","Lagging")
1751
+ st.markdown("**Quality filters**")
1752
+ min_tier = st.radio("Minimum tier", [1, 2, 3],
1753
+ format_func=lambda x: {1:"πŸ† Tier 1 only (β‰₯67% est.)",
1754
+ 2:"βœ… Tier 1+2 (β‰₯60%)",
1755
+ 3:"⚠️ All tiers"}[x],
1756
+ index=1, key="cs2_tier")
1757
+ require_lc = st.checkbox("Large cap only (β‰₯$50M 20D $vol)", value=True, key="cs2_lc")
1758
+ st.caption("Large-cap filter strongly recommended: all-cap xs_t = -5 vs large-cap xs_t closer to 0")
1759
+
1760
+ # ── Single ticker ─────────────────────────────────────────────────────────
1761
  st.subheader("Analyze a single ticker")
1762
+ col_a, col_b = st.columns([2,1])
1763
+ single = col_a.text_input("Ticker", value="AAPL", key="cs2_single").strip().upper()
1764
+ go_btn = col_b.button("Analyze", key="cs2_analyze")
1765
 
1766
+ if go_btn and single:
1767
  with st.spinner(f"Fetching {single} + {benchmark_sym}…"):
1768
+ bm = _fetch_benchmark(benchmark_sym)
1769
+ df = _fetch_ticker(single)
1770
 
1771
+ r = analyze_combined(single, df, bm, weekly_win, daily_win, list(target_quads))
1772
 
1773
  if r is None:
1774
+ st.error("Not enough data (need β‰₯5y daily bars for 52w high calculation).")
1775
  else:
1776
+ ti = r.get("tier_info")
1777
+
1778
+ # Metric row
1779
+ c1,c2,c3,c4,c5,c6 = st.columns(6)
1780
+ c1.metric("Price", f"${r['price']}")
1781
+ c2.metric("RSI", f"{r['rsi']}")
1782
+ c3.metric("Stretch", f"{r['stretch']:.1f}%")
1783
+ c4.metric("vs 52w High", f"{r['pct_from_52w']:.1f}%")
1784
+ c5.metric("Weekly Quad", QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"])
1785
+ c6.metric("2-day Turn", "βœ… YES" if r["turn_2d"] else "❌ NO",
1786
  delta=f"dx={r['dx_today']:+.2f} dy={r['dy_today']:+.2f}")
1787
+
1788
+ if not r["quality_ok"]:
1789
+ st.warning(f"⚠️ Quality gate failed β€” price ${r['price']} or liquidity "
1790
+ f"(${r['dvol_20m']:.1f}M 20D $vol < ${MIN_DVOL/1e6:.0f}M min)")
1791
+
1792
+ if r["signal_met"] and ti:
1793
+ col = {"πŸ† Tier 1":"success","βœ… Tier 2":"info","⚠️ Tier 3":"warning"}.get(ti["tier_label"],"info")
1794
+ msg = (f"{ti['tier_label']} | **Estimated {ti['estimated_win']:.1f}% win rate**\n\n"
1795
+ f"RSI: {ti['rsi_note']}\n\n"
1796
+ f"ATH: {ti['ath_note']}")
1797
+ if col == "success":
1798
+ st.success(msg)
1799
+ elif col == "warning":
1800
+ st.warning(msg)
1801
  else:
1802
+ st.info(msg)
1803
+ elif not r["rrg_entry"]:
 
1804
  reasons = []
1805
+ if r["weekly_quad"] not in list(target_quads):
1806
+ reasons.append(f"weekly quad is {r['weekly_quad']} (need {'/'.join(target_quads)})")
1807
+ if not r["turn_2d"]:
1808
+ reasons.append("no 2-day positive daily turn")
1809
+ st.info("Signal not active: " + " | ".join(reasons))
1810
+ elif not r["quality_ok"]:
1811
+ st.warning("Signal fires but quality gate failed β€” treat with caution.")
1812
 
 
 
 
 
 
1813
  st.plotly_chart(mini_rrg_chart(r, single), use_container_width=True)
 
 
1814
  with st.expander("πŸ“ˆ Detailed price + daily RRG panels"):
1815
  detail_chart(r, single)
1816
 
1817
+ # ── Full scan ─────────────────────────────────────────────────────────────
1818
  st.markdown("---")
1819
  st.subheader("Full universe scan")
1820
 
1821
  universe = get_clean_premarket_universe()
1822
 
1823
+ quad_str = " or ".join(target_quads)
1824
+ tier_str = {1:"Tier 1 only",2:"Tier 1+2",3:"All tiers"}.get(min_tier,"")
1825
+ lc_str = "large-cap " if require_lc else ""
1826
  st.caption(
1827
+ f"Universe: {len(universe)} tickers | Benchmark: {benchmark_sym} | "
1828
+ f"Quadrant: {quad_str} | Filter: {lc_str}{tier_str} | Cached 12h"
 
1829
  )
1830
 
1831
+ if st.button("πŸ” Run Scan", key="cs2_scan"):
1832
  if not universe:
1833
  st.error("No tickers loaded.")
1834
  else:
1835
+ df_r = run_combined_scan(
1836
  tuple(universe), benchmark_sym, weekly_win, daily_win,
1837
+ target_quads, min_tier, require_lc,
1838
  )
1839
+ st.session_state["cs2_results"] = df_r
1840
 
1841
+ if "cs2_results" in st.session_state:
1842
+ df_r = st.session_state["cs2_results"]
1843
+ if df_r.empty:
1844
+ st.info("No tickers cleared all filters. Try relaxing Tier or large-cap settings.")
1845
  else:
1846
+ st.success(f"{len(df_r)} ticker(s) β€” sorted by Tier then Est. Win%")
1847
+
1848
+ # Highlight tier 1 rows
1849
+ def row_style(row):
1850
+ if "Tier 1" in str(row.get("Tier","")):
1851
+ return ["background-color: #d4edda"] * len(row)
1852
+ elif "Tier 3" in str(row.get("Tier","")):
1853
+ return ["background-color: #f8d7da"] * len(row)
1854
+ return [""] * len(row)
1855
+
1856
+ display_cols = ["Tier","Est. Win%","Ticker","Price","RSI","Stretch %",
1857
+ "% from 52w H","Wk Quad","Prev Wk Quad","Large Cap",
1858
+ "$Vol 20D (M)","RSI note","ATH note"]
1859
+ display_cols = [c for c in display_cols if c in df_r.columns]
1860
 
1861
  st.dataframe(
1862
+ df_r[display_cols].style.apply(row_style, axis=1),
1863
  use_container_width=True, hide_index=True,
1864
  )
1865
 
1866
+ pick = st.selectbox("View chart for:", df_r["Ticker"].tolist(), key="cs2_pick")
 
1867
  if pick:
1868
  with st.spinner(f"Fetching {pick}…"):
1869
  bm = _fetch_benchmark(benchmark_sym)
1870
  dfp = _fetch_ticker(pick)
1871
+ rp = analyze_combined(pick, dfp, bm, weekly_win, daily_win, list(target_quads))
1872
  if rp:
1873
+ ti = rp.get("tier_info")
1874
+ if ti:
1875
+ st.info(f"{ti['tier_label']} β€’ Est. {ti['estimated_win']:.1f}% win\n\n"
1876
+ f"{ti['rsi_note']}\n\n{ti['ath_note']}")
1877
  st.plotly_chart(mini_rrg_chart(rp, pick), use_container_width=True)
1878
  with st.expander("πŸ“ˆ Detailed panels"):
1879
  detail_chart(rp, pick)
1880
 
1881
+ # ── Workflow guide ────────────────────────────────────────────────────────
1882
  st.markdown("---")
1883
+ with st.expander("πŸ’‘ How to read the tiers + DCA workflow"):
1884
+ st.markdown(f"""
1885
+ **Tier 1 πŸ†** β€” Est. win rate β‰₯67%. Best historical outcomes within the signal.
1886
+ Look for RSI<40, deep negative stretch, or RSI 65-80 with stretch >10%. Position is
1887
+ 10-35% below its 52-week high. These are the clearest mean-reversion setups.
1888
+
1889
+ **Tier 2 βœ…** β€” Est. win rate 60-66%. Solid signal, majority of scan hits will be here.
1890
+ RSI 55-65 + stretch 3-10% is the sweet spot for alpha quality (xs_t closest to 0).
1891
+ The six positions in the live test portfolio are Tier 2 entries.
1892
+
1893
+ **Tier 3 ⚠️** β€” Est. win rate <61%, near-random. RSI 65-75 with moderate stretch, or
1894
+ within 5% of the 52-week high. The scanner flags these so you can see them but they
1895
+ carry no statistically meaningful edge over just buying the benchmark.
1896
+
1897
+ **DCA workflow:**
1898
+ 1. Watchlist conviction first (macro thesis, fundamentals)
1899
+ 2. Combined Scanner fires β†’ Tranche 1 (β…“ position) if Tier 1 or 2
1900
+ 3. If signal re-fires within 63 days β†’ Tranche 2 (lowers average cost)
1901
+ 4. Peak Detector >50% risk β†’ hold / re-evaluate exit
1902
+ 5. RRG rolls out of Weakening/Leading back to Lagging β†’ consider exit
1903
+
1904
+ **Stop losses: the backtest says don't.** 91% of entries (including winners) touch -5%
1905
+ intraperiod. Mean intraperiod drawdown is -14.3% even for winning trades.
1906
+ The edge is mean-reversion β€” cutting at -5% destroys it.
 
 
 
 
 
 
 
1907
  """)
1908
 
 
1909
  # =========================================================
1910
  # MAIN LAYOUT
1911
  # =========================================================