Spaces:
Running
Running
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +580 -7
src/streamlit_app.py
CHANGED
|
@@ -1221,15 +1221,588 @@ def render_rotation_map():
|
|
| 1221 |
)
|
| 1222 |
|
| 1223 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1224 |
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
|
|
|
|
|
|
|
|
|
| 1230 |
|
| 1231 |
-
|
| 1232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|