Trebor1009 commited on
Commit
07b610c
Β·
verified Β·
1 Parent(s): 5f4ebaf

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +580 -7
src/streamlit_app.py CHANGED
@@ -1221,15 +1221,588 @@ def render_rotation_map():
1221
  )
1222
 
1223
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1224
 
1225
- # Append the combined scanner functions (Tab 6)
1226
- app += "\n\n# =========================================================\n"
1227
- app += "# TAB 6: COMBINED SCANNER (RRG + Bottom Scanner)\n"
1228
- app += "# =========================================================\n\n"
1229
- app += cs_body
 
 
 
1230
 
1231
- # Append the main layout with 6 tabs
1232
- app += """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
 
1234
  # =========================================================
1235
  # MAIN LAYOUT
 
1221
  )
1222
 
1223
  """
1224
+ combined_scanner.py β€” Tab 6: RRG + Bottom Scanner Combined Signal
1225
+
1226
+ Backtest findings (2,266 tickers, 2016–2026, ~4.9M ticker-days):
1227
+
1228
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
1229
+ β”‚ Signal β”‚ n β”‚ 63D win β”‚ 63D avg β”‚ xs t(63D)β”‚ CAGR β”‚ Sharpe β”‚
1230
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
1231
+ β”‚ EW B&H benchmark β”‚ β€” β”‚ β€” β”‚ β€” β”‚ β€” β”‚ 31.8% β”‚ 0.79 β”‚
1232
+ β”‚ RRG only (Lag|Weak + 2d turn) β”‚ 46,528 β”‚ 54.5% β”‚ +3.1% β”‚ -12.1 β”‚ 20.3% β”‚ 0.65 β”‚
1233
+ β”‚ Bottom Scanner only β”‚ 30,527 β”‚ 55.1% β”‚ +3.3% β”‚ -10.2 β”‚ 14.0% β”‚ 0.51 β”‚
1234
+ β”‚ COMBINED (all-cap) β”‚ 15,617 β”‚ 55.1% β”‚ +4.0% β”‚ -5.2 β”‚ 3.8% β”‚ 0.23 β”‚
1235
+ β”‚ COMBINED (large-cap) β”‚ 4,841 β”‚ 60.5% β”‚ +5.8% β”‚ +0.3 βœ“ β”‚ 8.1% β”‚ 0.37 β”‚
1236
+ β”‚ BS + Weakening only (large-cap) β”‚ 228 β”‚ 79.6% β”‚+14.8% β”‚ -1.2 β”‚ 5.1% β”‚ 0.55 β”‚
1237
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜
1238
+
1239
+ Key insight: the COMBINED large-cap signal is the only configuration with a (marginally)
1240
+ positive cross-sectional t-stat (+0.32 at 63D), meaning it's the closest thing to
1241
+ genuine alpha vs peers rather than just market beta. "BS + Weakening only" gets closest
1242
+ to Gemini's 75% XL-ETF finding (79.6% win rate on 228 trades, CI 73.9–84.4%).
1243
+
1244
+ Drop-in usage:
1245
+ with tab6:
1246
+ from combined_scanner import render_combined_scanner
1247
+ render_combined_scanner()
1248
+ """
1249
+ def _rrg(price_series: pd.Series, benchmark: pd.Series, window: int) -> pd.DataFrame:
1250
+ """
1251
+ Compute RS-Ratio and RS-Momentum centred at 100.
1252
+ price_series and benchmark must be aligned (same index).
1253
+ """
1254
+ rs = price_series / (benchmark + 1e-9)
1255
+ rs_ratio = 100 + ((rs - rs.rolling(window).mean()) /
1256
+ (rs.rolling(window).std() + 1e-9)) * 5
1257
+ rs_mom = 100 + ((rs_ratio - rs_ratio.rolling(window).mean()) /
1258
+ (rs_ratio.rolling(window).std() + 1e-9)) * 5
1259
+ return pd.DataFrame({"RS_Ratio": rs_ratio, "RS_Momentum": rs_mom})
1260
+
1261
+
1262
+ def _quadrant(ratio: float, mom: float) -> str:
1263
+ if ratio >= 100 and mom >= 100: return "Leading"
1264
+ elif ratio >= 100 and mom < 100: return "Weakening"
1265
+ elif ratio < 100 and mom < 100: return "Lagging"
1266
+ else: return "Improving"
1267
+
1268
+
1269
+ QUAD_COLORS = {
1270
+ "Leading": "#27ae60",
1271
+ "Weakening": "#e67e22",
1272
+ "Lagging": "#e74c3c",
1273
+ "Improving": "#2980b9",
1274
+ }
1275
+
1276
+ QUAD_ICONS = {
1277
+ "Leading": "🟒", "Weakening": "🟠", "Lagging": "πŸ”΄", "Improving": "πŸ”΅"
1278
+ }
1279
+
1280
+
1281
+ # ── per-ticker analysis ────────────────────────────────────────────────────────
1282
+
1283
+ def analyze_combined(
1284
+ ticker: str,
1285
+ daily_df: pd.DataFrame,
1286
+ benchmark_daily: pd.DataFrame,
1287
+ weekly_window: int = 13,
1288
+ daily_window: int = 63,
1289
+ require_weakening_only: bool = False,
1290
+ ) -> dict | None:
1291
+ """
1292
+ Returns a dict of signals or None if data is insufficient.
1293
+
1294
+ Parameters
1295
+ ----------
1296
+ ticker : ticker symbol (for labels)
1297
+ daily_df : OHLCV DataFrame (daily, from yfinance), indexed by Date
1298
+ benchmark_daily : close-price Series for the benchmark (same date range)
1299
+ weekly_window : z-score window in weeks (default 13 β‰ˆ 1 quarter)
1300
+ daily_window : z-score window in days (default 63 β‰ˆ 1 quarter)
1301
+ require_weakening_only : if True, only flag Weakening (not Lagging); closer to Gemini's
1302
+ 79.6% win-rate config but far fewer signals
1303
+ """
1304
+ if daily_df is None or len(daily_df) < max(weekly_window * 5, daily_window) + 5:
1305
+ return None
1306
+
1307
+ close = daily_df["Close"].astype(float).dropna()
1308
+ vol = daily_df["Volume"].astype(float)
1309
+
1310
+ # ── quality gate ────────────────────────────────────────────────────────
1311
+ current_price = float(close.iloc[-1])
1312
+ dvol_20 = float(close.iloc[-20:].mean() * vol.iloc[-20:].mean()) if len(close) >= 20 else 0.0
1313
+ quality_ok = current_price >= PRICE_FLOOR and dvol_20 >= MIN_DVOL
1314
+
1315
+ # ── large-cap flag (proxy: 20D avg dollar vol β‰₯ $50M) ──────────────────
1316
+ large_cap = dvol_20 >= 50_000_000
1317
+
1318
+ # ── align benchmark ──────────────────────────────────────────────────────
1319
+ bm = benchmark_daily.reindex(close.index, method="ffill").astype(float)
1320
+ if bm.isna().all():
1321
+ return None
1322
+
1323
+ # ── DAILY RRG ───────────────────────────────────────────────────────────
1324
+ rrg_d = _rrg(close, bm, daily_window)
1325
+ dx = rrg_d["RS_Ratio"].diff()
1326
+ dy = rrg_d["RS_Momentum"].diff()
1327
+ turn_2d = bool(
1328
+ dx.iloc[-1] > 0 and dy.iloc[-1] > 0 and
1329
+ dx.iloc[-2] > 0 and dy.iloc[-2] > 0
1330
+ )
1331
+
1332
+ # history of daily direction for the trail chart
1333
+ daily_trail = rrg_d.tail(10).copy()
1334
+ daily_trail["dx"] = dx.tail(10)
1335
+ daily_trail["dy"] = dy.tail(10)
1336
+
1337
+ # ── WEEKLY RRG ──────────────────────────────────────────────────────────
1338
+ close_w = close.resample("W-FRI").last().dropna()
1339
+ bm_w = bm.resample("W-FRI").last().reindex(close_w.index, method="ffill")
1340
+ if len(close_w) < weekly_window + 2:
1341
+ return None
1342
+ rrg_w = _rrg(close_w, bm_w, weekly_window)
1343
+ w_ratio = float(rrg_w["RS_Ratio"].iloc[-1])
1344
+ w_mom = float(rrg_w["RS_Momentum"].iloc[-1])
1345
+ weekly_quad = _quadrant(w_ratio, w_mom)
1346
+ prev_weekly_quad = _quadrant(
1347
+ float(rrg_w["RS_Ratio"].iloc[-2]),
1348
+ float(rrg_w["RS_Momentum"].iloc[-2]),
1349
+ )
1350
+
1351
+ # ── SIGNAL LOGIC ─────────────────────────────────────────────────────────
1352
+ # Backtest best configs:
1353
+ # "Lagging or Weakening" + 2d turn β†’ 54.5% win (all-cap)
1354
+ # "Weakening only" + 2d turn β†’ 60.7% win (all-cap), 79.6% large-cap
1355
+ target_quads = ["Weakening"] if require_weakening_only else ["Lagging", "Weakening"]
1356
+ rrg_entry = weekly_quad in target_quads and turn_2d
1357
+
1358
+ # ── OPTIONAL: combine with oversold RSI / SMA stretch ────────────────────
1359
+ sma20 = close.rolling(20).mean()
1360
+ delta = close.diff()
1361
+ gain = delta.where(delta > 0, 0).ewm(alpha=1/14, adjust=False).mean()
1362
+ loss = (-delta.where(delta < 0, 0)).ewm(alpha=1/14, adjust=False).mean()
1363
+ rsi = float(100 - 100 / (1 + gain.iloc[-1] / (loss.iloc[-1] + 1e-9)))
1364
+ stretch = float((close.iloc[-1] - sma20.iloc[-1]) / (sma20.iloc[-1] + 1e-9) * 100)
1365
+
1366
+ # weekly RRG trail (last 6 weeks for the mini chart)
1367
+ weekly_trail = rrg_w.tail(7).copy()
1368
+
1369
+ return {
1370
+ "ticker": ticker,
1371
+ "price": round(current_price, 2),
1372
+ "dvol_20m": round(dvol_20 / 1e6, 1),
1373
+ "quality_ok": quality_ok,
1374
+ "large_cap": large_cap,
1375
+ "rsi": round(rsi, 1),
1376
+ "stretch": round(stretch, 2),
1377
+ # weekly RRG
1378
+ "weekly_quad": weekly_quad,
1379
+ "prev_weekly_quad":prev_weekly_quad,
1380
+ "w_ratio": round(w_ratio, 2),
1381
+ "w_mom": round(w_mom, 2),
1382
+ # daily RRG
1383
+ "d_ratio": round(float(rrg_d["RS_Ratio"].iloc[-1]), 2),
1384
+ "d_mom": round(float(rrg_d["RS_Momentum"].iloc[-1]), 2),
1385
+ "dx_today": round(float(dx.iloc[-1]), 3),
1386
+ "dy_today": round(float(dy.iloc[-1]), 3),
1387
+ "turn_2d": turn_2d,
1388
+ # combined
1389
+ "rrg_entry": rrg_entry,
1390
+ "signal_met": rrg_entry and quality_ok,
1391
+ # trail data for charts
1392
+ "daily_trail": daily_trail,
1393
+ "weekly_trail": weekly_trail,
1394
+ "rrg_w_full": rrg_w,
1395
+ "rrg_d_full": rrg_d,
1396
+ "close": close,
1397
+ }
1398
+
1399
+
1400
+ # ── mini RRG chart (plotly, matches the Rotation Map tab style) ──────────────
1401
+
1402
+ def mini_rrg_chart(result: dict, ticker: str) -> go.Figure:
1403
+ wt = result["weekly_trail"]
1404
+ dt = result["daily_trail"]
1405
+
1406
+ fig = go.Figure()
1407
+
1408
+ # background quadrant shading
1409
+ for x0, x1, y0, y1, color, label in [
1410
+ (100, 112, 100, 112, "rgba(39,174,96,0.08)", "Leading"),
1411
+ (100, 112, 88, 100, "rgba(230,126,34,0.08)", "Weakening"),
1412
+ (88, 100, 88, 100, "rgba(231,76,60,0.08)", "Lagging"),
1413
+ (88, 100, 100, 112, "rgba(41,128,185,0.08)", "Improving"),
1414
+ ]:
1415
+ fig.add_shape(type="rect", x0=x0, x1=x1, y0=y0, y1=y1,
1416
+ fillcolor=color, line_width=0, layer="below")
1417
+ fig.add_annotation(x=(x0+x1)/2, y=(y0+y1)/2, text=label,
1418
+ font=dict(size=10, color=color.replace("0.08","0.35")),
1419
+ showarrow=False)
1420
+
1421
+ # weekly trail
1422
+ fig.add_trace(go.Scatter(
1423
+ x=wt["RS_Ratio"].tolist(), y=wt["RS_Momentum"].tolist(),
1424
+ mode="lines+markers", name="Weekly RRG (6w)",
1425
+ line=dict(color="#1f77b4", width=2),
1426
+ marker=dict(size=[6]*len(wt), color=["#1f77b4"]*len(wt)),
1427
+ text=[str(d.date()) for d in wt.index],
1428
+ ))
1429
+ # highlight latest weekly point
1430
+ fig.add_trace(go.Scatter(
1431
+ x=[wt["RS_Ratio"].iloc[-1]], y=[wt["RS_Momentum"].iloc[-1]],
1432
+ mode="markers+text", name=f"Weekly now ({result['weekly_quad']})",
1433
+ marker=dict(size=14, color=QUAD_COLORS.get(result["weekly_quad"], "gray"),
1434
+ symbol="diamond"),
1435
+ text=[ticker], textposition="top center", textfont=dict(size=11),
1436
+ ))
1437
+
1438
+ # daily trail (last 10 bars, smaller)
1439
+ fig.add_trace(go.Scatter(
1440
+ x=dt["RS_Ratio"].tolist(), y=dt["RS_Momentum"].tolist(),
1441
+ mode="lines+markers", name="Daily RRG (10d)",
1442
+ line=dict(color="orange", width=1.5, dash="dot"),
1443
+ marker=dict(size=5, color="orange"),
1444
+ ))
1445
+
1446
+ fig.add_hline(y=100, line_dash="dash", line_color="gray", opacity=0.4)
1447
+ fig.add_vline(x=100, line_dash="dash", line_color="gray", opacity=0.4)
1448
+
1449
+ fig.update_layout(
1450
+ title=f"{ticker} β€” Dual-TF RRG (weekly: {result['weekly_quad']})",
1451
+ xaxis_title="RS-Ratio",
1452
+ yaxis_title="RS-Momentum",
1453
+ height=380,
1454
+ template="plotly_white",
1455
+ legend=dict(orientation="h", yanchor="bottom", y=1.02),
1456
+ margin=dict(l=40, r=20, t=60, b=40),
1457
+ )
1458
+ return fig
1459
+
1460
+
1461
+ # ── single-ticker detailed chart (matplotlib) ────────────────────────────────
1462
+
1463
+ def detail_chart(result: dict, ticker: str):
1464
+ close = result["close"]
1465
+ rrg_d = result["rrg_d_full"]
1466
+ dx = rrg_d["RS_Ratio"].diff()
1467
+ dy = rrg_d["RS_Momentum"].diff()
1468
+
1469
+ fig, axes = plt.subplots(3, 1, figsize=(13, 10), sharex=False,
1470
+ gridspec_kw={"height_ratios": [2.5, 1, 1]})
1471
+
1472
+ # Panel 1: price + 20-day SMA
1473
+ recent = close.iloc[-120:]
1474
+ sma20 = close.rolling(20).mean().iloc[-120:]
1475
+ axes[0].plot(recent.index, recent.values, color="black", lw=1.5, label="Price")
1476
+ axes[0].plot(recent.index, sma20.values, color="blue", lw=0.9, alpha=0.6, label="SMA20")
1477
+ axes[0].set_title(f"{ticker} | Price + RRG Signals", fontweight="bold", fontsize=13)
1478
+ axes[0].legend(fontsize=9); axes[0].grid(True, alpha=0.15)
1479
+
1480
+ # shade last 2 bars green if turn_2d
1481
+ if result["turn_2d"]:
1482
+ axes[0].axvspan(recent.index[-2], recent.index[-1],
1483
+ color="limegreen", alpha=0.18, label="2-day turn")
1484
+
1485
+ # Panel 2: daily RS-Ratio with direction markers
1486
+ rd = rrg_d["RS_Ratio"].iloc[-120:]
1487
+ axes[1].plot(rd.index, rd.values, color="#1f77b4", lw=1.5)
1488
+ axes[1].axhline(100, color="gray", ls="--", lw=0.8)
1489
+ pos_d = dx.iloc[-120:] > 0
1490
+ axes[1].fill_between(rd.index, rd.values, 100,
1491
+ where=pos_d.values, alpha=0.2, color="green", label="RS-Ratio > yesterday")
1492
+ axes[1].fill_between(rd.index, rd.values, 100,
1493
+ where=~pos_d.values, alpha=0.15, color="red", label="RS-Ratio < yesterday")
1494
+ axes[1].set_title("Daily RS-Ratio (green = gaining vs benchmark, red = losing)", fontsize=10)
1495
+ axes[1].legend(fontsize=8); axes[1].grid(True, alpha=0.15)
1496
+
1497
+ # Panel 3: daily RS-Momentum
1498
+ rm = rrg_d["RS_Momentum"].iloc[-120:]
1499
+ pos_m = dy.iloc[-120:] > 0
1500
+ axes[2].plot(rm.index, rm.values, color="purple", lw=1.5)
1501
+ axes[2].axhline(100, color="gray", ls="--", lw=0.8)
1502
+ axes[2].fill_between(rm.index, rm.values, 100,
1503
+ where=pos_m.values, alpha=0.2, color="green", label="Momentum rising")
1504
+ axes[2].fill_between(rm.index, rm.values, 100,
1505
+ where=~pos_m.values, alpha=0.15, color="red", label="Momentum falling")
1506
+ axes[2].set_title("Daily RS-Momentum (green = 2d+ both rising = entry signal)", fontsize=10)
1507
+ axes[2].legend(fontsize=8); axes[2].grid(True, alpha=0.15)
1508
+
1509
+ plt.tight_layout()
1510
+ st.pyplot(fig)
1511
+ plt.close(fig)
1512
+
1513
+
1514
+ # ── cached data helpers ───────────────────────────────────────────────────────
1515
+
1516
+ @st.cache_data(ttl=4 * 3600, show_spinner=False)
1517
+ def _fetch_benchmark(benchmark_ticker: str) -> pd.Series:
1518
+ raw = yf.download(benchmark_ticker, period="5y", interval="1d", progress=False)
1519
+ if isinstance(raw.columns, pd.MultiIndex):
1520
+ raw.columns = raw.columns.get_level_values(0)
1521
+ return raw["Close"].dropna().astype(float)
1522
+
1523
+
1524
+ @st.cache_data(ttl=4 * 3600, show_spinner=False)
1525
+ def _fetch_ticker(ticker: str) -> pd.DataFrame:
1526
+ raw = yf.download(ticker, period="5y", interval="1d", progress=False)
1527
+ if isinstance(raw.columns, pd.MultiIndex):
1528
+ raw.columns = raw.columns.get_level_values(0)
1529
+ return raw.dropna()
1530
+
1531
+
1532
+ @st.cache_data(ttl=12 * 3600, show_spinner=False)
1533
+ def run_combined_scan(
1534
+ tickers_tuple: tuple,
1535
+ benchmark_ticker: str,
1536
+ weekly_window: int,
1537
+ daily_window: int,
1538
+ require_weakening: bool,
1539
+ require_bs: bool,
1540
+ rsi_thresh: float,
1541
+ stretch_thresh: float,
1542
+ require_large_cap: bool,
1543
+ ) -> pd.DataFrame:
1544
+ bm = _fetch_benchmark(benchmark_ticker)
1545
+ results = []
1546
+ batch_size = 10
1547
+ n_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
1548
+ progress = st.progress(0, text="Scanning…")
1549
 
1550
+ for b, i in enumerate(range(0, len(tickers_tuple), batch_size)):
1551
+ batch = list(tickers_tuple[i:i + batch_size])
1552
+ try:
1553
+ data = yf.download(batch, period="5y", interval="1d",
1554
+ group_by="ticker", progress=False, threads=True)
1555
+ except Exception:
1556
+ progress.progress((b+1)/n_batches)
1557
+ continue
1558
 
1559
+ for ticker in batch:
1560
+ try:
1561
+ raw = data[ticker] if isinstance(data.columns, pd.MultiIndex) else data
1562
+ df = raw.dropna()
1563
+ r = analyze_combined(ticker, df, bm, weekly_window, daily_window,
1564
+ require_weakening)
1565
+ if r is None or not r["signal_met"]:
1566
+ continue
1567
+ if require_large_cap and not r["large_cap"]:
1568
+ continue
1569
+ if require_bs and (r["rsi"] >= rsi_thresh or r["stretch"] >= stretch_thresh):
1570
+ continue
1571
+ results.append({
1572
+ "Ticker": ticker,
1573
+ "Price": r["price"],
1574
+ "20D $vol (M)": r["dvol_20m"],
1575
+ "Large Cap": "βœ…" if r["large_cap"] else "β€”",
1576
+ "RSI": r["rsi"],
1577
+ "Stretch %": r["stretch"],
1578
+ "Wk Quad": QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"],
1579
+ "Prev Wk Quad": QUAD_ICONS.get(r["prev_weekly_quad"],"") + " " + r["prev_weekly_quad"],
1580
+ "W RS-Ratio": r["w_ratio"],
1581
+ "W RS-Mom": r["w_mom"],
1582
+ "D RS-Ratio": r["d_ratio"],
1583
+ "D RS-Mom": r["d_mom"],
1584
+ "dx today": r["dx_today"],
1585
+ "dy today": r["dy_today"],
1586
+ "2d Turn": "βœ…" if r["turn_2d"] else "❌",
1587
+ })
1588
+ except Exception:
1589
+ continue
1590
+
1591
+ progress.progress((b+1)/n_batches, text=f"Batch {b+1}/{n_batches}…")
1592
+
1593
+ progress.empty()
1594
+ return pd.DataFrame(results)
1595
+
1596
+
1597
+ # ── MAIN RENDER FUNCTION ──────────────────────────────────────────────────────
1598
+
1599
+ def render_combined_scanner():
1600
+ st.title("🎯 Combined Scanner β€” RRG + Bottom Scanner")
1601
+
1602
+ # ── BACKTEST SUMMARY CALLOUT ─────────────────────────────────────────────
1603
+ with st.expander("πŸ“Š Backtest findings (10-year, 2,266 tickers)", expanded=True):
1604
+ st.markdown("""
1605
+ **How this signal was developed:** Gemini backtested the RRG scanner on the XL? sector
1606
+ ETFs and found ~75% win rate at 6 months when the weekly RRG was in Weakening/Lagging
1607
+ AND the daily RRG showed 2 consecutive days of positive RS-Ratio AND RS-Momentum movement.
1608
+ This tab reproduces and extends that finding on 2,266 individual stocks.
1609
+
1610
+ | Signal | n trades | 63D win% | 63D mean | xs t-stat | CAGR | Sharpe |
1611
+ |--------|----------|----------|----------|-----------|------|--------|
1612
+ | EW Buy & Hold | β€” | β€” | β€” | β€” | **31.8%** | **0.79** |
1613
+ | RRG only (Lag/Weak + 2d turn) | 46,528 | 54.5% | +3.1% | βˆ’12.1 | 20.3% | 0.65 |
1614
+ | Bottom Scanner only | 30,527 | 55.1% | +3.3% | βˆ’10.2 | 14.0% | 0.51 |
1615
+ | **COMBINED (all-cap)** | 15,617 | 55.1% | +4.0% | **βˆ’5.2** | 3.8% | 0.23 |
1616
+ | **COMBINED (large-cap β‰₯$50M $vol)** | 4,841 | **60.5%** | +5.8% | **+0.3 βœ“** | 8.1% | 0.37 |
1617
+ | **BS + Weakening only (large-cap)** | 228 | **79.6%** | +14.8% | βˆ’1.2 | 5.1% | 0.55 |
1618
+
1619
+ **How to use this:** The combined signal is best used as a **high-conviction watchlist**,
1620
+ not a systematic buy-everything strategy. The large-cap Weakening+BS combo (79.6% win rate)
1621
+ is the closest to Gemini's XL-ETF finding β€” but n=228 over 10 years means only ~20–25
1622
+ trades per year, each with CI [73.9%–84.4%] so treat it as a lean, not a certainty.
1623
+
1624
+ **Why it still underperforms B&H in CAGR:** the signal is relatively rare (it selects
1625
+ ~10οΏ½οΏ½οΏ½15% of signals vs B&H which holds everything). Idle capital earns 0% in the simulation.
1626
+ The real use is as a *filter on top of an existing watchlist*, not a standalone system.
1627
+ """)
1628
+
1629
+ # ── SIDEBAR ──────────────────────────────────────────────────────────────
1630
+ with st.sidebar:
1631
+ st.markdown("---")
1632
+ st.header("Combined Scanner")
1633
+ benchmark_sym = st.text_input("Benchmark", value="SPY", key="cs_bm").strip().upper()
1634
+ weekly_win = st.slider("Weekly z-score window (weeks)", 8, 26, 13, 1, key="cs_ww")
1635
+ daily_win = st.slider("Daily z-score window (days)", 21, 126, 63, 1, key="cs_dw")
1636
+ st.markdown("**RRG filters**")
1637
+ require_wk = st.checkbox("Weakening only (stricter, closer to Gemini's 75%)",
1638
+ value=False, key="cs_wk")
1639
+ st.markdown("**Bottom Scanner overlay (optional)**")
1640
+ use_bs = st.checkbox("Require oversold RSI + stretch", value=False, key="cs_bs")
1641
+ rsi_thr = st.slider("RSI threshold", 20, 50, 35, 1, key="cs_rsi") if use_bs else 35
1642
+ stretch_thr = st.slider("Stretch %", -10.0, 0.0, -4.0, 0.5, key="cs_str") if use_bs else -4.0
1643
+ lc_only = st.checkbox("Large-cap only (β‰₯$50M 20D $vol)", value=False, key="cs_lc")
1644
+ st.caption(
1645
+ "βœ… Large-cap filter strongly recommended: all-cap COMBINED xs t=-5.2 vs "
1646
+ "large-cap xs t=+0.3 (the only marginally positive alpha configuration)"
1647
+ )
1648
+
1649
+ # ── SINGLE TICKER ────────────────────────────────────────────────────────
1650
+ st.subheader("Analyze a single ticker")
1651
+ col_a, col_b = st.columns([2, 1])
1652
+ single = col_a.text_input("Ticker", value="XLF", key="cs_single").strip().upper()
1653
+ analyze_btn = col_b.button("Analyze", key="cs_analyze")
1654
+
1655
+ if analyze_btn and single:
1656
+ with st.spinner(f"Fetching {single} + {benchmark_sym}…"):
1657
+ bm = _fetch_benchmark(benchmark_sym)
1658
+ df = _fetch_ticker(single)
1659
+
1660
+ r = analyze_combined(single, df, bm, weekly_win, daily_win, require_wk)
1661
+
1662
+ if r is None:
1663
+ st.error("Not enough data (need 5y of daily bars).")
1664
+ else:
1665
+ # metric cards
1666
+ c1, c2, c3, c4, c5, c6 = st.columns(6)
1667
+ c1.metric("Price", f"${r['price']}")
1668
+ c2.metric("Quality", "βœ…" if r["quality_ok"] else "❌ Fail")
1669
+ c3.metric("Large Cap", "βœ…" if r["large_cap"] else "β€”")
1670
+ c4.metric("Weekly Quad",
1671
+ QUAD_ICONS.get(r["weekly_quad"],"") + " " + r["weekly_quad"])
1672
+ c5.metric("2-day Turn", "βœ… YES" if r["turn_2d"] else "❌ NO",
1673
+ delta=f"dx={r['dx_today']:+.2f} dy={r['dy_today']:+.2f}")
1674
+ c6.metric("Signal", "🎯 ACTIVE" if r["signal_met"] else "⬜ inactive")
1675
+
1676
+ if r["signal_met"]:
1677
+ wq = r["weekly_quad"]
1678
+ # Show the relevant backtest win rate
1679
+ if r["large_cap"] and wq == "Weakening":
1680
+ 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."
1681
+ elif r["large_cap"]:
1682
+ wr_note = "**60.5% historical 63D win rate** (large-cap COMBINED, n=4,841, CI 59.1–61.9%)."
1683
+ else:
1684
+ wr_note = "**55.1% historical 63D win rate** (all-cap COMBINED, n=15,617, CI 54.3–55.9%)."
1685
+ st.success(f"🎯 Signal active β€” {wq} quadrant with 2-day daily turn. {wr_note}")
1686
+ else:
1687
+ reasons = []
1688
+ if not r["quality_ok"]: reasons.append("quality gate (price/liquidity)")
1689
+ if r["weekly_quad"] not in (["Weakening"] if require_wk else ["Lagging","Weakening"]):
1690
+ reasons.append(f"weekly quadrant is {r['weekly_quad']} (need {'Weakening' if require_wk else 'Lagging or Weakening'})")
1691
+ if not r["turn_2d"]: reasons.append("no 2-day daily turn yet")
1692
+ st.info("Signal not active: " + " | ".join(reasons) if reasons else "Conditions not met.")
1693
+
1694
+ # RSI / stretch context
1695
+ st.caption(f"RSI {r['rsi']} | SMA20 stretch {r['stretch']}% | "
1696
+ f"20D $vol ${r['dvol_20m']:.1f}M")
1697
+
1698
+ # mini RRG chart
1699
+ st.plotly_chart(mini_rrg_chart(r, single), use_container_width=True)
1700
+
1701
+ # detail chart
1702
+ with st.expander("πŸ“ˆ Detailed price + daily RRG panels"):
1703
+ detail_chart(r, single)
1704
+
1705
+ # ── FULL SCAN ────────────────────────────────────────────────────────────
1706
+ st.markdown("---")
1707
+ st.subheader("Full universe scan")
1708
+
1709
+ try:
1710
+ from huggingface_hub import hf_hub_download
1711
+ local = hf_hub_download(
1712
+ repo_id="Trebor1009/clean-premarket-data", repo_type="dataset",
1713
+ filename="clean_premarket_data.csv", token=HF_TOKEN,
1714
+ )
1715
+ universe = sorted(pd.read_csv(local, usecols=["Ticker"])["Ticker"].dropna().unique())
1716
+ except Exception as e:
1717
+ universe = []
1718
+ st.warning(f"Could not load universe: {e}")
1719
+
1720
+ st.caption(
1721
+ f"Universe: {len(universe)} tickers. Each ticker fetches 5y of daily data β€” "
1722
+ "this scan takes 5–10 minutes on first run, then cached 12 hours. "
1723
+ "The large-cap filter (sidebar) cuts runtime ~75% and gives the best signal quality."
1724
+ )
1725
+
1726
+ if st.button("πŸ” Run Combined Scan", key="cs_scan"):
1727
+ if not universe:
1728
+ st.error("No tickers loaded.")
1729
+ else:
1730
+ results_df = run_combined_scan(
1731
+ tuple(universe), benchmark_sym, weekly_win, daily_win,
1732
+ require_wk, use_bs, rsi_thr, stretch_thr, lc_only,
1733
+ )
1734
+ st.session_state["cs_results"] = results_df
1735
+
1736
+ if "cs_results" in st.session_state:
1737
+ df = st.session_state["cs_results"]
1738
+ if df.empty:
1739
+ st.info("No tickers cleared all filters. Try relaxing the settings.")
1740
+ else:
1741
+ st.success(f"{len(df)} ticker(s) met all conditions.")
1742
+
1743
+ # tag with expected win rate
1744
+ def expected_wr(row):
1745
+ lc = row.get("Large Cap") == "βœ…"
1746
+ wk = "Weakening" in str(row.get("Wk Quad",""))
1747
+ if lc and wk:
1748
+ return "~79.6% (LC+Weak)"
1749
+ elif lc:
1750
+ return "~60.5% (LC+Combo)"
1751
+ else:
1752
+ return "~55.1% (All-cap)"
1753
+ df["Expected Win%"] = df.apply(expected_wr, axis=1)
1754
+
1755
+ st.dataframe(
1756
+ df.sort_values("20D $vol (M)", ascending=False).reset_index(drop=True),
1757
+ use_container_width=True, hide_index=True,
1758
+ )
1759
+
1760
+ # chart picker
1761
+ pick = st.selectbox("View RRG chart for:", df["Ticker"].tolist(), key="cs_pick")
1762
+ if pick:
1763
+ with st.spinner(f"Fetching {pick}…"):
1764
+ bm = _fetch_benchmark(benchmark_sym)
1765
+ dfp = _fetch_ticker(pick)
1766
+ rp = analyze_combined(pick, dfp, bm, weekly_win, daily_win, require_wk)
1767
+ if rp:
1768
+ st.plotly_chart(mini_rrg_chart(rp, pick), use_container_width=True)
1769
+ with st.expander("πŸ“ˆ Detailed panels"):
1770
+ detail_chart(rp, pick)
1771
+
1772
+ # ── HOW TO USE THIS WITH THE ROTATION MAP ────────────────────────────────
1773
+ st.markdown("---")
1774
+ with st.expander("πŸ’‘ How to use Combined Scanner with the Rotation Map tab"):
1775
+ st.markdown("""
1776
+ **Workflow (the 'watchlist funnel' approach):**
1777
+
1778
+ 1. **Rotation Map tab** β†’ set your sector ETF basket β†’ note which ETFs sit in **Weakening**
1779
+ with their tail starting to curl toward Improving. These are the *sectors* with
1780
+ potential rotation setups.
1781
+
1782
+ 2. **Bottom Scanner tab** β†’ run a scan of the holdings within those sectors. Hits
1783
+ give you a shortlist of oversold individual names inside a potentially-turning sector.
1784
+
1785
+ 3. **Combined Scanner tab** β†’ paste those tickers into single-ticker mode here and
1786
+ check the dual-TF RRG. You're looking for:
1787
+ - Weekly: **Weakening** (strong but losing steam) or **Lagging** (weak but starting to turn)
1788
+ - Daily: **2 consecutive days with positive dx AND dy** (the early rotation signal)
1789
+
1790
+ 4. **Check the Rotation Map's own trail for those stocks** (add them to the custom ticker
1791
+ list in Rotation Map) β€” does the tail show the same early curl from Weakening β†’ Improving?
1792
+
1793
+ **Why this combination matters:**
1794
+ - Bottom Scanner alone: xs t-stat β‰ˆ βˆ’10 (signal underperforms peers)
1795
+ - RRG alone: xs t-stat β‰ˆ βˆ’12 (worse β€” very broad signal)
1796
+ - Combined large-cap: xs t-stat β‰ˆ **+0.3** (the only config with marginal positive alpha)
1797
+ - The intersection removes most of the "falling knives" the BS alone catches
1798
+ - Adding the large-cap / Weakening filter brings win rates to 60–79%, matching
1799
+ Gemini's XL-ETF observation on individual stocks too
1800
+
1801
+ **Honest caveat:** even at 79.6% (large-cap Weakening+BS, n=228 over 10 years),
1802
+ the 95% CI is [73.9%–84.4%]. The equity-curve CAGR (5.1%) still underperforms
1803
+ the equal-weight benchmark (31.8%) because active holding periods are sparse.
1804
+ Use as a filter, not a standalone system.
1805
+ """)
1806
 
1807
  # =========================================================
1808
  # MAIN LAYOUT