Spaces:
Running
Running
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +230 -348
src/streamlit_app.py
CHANGED
|
@@ -12,7 +12,7 @@ import matplotlib.pyplot as plt
|
|
| 12 |
import plotly.graph_objects as go
|
| 13 |
import plotly.express as px
|
| 14 |
from numpy.lib.stride_tricks import sliding_window_view
|
| 15 |
-
from scipy.stats import spearmanr
|
| 16 |
from sklearn.ensemble import GradientBoostingClassifier
|
| 17 |
from huggingface_hub import HfApi, hf_hub_download
|
| 18 |
|
|
@@ -144,8 +144,6 @@ def get_clean_premarket_universe():
|
|
| 144 |
|
| 145 |
@st.cache_data(ttl=3600, show_spinner="Fetching prior-close reference prices...")
|
| 146 |
def get_daily_reference_data(tickers_tuple):
|
| 147 |
-
"""Bulk-fetch recent daily bars for previous close + average volume context.
|
| 148 |
-
Batched at 10 tickers per request β anything larger triggered rate limiting."""
|
| 149 |
ref = {}
|
| 150 |
batch_size = 10
|
| 151 |
total_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
|
|
@@ -193,7 +191,6 @@ def get_daily_reference_data(tickers_tuple):
|
|
| 193 |
@st.cache_data(ttl=300, show_spinner=False)
|
| 194 |
def run_premarket_scan(tickers_tuple, min_gap_pct, direction):
|
| 195 |
ref_data = get_daily_reference_data(tickers_tuple)
|
| 196 |
-
|
| 197 |
results = []
|
| 198 |
batch_size = 10
|
| 199 |
total_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
|
|
@@ -217,37 +214,30 @@ def run_premarket_scan(tickers_tuple, min_gap_pct, direction):
|
|
| 217 |
df = raw.dropna(subset=["Close"])
|
| 218 |
if df.empty:
|
| 219 |
continue
|
| 220 |
-
|
| 221 |
if df.index.tz is not None:
|
| 222 |
df = df.copy()
|
| 223 |
df.index = df.index.tz_convert("America/New_York")
|
| 224 |
else:
|
| 225 |
df = df.copy()
|
| 226 |
df.index = df.index.tz_localize("America/New_York")
|
| 227 |
-
|
| 228 |
premarket = df[df.index.time < dt_time(9, 30)]
|
| 229 |
if premarket.empty:
|
| 230 |
continue
|
| 231 |
-
|
| 232 |
ref = ref_data.get(ticker)
|
| 233 |
if not ref or not ref.get("prev_close"):
|
| 234 |
continue
|
| 235 |
prev_close = ref["prev_close"]
|
| 236 |
-
|
| 237 |
last_price = float(premarket["Close"].iloc[-1])
|
| 238 |
if not prev_close:
|
| 239 |
continue
|
| 240 |
gap_pct = (last_price - prev_close) / prev_close * 100
|
| 241 |
-
|
| 242 |
if abs(gap_pct) < min_gap_pct:
|
| 243 |
continue
|
| 244 |
if direction == "Up only" and gap_pct <= 0:
|
| 245 |
continue
|
| 246 |
if direction == "Down only" and gap_pct >= 0:
|
| 247 |
continue
|
| 248 |
-
|
| 249 |
pm_volume = int(premarket["Volume"].sum())
|
| 250 |
-
|
| 251 |
results.append({
|
| 252 |
"Ticker": ticker,
|
| 253 |
"Prev Close": round(prev_close, 2),
|
|
@@ -272,18 +262,14 @@ def render_premarket_scan():
|
|
| 272 |
"close as the signal."
|
| 273 |
)
|
| 274 |
st.info(
|
| 275 |
-
"π Why price, not volume:
|
| 276 |
-
"
|
| 277 |
-
"
|
| 278 |
-
"volume β a data source limit, not a settings problem. So this scan uses premarket price "
|
| 279 |
-
"gap (which Yahoo does report reliably) instead. Premarket volume is still shown when "
|
| 280 |
-
"available, just not filtered on."
|
| 281 |
)
|
| 282 |
st.warning(
|
| 283 |
-
"β οΈ A premarket gap is not a reliable 'buy/short the open' signal by itself β it's exactly
|
| 284 |
-
"setup behind the classic 'gap and fade
|
| 285 |
-
"
|
| 286 |
-
"news on,' not a standalone trade signal."
|
| 287 |
)
|
| 288 |
|
| 289 |
with st.sidebar:
|
|
@@ -292,15 +278,11 @@ def render_premarket_scan():
|
|
| 292 |
direction = st.radio("Direction", ["Both", "Up only", "Down only"], key="pm_direction")
|
| 293 |
|
| 294 |
universe = get_clean_premarket_universe()
|
| 295 |
-
st.caption(
|
| 296 |
-
f"Universe: {len(universe)} tickers from your curated list (Trebor1009/clean-premarket-data). "
|
| 297 |
-
f"Pulled in batches of 10 to stay under Yahoo's rate limit β a run over this many tickers will "
|
| 298 |
-
f"still take a few minutes."
|
| 299 |
-
)
|
| 300 |
|
| 301 |
if st.button("π Run Premarket Scan", key="pm_scan_btn"):
|
| 302 |
if not universe:
|
| 303 |
-
st.error("No tickers loaded
|
| 304 |
else:
|
| 305 |
results_df = run_premarket_scan(tuple(universe), min_gap_pct, direction)
|
| 306 |
st.session_state["pm_results"] = results_df
|
|
@@ -310,32 +292,22 @@ def render_premarket_scan():
|
|
| 310 |
if results_df.empty:
|
| 311 |
st.info("No tickers cleared the gap threshold. Try lowering the minimum %.")
|
| 312 |
else:
|
| 313 |
-
EXTREME_GAP_CUTOFF = 50
|
| 314 |
extreme = results_df[results_df["Gap %"].abs() >= EXTREME_GAP_CUTOFF].copy()
|
| 315 |
-
normal
|
| 316 |
-
|
| 317 |
sorted_df = normal.reindex(
|
| 318 |
normal["Gap %"].abs().sort_values(ascending=False).index
|
| 319 |
).reset_index(drop=True)
|
| 320 |
gainers = sorted_df[sorted_df["Gap %"] > 0].reset_index(drop=True)
|
| 321 |
-
losers
|
| 322 |
-
|
| 323 |
-
st.success(
|
| 324 |
-
f"{len(sorted_df)} ticker(s) past the gap threshold β "
|
| 325 |
-
f"{len(gainers)} up, {len(losers)} down."
|
| 326 |
-
)
|
| 327 |
st.markdown("### Premarket Gainers")
|
| 328 |
st.dataframe(gainers, use_container_width=True, hide_index=True)
|
| 329 |
st.markdown("### Premarket Losers")
|
| 330 |
st.dataframe(losers, use_container_width=True, hide_index=True)
|
| 331 |
-
|
| 332 |
if not extreme.empty:
|
| 333 |
st.markdown("### β οΈ Extreme moves β verify before trusting")
|
| 334 |
-
st.caption(
|
| 335 |
-
f"Gaps of {EXTREME_GAP_CUTOFF}%+ are almost always a stock split or other corporate "
|
| 336 |
-
f"action causing the prior-close and live-price data to be on different share bases, "
|
| 337 |
-
f"not a real overnight move. Check the ticker before reading any meaning into these."
|
| 338 |
-
)
|
| 339 |
st.dataframe(extreme.reset_index(drop=True), use_container_width=True, hide_index=True)
|
| 340 |
|
| 341 |
|
|
@@ -380,22 +352,22 @@ def render_whale_single_ticker():
|
|
| 380 |
return
|
| 381 |
|
| 382 |
median_vol = df[df["Volume"] > 0]["Volume"].median()
|
| 383 |
-
mean_vol
|
| 384 |
|
| 385 |
df["Price_Change"] = df["Close"].diff()
|
| 386 |
-
df["Pct_Change"]
|
| 387 |
-
df["Vol_Ratio"]
|
| 388 |
-
df["is_notable"]
|
| 389 |
|
| 390 |
df[["Delta", "Sentiment"]] = df.apply(lambda r: pd.Series(get_sentiment(r)), axis=1)
|
| 391 |
df["Cum_Delta"] = df["Delta"].cumsum()
|
| 392 |
-
df["Size"]
|
| 393 |
|
| 394 |
st.subheader(f"{symbol} β Session: {last_session.date()}")
|
| 395 |
c1, c2, c3, c4 = st.columns(4)
|
| 396 |
c1.metric("Median 1-min Volume", f"{median_vol:,.0f}")
|
| 397 |
-
c2.metric("Mean 1-min Volume",
|
| 398 |
-
c3.metric("Flag Threshold",
|
| 399 |
c4.metric(
|
| 400 |
"Last Price",
|
| 401 |
f"${df['Close'].iloc[-1]:.2f}",
|
|
@@ -414,8 +386,7 @@ def render_whale_single_ticker():
|
|
| 414 |
table.style.format(
|
| 415 |
{"Volume": "{:,.0f}", "Vol_Ratio": "{:.1f}x", "Close": "${:.2f}", "Pct_Change": "{:+.2f}%"}
|
| 416 |
),
|
| 417 |
-
use_container_width=True,
|
| 418 |
-
height=400,
|
| 419 |
)
|
| 420 |
|
| 421 |
st.markdown("### Cumulative Buy/Sell Delta")
|
|
@@ -423,14 +394,13 @@ def render_whale_single_ticker():
|
|
| 423 |
chart_df.index = chart_df.index.strftime("%H:%M")
|
| 424 |
st.line_chart(chart_df)
|
| 425 |
|
| 426 |
-
total_buy
|
| 427 |
total_sell = abs(df[df["Delta"] < 0]["Delta"].sum())
|
| 428 |
-
net
|
| 429 |
-
|
| 430 |
-
whale_buys = df[(df["Size"] == "WHALE") & (df["Sentiment"] == "BUY")]["Volume"].sum()
|
| 431 |
whale_sells = df[(df["Size"] == "WHALE") & (df["Sentiment"] == "SELL")]["Volume"].sum()
|
| 432 |
-
inst_buys
|
| 433 |
-
inst_sells
|
| 434 |
|
| 435 |
st.markdown("### Session Analysis")
|
| 436 |
colA, colB, colC = st.columns(3)
|
|
@@ -458,9 +428,9 @@ def render_whale_single_ticker():
|
|
| 458 |
rows = []
|
| 459 |
total_vol = df["Volume"].sum()
|
| 460 |
for size in ["WHALE", "INSTITUTIONAL", "LARGE", "MEDIUM", "RETAIL"]:
|
| 461 |
-
vol
|
| 462 |
count = len(df[df["Size"] == size])
|
| 463 |
-
pct
|
| 464 |
rows.append({"Size": size, "Volume": vol, "% of Total": round(pct, 1), "Candles": count})
|
| 465 |
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 466 |
|
|
@@ -504,8 +474,8 @@ def label_peaks(df: pd.DataFrame, target_drop: float, window: int) -> pd.DataFra
|
|
| 504 |
def add_peak_features(df: pd.DataFrame, vol_sensitivity: float) -> pd.DataFrame:
|
| 505 |
d = df.copy()
|
| 506 |
delta = d["Close"].diff()
|
| 507 |
-
gain
|
| 508 |
-
loss
|
| 509 |
d["RSI"] = 100 - (100 / (1 + (gain / (loss + 1e-9))))
|
| 510 |
d["Div_Warning"] = ((d["High"] >= d["High"].shift(1)) & (d["RSI"] < d["RSI"].shift(1))).astype(int)
|
| 511 |
d["Vol_Rel"] = d["Volume"] / d["Volume"].rolling(20).mean()
|
|
@@ -549,10 +519,10 @@ def render_peak_detector():
|
|
| 549 |
with st.sidebar:
|
| 550 |
st.markdown("---")
|
| 551 |
st.header("Peak Detector Settings")
|
| 552 |
-
pd_ticker
|
| 553 |
-
pd_custom
|
| 554 |
-
target_drop
|
| 555 |
-
window
|
| 556 |
vol_sensitivity = st.slider("Volume sensitivity", 0.5, 3.0, 1.0, 0.1, key="pd_vol")
|
| 557 |
if st.button("π Retrain model", key="pd_refresh"):
|
| 558 |
run_peak_model.clear()
|
|
@@ -569,27 +539,23 @@ def render_peak_detector():
|
|
| 569 |
|
| 570 |
st.subheader(f"{ticker}")
|
| 571 |
c1, c2, c3 = st.columns(3)
|
| 572 |
-
c1.metric("Status",
|
| 573 |
-
c2.metric("Risk %",
|
| 574 |
-
c3.metric("RSI",
|
| 575 |
|
| 576 |
plot_df = data.iloc[-240:].copy()
|
| 577 |
-
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True,
|
| 578 |
-
|
| 579 |
ax1.plot(plot_df["Date"], plot_df["Close"], color="black", linewidth=1)
|
| 580 |
ax1.fill_between(plot_df["Date"], plot_df["Close"], plot_df["RSI_Blowoff_Band"], color="red", alpha=0.1)
|
| 581 |
-
|
| 582 |
risk_days = plot_df[plot_df["Risk_Pct"] > 50]
|
| 583 |
ax1.scatter(risk_days["Date"], risk_days["High"] * 1.02, color="red", marker="D", s=50)
|
| 584 |
for _, row in risk_days.iterrows():
|
| 585 |
ax1.text(row["Date"], row["High"] * 1.03, "!", color="red", fontweight="bold", ha="center")
|
| 586 |
-
|
| 587 |
ax1.set_title(f"{ticker} | Price vs Risk Heatmap")
|
| 588 |
-
|
| 589 |
ax2.fill_between(plot_df["Date"], 0, plot_df["Risk_Pct"], color="purple", alpha=0.4)
|
| 590 |
ax2.axhline(50, color="red", linestyle="--", alpha=0.3)
|
| 591 |
ax2.set_ylabel("Drop Risk %")
|
| 592 |
-
|
| 593 |
plt.tight_layout()
|
| 594 |
st.pyplot(fig)
|
| 595 |
plt.close(fig)
|
|
@@ -601,43 +567,38 @@ def render_peak_detector():
|
|
| 601 |
|
| 602 |
|
| 603 |
# =========================================================
|
| 604 |
-
# TAB 3:
|
| 605 |
# =========================================================
|
| 606 |
|
| 607 |
def sniper_rsi(series, period=14):
|
| 608 |
delta = series.diff()
|
| 609 |
-
gain
|
| 610 |
-
loss
|
| 611 |
-
rs
|
| 612 |
return 100 - (100 / (1 + rs))
|
| 613 |
|
| 614 |
|
| 615 |
def get_strict_stats(df, rsi, sma, current_stretch, rsi_threshold, strict_window,
|
| 616 |
forward_1d=1, forward_5d=5, forward_3m=63):
|
| 617 |
-
close
|
| 618 |
stretches = ((close - sma.values) / sma.values) * 100
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
ret_3m = (pd.Series(close).shift(-forward_3m).values - close) / close
|
| 623 |
-
|
| 624 |
mask_stretch_lower = stretches >= current_stretch - strict_window
|
| 625 |
mask_stretch_upper = stretches <= current_stretch + strict_window
|
| 626 |
mask_rsi_threshold = (rsi < rsi_threshold).values
|
| 627 |
-
|
| 628 |
-
mask = mask_stretch_lower & mask_stretch_upper & mask_rsi_threshold
|
| 629 |
valid_mask = mask & (~np.isnan(ret_3m))
|
| 630 |
match_indices = np.where(valid_mask)[0]
|
| 631 |
-
|
| 632 |
if len(match_indices) < 10:
|
| 633 |
return None
|
| 634 |
-
|
| 635 |
return {
|
| 636 |
-
"count":
|
| 637 |
-
"avg_1d":
|
| 638 |
-
"avg_5d":
|
| 639 |
-
"avg_3m":
|
| 640 |
-
"win_3m":
|
| 641 |
"match_dates": df.index[match_indices],
|
| 642 |
"match_prices": close[match_indices],
|
| 643 |
}
|
|
@@ -647,46 +608,35 @@ def analyze_ticker(df, rsi_threshold, stretch_threshold, strict_window, lookback
|
|
| 647 |
df = df.dropna()
|
| 648 |
if len(df) < 200:
|
| 649 |
return None
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
sma20 = close.rolling(20).mean()
|
| 654 |
-
|
| 655 |
current_stretch = ((close.iloc[-1] - sma20.iloc[-1]) / sma20.iloc[-1]) * 100
|
| 656 |
-
|
| 657 |
result = {
|
| 658 |
-
"rsi": rsi.iloc[-1],
|
| 659 |
-
"
|
| 660 |
-
"
|
| 661 |
-
"stats": None,
|
| 662 |
-
"fractal_target": None,
|
| 663 |
-
"df": df,
|
| 664 |
-
"rsi_series": rsi,
|
| 665 |
-
"signal_met": False,
|
| 666 |
}
|
| 667 |
-
|
| 668 |
try:
|
| 669 |
hist = np.squeeze(close.values[:-90])
|
| 670 |
if hist.ndim == 1 and len(hist) > lookback:
|
| 671 |
-
curr
|
| 672 |
curr_n = (curr - np.mean(curr)) / (np.std(curr) + 1e-9)
|
| 673 |
-
wins
|
| 674 |
wins_n = (wins - np.mean(wins, axis=1, keepdims=True)) / (np.std(wins, axis=1, keepdims=True) + 1e-9)
|
| 675 |
-
corrs
|
| 676 |
top_idx = np.argmax(corrs)
|
| 677 |
result["fractal_target"] = close.values[-1] * (
|
| 678 |
close.values[top_idx + lookback] / close.values[top_idx + lookback - 1]
|
| 679 |
)
|
| 680 |
except Exception:
|
| 681 |
pass
|
| 682 |
-
|
| 683 |
if result["rsi"] < rsi_threshold and current_stretch < stretch_threshold:
|
| 684 |
stats = get_strict_stats(df, rsi, sma20, current_stretch, rsi_threshold, strict_window)
|
| 685 |
result["stats"] = stats
|
| 686 |
if stats:
|
| 687 |
is_triple_positive = stats["avg_1d"] > 0 and stats["avg_5d"] > 0 and stats["avg_3m"] > 0
|
| 688 |
result["signal_met"] = is_triple_positive and stats["win_3m"] >= win_rate_min
|
| 689 |
-
|
| 690 |
return result
|
| 691 |
|
| 692 |
|
|
@@ -699,46 +649,23 @@ def get_sniper_ticker_data(ticker: str) -> pd.DataFrame:
|
|
| 699 |
return df.dropna()
|
| 700 |
|
| 701 |
|
| 702 |
-
@st.cache_data(ttl=24 * 3600, show_spinner="Fetching ticker universe...")
|
| 703 |
-
def get_universe_tickers(include_russell: bool, extra_tickers: tuple):
|
| 704 |
-
tickers = set()
|
| 705 |
-
try:
|
| 706 |
-
sp500 = pd.read_csv(
|
| 707 |
-
"https://raw.githubusercontent.com/datasets/s-and-p-500-companies/master/data/constituents.csv"
|
| 708 |
-
)["Symbol"].tolist()
|
| 709 |
-
tickers.update(t.replace(".", "-") for t in sp500)
|
| 710 |
-
except Exception as e:
|
| 711 |
-
st.warning(f"Could not fetch S&P 500 list: {e}")
|
| 712 |
-
|
| 713 |
-
if include_russell:
|
| 714 |
-
try:
|
| 715 |
-
russell = pd.read_csv(
|
| 716 |
-
"https://raw.githubusercontent.com/ikoniaris/Russell2000/master/russell_2000_components.csv"
|
| 717 |
-
)["Ticker"].tolist()
|
| 718 |
-
tickers.update(t.replace(".", "-") for t in russell)
|
| 719 |
-
except Exception as e:
|
| 720 |
-
st.warning(f"Could not fetch Russell 2000 list: {e}")
|
| 721 |
-
|
| 722 |
-
tickers.update(extra_tickers)
|
| 723 |
-
return sorted(tickers)
|
| 724 |
-
|
| 725 |
-
|
| 726 |
@st.cache_data(ttl=12 * 3600, show_spinner=False)
|
| 727 |
def run_full_scan(universe_label, extra_tickers_tuple, rsi_threshold, stretch_threshold,
|
| 728 |
strict_window, lookback, win_rate_min, _tickers):
|
| 729 |
-
results
|
| 730 |
batch_size = 10
|
| 731 |
total_batches = max(1, (len(_tickers) + batch_size - 1) // batch_size)
|
| 732 |
-
progress
|
| 733 |
|
| 734 |
for b, i in enumerate(range(0, len(_tickers), batch_size)):
|
| 735 |
batch = _tickers[i:i + batch_size]
|
| 736 |
try:
|
| 737 |
-
data = yf.download(batch, period="max", interval="1d", group_by="ticker",
|
|
|
|
| 738 |
except Exception:
|
| 739 |
-
progress.progress((b + 1) / total_batches,
|
|
|
|
| 740 |
continue
|
| 741 |
-
|
| 742 |
for ticker in batch:
|
| 743 |
try:
|
| 744 |
try:
|
|
@@ -748,23 +675,23 @@ def run_full_scan(universe_label, extra_tickers_tuple, rsi_threshold, stretch_th
|
|
| 748 |
df = raw.dropna()
|
| 749 |
if len(df) < 200:
|
| 750 |
continue
|
| 751 |
-
result = analyze_ticker(df, rsi_threshold, stretch_threshold,
|
|
|
|
| 752 |
if result and result["signal_met"]:
|
| 753 |
stats = result["stats"]
|
| 754 |
results.append({
|
| 755 |
-
"Ticker":
|
| 756 |
-
"Price":
|
| 757 |
-
"RSI":
|
| 758 |
-
"Stretch %":
|
| 759 |
-
"Matches":
|
| 760 |
-
"Avg_1D":
|
| 761 |
-
"Avg_5D":
|
| 762 |
-
"Avg_3M":
|
| 763 |
"WinRate_3M": round(stats["win_3m"], 1),
|
| 764 |
})
|
| 765 |
except Exception:
|
| 766 |
continue
|
| 767 |
-
|
| 768 |
progress.progress((b + 1) / total_batches, text=f"Scanned batch {b + 1}/{total_batches}...")
|
| 769 |
|
| 770 |
progress.empty()
|
|
@@ -772,56 +699,50 @@ def run_full_scan(universe_label, extra_tickers_tuple, rsi_threshold, stretch_th
|
|
| 772 |
|
| 773 |
|
| 774 |
def render_sniper_dashboard(ticker, result):
|
| 775 |
-
df
|
| 776 |
rsi_series = result["rsi_series"]
|
| 777 |
-
stats
|
| 778 |
-
target
|
| 779 |
|
| 780 |
fig = plt.figure(figsize=(12, 8))
|
| 781 |
-
gs
|
| 782 |
-
|
| 783 |
ax1 = fig.add_subplot(gs[0, :])
|
| 784 |
ax1.plot(df.index, df["Close"], color="gray", alpha=0.3, label="History")
|
| 785 |
recent = df.tail(120)
|
| 786 |
ax1.plot(recent.index, recent["Close"], color="black", linewidth=1.5, label="Current Trend")
|
| 787 |
-
|
| 788 |
if stats:
|
| 789 |
-
ax1.scatter(stats["match_dates"], stats["match_prices"],
|
| 790 |
-
|
| 791 |
if target is not None:
|
| 792 |
-
ax1.axhline(target, color="blue", ls="--", alpha=0.6,
|
| 793 |
-
|
| 794 |
ax1.set_title(f"{ticker}", fontsize=16, fontweight="bold")
|
| 795 |
ax1.legend(loc="upper left", fontsize=9)
|
| 796 |
ax1.grid(True, alpha=0.1)
|
| 797 |
-
|
| 798 |
if stats:
|
| 799 |
stats_text = (
|
| 800 |
-
f"Historical Matches: {stats['count']}\n"
|
| 801 |
-
f"Avg Change 1D: {stats['avg_1d']:+.2f}%\n"
|
| 802 |
-
f"Avg Change 5D: {stats['avg_5d']:+.2f}%\n"
|
| 803 |
-
f"Avg Change 3M: {stats['avg_3m']:+.2f}%\n"
|
| 804 |
f"3M Win Rate: {stats['win_3m']:.1f}% (n={stats['count']})"
|
| 805 |
)
|
| 806 |
ax1.text(0.98, 0.05, stats_text, transform=ax1.transAxes, va="bottom", ha="right",
|
| 807 |
bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="darkgreen"),
|
| 808 |
fontsize=10, family="monospace")
|
| 809 |
-
|
| 810 |
ax2 = fig.add_subplot(gs[1, 0])
|
| 811 |
ax2.plot(recent.index, rsi_series.tail(120), color="purple")
|
| 812 |
ax2.axhline(result["rsi"], color="orange", ls=":")
|
| 813 |
ax2.set_title(f"RSI: {result['rsi']:.1f}")
|
| 814 |
-
|
| 815 |
ax3 = fig.add_subplot(gs[1, 1])
|
| 816 |
if stats:
|
| 817 |
horizons = ["1D", "5D", "3M"]
|
| 818 |
-
values
|
| 819 |
ax3.bar(horizons, values, color="limegreen", alpha=0.8)
|
| 820 |
ax3.axhline(0, color="black", linewidth=0.8)
|
| 821 |
ax3.set_title("Historical Edge by Horizon")
|
| 822 |
else:
|
| 823 |
ax3.set_title("No matches to show")
|
| 824 |
-
|
| 825 |
plt.tight_layout()
|
| 826 |
st.pyplot(fig)
|
| 827 |
plt.close(fig)
|
|
@@ -832,12 +753,10 @@ def render_quick_forecast(ticker: str):
|
|
| 832 |
if raw.empty:
|
| 833 |
st.error(f"No data for '{ticker}'.")
|
| 834 |
return
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
context = raw[raw.index >= (anchor_date - pd.Timedelta(days=90))]
|
| 838 |
current_price = raw["Close"].iloc[-1]
|
| 839 |
-
|
| 840 |
-
projection = compute_analog_projection(ticker, str(anchor_date.date()))
|
| 841 |
|
| 842 |
fig, ax = plt.subplots(figsize=(12, 5))
|
| 843 |
if not context.empty:
|
|
@@ -845,7 +764,6 @@ def render_quick_forecast(ticker: str):
|
|
| 845 |
marker="o", markersize=2, label="Price")
|
| 846 |
ax.axvline(anchor_date, color="red", ls="-", alpha=0.4, label="Today")
|
| 847 |
ax.axhline(current_price, color="gray", ls=":", alpha=0.6, label="Current price")
|
| 848 |
-
|
| 849 |
if projection:
|
| 850 |
ax.plot(projection["future_dates"], projection["weighted_mean"],
|
| 851 |
color="green", ls="--", linewidth=2, label="Analog-weighted projection")
|
|
@@ -854,12 +772,10 @@ def render_quick_forecast(ticker: str):
|
|
| 854 |
st.caption(
|
| 855 |
f"Projection built from the {projection['match_count']} most similar non-overlapping "
|
| 856 |
f"60-day windows in {ticker}'s own history (robust similarity: "
|
| 857 |
-
f"{projection['robust_similarity']:.1f}%).
|
| 858 |
-
f"a handful of historical echoes, not independent confirming evidence."
|
| 859 |
)
|
| 860 |
else:
|
| 861 |
st.info("Not enough history for this ticker to build a projection.")
|
| 862 |
-
|
| 863 |
ax.set_title(f"{ticker} β forecast as of {anchor_date.date()}")
|
| 864 |
ax.legend(loc="best", fontsize=9)
|
| 865 |
ax.grid(True, alpha=0.1)
|
|
@@ -875,25 +791,23 @@ def render_sniper_tab():
|
|
| 875 |
"forward returns and win rate."
|
| 876 |
)
|
| 877 |
st.warning(
|
| 878 |
-
"β οΈ Two things worth keeping in mind:\n\n"
|
| 879 |
"1. **Small samples** β many qualifying setups have only 10-30 historical matches. An '80% win rate' "
|
| 880 |
-
"on 10 matches
|
| 881 |
-
"
|
| 882 |
-
"
|
| 883 |
-
"
|
| 884 |
-
"
|
| 885 |
-
"The dashed blue line ('best-match analog') is one single closest-matching historical window, shown "
|
| 886 |
-
"for context β not a statistical price target."
|
| 887 |
)
|
| 888 |
|
| 889 |
with st.sidebar:
|
| 890 |
st.markdown("---")
|
| 891 |
st.header("Bottom Scanner Settings")
|
| 892 |
-
rsi_threshold
|
| 893 |
stretch_threshold = st.slider("Max SMA20 stretch %", -8.0, 0.0, -2.0, 0.5, key="sn_stretch")
|
| 894 |
-
strict_window
|
| 895 |
-
lookback
|
| 896 |
-
win_rate_min
|
| 897 |
|
| 898 |
settings = {
|
| 899 |
"rsi_threshold": rsi_threshold, "stretch_threshold": stretch_threshold,
|
|
@@ -901,9 +815,9 @@ def render_sniper_tab():
|
|
| 901 |
}
|
| 902 |
|
| 903 |
st.subheader("Analyze a single ticker")
|
| 904 |
-
single_ticker
|
| 905 |
col_analyze, col_forecast = st.columns([1, 1])
|
| 906 |
-
analyze_clicked
|
| 907 |
forecast_clicked = col_forecast.button("Forecast", key="sn_forecast_btn")
|
| 908 |
|
| 909 |
if analyze_clicked and single_ticker:
|
|
@@ -911,7 +825,8 @@ def render_sniper_tab():
|
|
| 911 |
if raw.empty:
|
| 912 |
st.error(f"No data for '{single_ticker}'.")
|
| 913 |
else:
|
| 914 |
-
result = analyze_ticker(raw, rsi_threshold, stretch_threshold,
|
|
|
|
| 915 |
if result is None:
|
| 916 |
st.error("Not enough history for this ticker.")
|
| 917 |
else:
|
|
@@ -919,13 +834,11 @@ def render_sniper_tab():
|
|
| 919 |
if not entry_met:
|
| 920 |
st.info(
|
| 921 |
f"RSI {result['rsi']:.1f} / Stretch {result['stretch']:.2f}% β entry conditions "
|
| 922 |
-
f"(RSI < {rsi_threshold}, stretch < {stretch_threshold}%) not currently met.
|
| 923 |
-
"Showing current state for reference."
|
| 924 |
)
|
| 925 |
elif result["stats"] is None:
|
| 926 |
st.info(
|
| 927 |
-
f"Entry conditions met
|
| 928 |
-
"but fewer than 10 historical analogs found β not enough data to score."
|
| 929 |
)
|
| 930 |
elif result["signal_met"]:
|
| 931 |
st.success(
|
|
@@ -936,7 +849,8 @@ def render_sniper_tab():
|
|
| 936 |
hit_row = pd.DataFrame([{
|
| 937 |
"Ticker": single_ticker, "Price": round(result["price"], 2),
|
| 938 |
"RSI": round(result["rsi"], 1), "Stretch %": round(result["stretch"], 2),
|
| 939 |
-
"Matches": result["stats"]["count"],
|
|
|
|
| 940 |
"Avg_5D": round(result["stats"]["avg_5d"], 2),
|
| 941 |
"Avg_3M": round(result["stats"]["avg_3m"], 2),
|
| 942 |
"WinRate_3M": round(result["stats"]["win_3m"], 1),
|
|
@@ -956,15 +870,14 @@ def render_sniper_tab():
|
|
| 956 |
st.markdown("---")
|
| 957 |
st.subheader("Full universe scan")
|
| 958 |
st.caption(
|
| 959 |
-
"
|
| 960 |
-
"
|
| 961 |
-
"
|
| 962 |
-
f"{'β
Hits will be auto-saved to your signal log.' if _log_configured() else 'β οΈ Signal log storage not configured β hits will NOT be saved. See the Signal Tracker tab.'}"
|
| 963 |
)
|
| 964 |
if st.button("π Run Full Universe Scan", key="sn_scan_btn"):
|
| 965 |
tickers = get_clean_premarket_universe()
|
| 966 |
if not tickers:
|
| 967 |
-
st.error("No tickers loaded from the curated list
|
| 968 |
else:
|
| 969 |
st.write(f"Scanning {len(tickers)} tickers...")
|
| 970 |
results_df = run_full_scan(
|
|
@@ -972,7 +885,6 @@ def render_sniper_tab():
|
|
| 972 |
strict_window, lookback, win_rate_min, tuple(tickers),
|
| 973 |
)
|
| 974 |
st.session_state["sn_results"] = results_df
|
| 975 |
-
|
| 976 |
if not results_df.empty and _log_configured():
|
| 977 |
if append_hits_to_log(results_df, settings):
|
| 978 |
st.success(f"Logged {len(results_df)} hit(s) to the signal tracker.")
|
|
@@ -985,39 +897,37 @@ def render_sniper_tab():
|
|
| 985 |
st.success(f"{len(results_df)} ticker(s) met the criteria.")
|
| 986 |
sorted_df = results_df.sort_values("WinRate_3M", ascending=False).reset_index(drop=True)
|
| 987 |
st.dataframe(sorted_df, use_container_width=True, hide_index=True)
|
| 988 |
-
|
| 989 |
pick = st.selectbox("View chart for:", sorted_df["Ticker"].tolist(), key="sn_pick")
|
| 990 |
if pick:
|
| 991 |
raw = get_sniper_ticker_data(pick)
|
| 992 |
if not raw.empty:
|
| 993 |
-
result = analyze_ticker(raw, rsi_threshold, stretch_threshold,
|
| 994 |
-
|
| 995 |
if result:
|
| 996 |
render_sniper_dashboard(pick, result)
|
| 997 |
|
| 998 |
|
| 999 |
# =========================================================
|
| 1000 |
-
# ANALOG PATH PROJECTION
|
| 1001 |
# =========================================================
|
| 1002 |
|
| 1003 |
-
ANALOG_LOOKBACK
|
| 1004 |
-
ANALOG_FORWARD
|
| 1005 |
-
ANALOG_TOP_N
|
| 1006 |
ANALOG_WEIGHT_STRENGTH = 5
|
| 1007 |
|
| 1008 |
|
| 1009 |
def _similarity_matches(close, end_idx, lookback, forward):
|
| 1010 |
if end_idx - lookback < 0:
|
| 1011 |
return []
|
| 1012 |
-
recent
|
| 1013 |
recent_norm = (recent / recent.iloc[0]).values.flatten()
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
max_search = end_idx - lookback - forward
|
| 1017 |
for i in range(max_search):
|
| 1018 |
-
cand
|
| 1019 |
cand_norm = (cand / cand.iloc[0]).values.flatten()
|
| 1020 |
-
corr
|
| 1021 |
if not np.isnan(corr):
|
| 1022 |
matches.append((float(corr), i))
|
| 1023 |
return matches
|
|
@@ -1030,18 +940,15 @@ def compute_analog_projection(ticker: str, flag_date_str: str, lookback=ANALOG_L
|
|
| 1030 |
hist = get_sniper_ticker_data(ticker)
|
| 1031 |
if hist.empty:
|
| 1032 |
return None
|
| 1033 |
-
close
|
| 1034 |
flag_date = pd.Timestamp(flag_date_str)
|
| 1035 |
-
end_idx
|
| 1036 |
-
end_idx
|
| 1037 |
-
|
| 1038 |
if end_idx - lookback - forward < 5:
|
| 1039 |
-
return None
|
| 1040 |
-
|
| 1041 |
matches = _similarity_matches(close, end_idx, lookback, forward)
|
| 1042 |
if not matches:
|
| 1043 |
return None
|
| 1044 |
-
|
| 1045 |
matches.sort(key=lambda x: x[0], reverse=True)
|
| 1046 |
filtered, used = [], []
|
| 1047 |
for corr, idx in matches:
|
|
@@ -1052,37 +959,32 @@ def compute_analog_projection(ticker: str, flag_date_str: str, lookback=ANALOG_L
|
|
| 1052 |
break
|
| 1053 |
if not filtered:
|
| 1054 |
return None
|
| 1055 |
-
|
| 1056 |
-
corrs = np.array([c for c, _ in filtered])
|
| 1057 |
weights = np.exp(weight_strength * corrs)
|
| 1058 |
weights /= weights.sum()
|
| 1059 |
-
|
| 1060 |
anchor_price = close.iloc[end_idx]
|
| 1061 |
-
projections
|
| 1062 |
for corr, idx in filtered:
|
| 1063 |
-
fwd
|
| 1064 |
fwd_norm = (fwd / fwd.iloc[0]).values.flatten()
|
| 1065 |
projections.append(fwd_norm * anchor_price)
|
| 1066 |
-
projections
|
| 1067 |
-
|
| 1068 |
weighted_mean = np.average(projections, axis=0, weights=weights)
|
| 1069 |
-
weighted_var
|
| 1070 |
-
weighted_std
|
| 1071 |
-
|
| 1072 |
-
future_dates = pd.bdate_range(start=close.index[end_idx], periods=forward + 1)[1:]
|
| 1073 |
-
|
| 1074 |
return {
|
| 1075 |
-
"future_dates":
|
| 1076 |
-
"weighted_mean":
|
| 1077 |
-
"upper":
|
| 1078 |
-
"lower":
|
| 1079 |
"robust_similarity": float(np.sqrt(np.sum(weights * corrs ** 2)) * 100),
|
| 1080 |
-
"match_count":
|
| 1081 |
}
|
| 1082 |
|
| 1083 |
|
| 1084 |
# =========================================================
|
| 1085 |
-
# TAB 4: SIGNAL TRACKER
|
| 1086 |
# =========================================================
|
| 1087 |
|
| 1088 |
def render_signal_tracker():
|
|
@@ -1095,49 +997,43 @@ def render_signal_tracker():
|
|
| 1095 |
if not _log_configured():
|
| 1096 |
st.error(
|
| 1097 |
"Signal log storage isn't configured. In this Space's Settings, add a Secret named "
|
| 1098 |
-
"`HF_TOKEN`
|
| 1099 |
-
"(e.g. `your-username/sniper-signal-log`), then restart the Space."
|
| 1100 |
)
|
| 1101 |
return
|
| 1102 |
|
| 1103 |
log = load_signal_log()
|
| 1104 |
if log.empty:
|
| 1105 |
-
st.info(
|
| 1106 |
-
"No signals logged yet. Run a Full Universe Scan on the Bottom Scanner tab β qualifying "
|
| 1107 |
-
"hits are saved here automatically."
|
| 1108 |
-
)
|
| 1109 |
return
|
| 1110 |
|
| 1111 |
-
log["FlagDate"]
|
| 1112 |
log["DaysSinceFlag"] = (pd.Timestamp.now().normalize() - log["FlagDate"]).dt.days
|
| 1113 |
|
| 1114 |
st.markdown(f"### {len(log)} signals logged across {log['Ticker'].nunique()} tickers")
|
| 1115 |
|
| 1116 |
summary_rows = []
|
| 1117 |
for _, row in log.iterrows():
|
| 1118 |
-
ticker
|
| 1119 |
-
flag_price
|
| 1120 |
-
days_elapsed
|
| 1121 |
actual_return = np.nan
|
| 1122 |
try:
|
| 1123 |
hist = get_sniper_ticker_data(ticker)
|
| 1124 |
if not hist.empty and pd.notna(flag_price) and flag_price > 0:
|
| 1125 |
-
|
| 1126 |
-
actual_return = (current_price - flag_price) / flag_price * 100
|
| 1127 |
except Exception:
|
| 1128 |
pass
|
| 1129 |
-
|
| 1130 |
status = "Complete (90D+)" if days_elapsed >= 90 else f"In progress ({days_elapsed}d/90d)"
|
| 1131 |
summary_rows.append({
|
| 1132 |
-
"Ticker":
|
| 1133 |
-
"Flag Date":
|
| 1134 |
-
"Days Elapsed":
|
| 1135 |
-
"Status":
|
| 1136 |
-
"Matches":
|
| 1137 |
-
"Predicted Avg 3M %":
|
| 1138 |
-
"Predicted Win Rate %":
|
| 1139 |
"Actual Return % (to date)": round(actual_return, 2) if pd.notna(actual_return) else None,
|
| 1140 |
-
"On Track":
|
| 1141 |
})
|
| 1142 |
|
| 1143 |
summary_df = pd.DataFrame(summary_rows).sort_values("Flag Date", ascending=False)
|
|
@@ -1148,36 +1044,33 @@ def render_signal_tracker():
|
|
| 1148 |
hit_rate = (completed["On Track"] == True).mean() * 100
|
| 1149 |
st.metric("Realized hit rate on completed 90-day signals", f"{hit_rate:.0f}% (n={len(completed)})")
|
| 1150 |
else:
|
| 1151 |
-
st.caption("No signals have reached the
|
| 1152 |
|
| 1153 |
st.download_button(
|
| 1154 |
"β¬οΈ Download full signal log (CSV)",
|
| 1155 |
data=log.to_csv(index=False).encode("utf-8"),
|
| 1156 |
-
file_name="signal_log.csv",
|
| 1157 |
-
mime="text/csv",
|
| 1158 |
)
|
| 1159 |
|
| 1160 |
st.markdown("---")
|
| 1161 |
st.subheader("Chart a specific signal")
|
| 1162 |
options = [f"{r['Ticker']} β flagged {r['Flag Date']}" for _, r in summary_df.iterrows()]
|
| 1163 |
-
pick
|
| 1164 |
if pick:
|
| 1165 |
ticker, flag_date_str = pick.split(" β flagged ")
|
| 1166 |
match = log[(log["Ticker"] == ticker) & (log["FlagDate"].dt.date.astype(str) == flag_date_str)]
|
| 1167 |
if not match.empty:
|
| 1168 |
-
row
|
| 1169 |
-
hist
|
| 1170 |
if not hist.empty:
|
| 1171 |
flag_date = row["FlagDate"]
|
| 1172 |
-
context
|
| 1173 |
on_or_after = context[context.index >= flag_date]
|
| 1174 |
-
flag_price
|
| 1175 |
if pd.isna(flag_price):
|
| 1176 |
-
if not on_or_after.empty
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
flag_price = context["Close"].iloc[-1]
|
| 1180 |
-
|
| 1181 |
fig, ax = plt.subplots(figsize=(12, 5))
|
| 1182 |
if not context.empty:
|
| 1183 |
ax.plot(context.index, context["Close"], color="black", linewidth=1.5,
|
|
@@ -1185,7 +1078,6 @@ def render_signal_tracker():
|
|
| 1185 |
ax.axvline(flag_date, color="red", ls="-", alpha=0.4, label="Flagged here")
|
| 1186 |
if pd.notna(flag_price):
|
| 1187 |
ax.axhline(flag_price, color="gray", ls=":", alpha=0.6, label="Price at flag")
|
| 1188 |
-
|
| 1189 |
projection = compute_analog_projection(ticker, str(flag_date.date()))
|
| 1190 |
if projection:
|
| 1191 |
ax.plot(projection["future_dates"], projection["weighted_mean"],
|
|
@@ -1194,12 +1086,9 @@ def render_signal_tracker():
|
|
| 1194 |
color="green", alpha=0.15, label="Projection band (1Ο)")
|
| 1195 |
st.caption(
|
| 1196 |
f"Projection built from the {projection['match_count']} most similar non-overlapping "
|
| 1197 |
-
f"60-day windows in {ticker}'s own history
|
| 1198 |
-
f"(robust similarity: {projection['robust_similarity']:.1f}%).
|
| 1199 |
-
f"comparable past setups did,' not a calibrated forecast β a handful of historical "
|
| 1200 |
-
f"echoes, not independent confirming evidence."
|
| 1201 |
)
|
| 1202 |
-
|
| 1203 |
ax.set_title(f"{ticker} β flagged {flag_date.date()}")
|
| 1204 |
ax.legend(loc="best", fontsize=9)
|
| 1205 |
ax.grid(True, alpha=0.1)
|
|
@@ -1208,15 +1097,15 @@ def render_signal_tracker():
|
|
| 1208 |
|
| 1209 |
|
| 1210 |
# =========================================================
|
| 1211 |
-
# TAB 5: ROTATION MAP
|
| 1212 |
# =========================================================
|
| 1213 |
|
| 1214 |
-
DEFAULT_ROTATION_TICKERS = ["XLK", "XLB", "XLF", "XLE", "XLV", "XLI", "XLU", "XLP", "XLRE", "XLY"
|
| 1215 |
|
| 1216 |
|
| 1217 |
@st.cache_data(ttl=4 * 3600, show_spinner="Building rotation map...")
|
| 1218 |
def compute_rotation_data(tickers_tuple, benchmark, lookback_years, window):
|
| 1219 |
-
symbols
|
| 1220 |
start_date = (pd.Timestamp.now() - pd.DateOffset(years=lookback_years)).strftime("%Y-%m-%d")
|
| 1221 |
try:
|
| 1222 |
data = yf.download(symbols, start=start_date, progress=False)["Close"]
|
|
@@ -1224,43 +1113,33 @@ def compute_rotation_data(tickers_tuple, benchmark, lookback_years, window):
|
|
| 1224 |
return None, list(tickers_tuple)
|
| 1225 |
if isinstance(data, pd.Series):
|
| 1226 |
data = data.to_frame()
|
| 1227 |
-
|
| 1228 |
-
weekly = data.resample("W-FRI").last()
|
| 1229 |
-
|
| 1230 |
missing = [t for t in tickers_tuple if t not in weekly.columns or weekly[t].dropna().empty]
|
| 1231 |
valid_tickers = [t for t in tickers_tuple if t not in missing]
|
| 1232 |
-
|
| 1233 |
if benchmark not in weekly.columns or weekly[benchmark].dropna().empty or not valid_tickers:
|
| 1234 |
return None, missing
|
| 1235 |
-
|
| 1236 |
-
rs_df
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
rs_ratio = rs_ratio.dropna()
|
| 1241 |
-
rs_momentum = rs_momentum.dropna()
|
| 1242 |
-
|
| 1243 |
return {"rs_ratio": rs_ratio, "rs_momentum": rs_momentum, "valid_tickers": valid_tickers}, missing
|
| 1244 |
|
| 1245 |
|
| 1246 |
def render_rotation_map():
|
| 1247 |
-
st.title("
|
| 1248 |
st.caption(
|
| 1249 |
"Plots relative strength and momentum versus a benchmark for a basket of tickers, showing "
|
| 1250 |
"which quadrant each one currently sits in and how it's been moving over recent weeks."
|
| 1251 |
)
|
| 1252 |
st.info(
|
| 1253 |
-
"
|
| 1254 |
-
"performance
|
| 1255 |
-
"
|
| 1256 |
-
"but losing steam), Lagging (weak + still losing ground), Improving (weak but starting to turn)."
|
| 1257 |
)
|
| 1258 |
st.warning(
|
| 1259 |
-
"β οΈ
|
| 1260 |
-
"
|
| 1261 |
-
"own recent history, which behaves similarly but isn't textbook RRG momentum. Also worth "
|
| 1262 |
-
"knowing: small/thinly-traded names will naturally show noisier, more dramatic-looking loops "
|
| 1263 |
-
"than large liquid sector ETFs simply because their relative-strength ratio is more volatile β "
|
| 1264 |
"a big swing on a small-cap isn't necessarily more meaningful than a small swing on XLK."
|
| 1265 |
)
|
| 1266 |
|
|
@@ -1269,14 +1148,13 @@ def render_rotation_map():
|
|
| 1269 |
st.header("Rotation Map Settings")
|
| 1270 |
tickers_input = st.text_area(
|
| 1271 |
"Tickers (comma-separated)",
|
| 1272 |
-
value=", ".join(DEFAULT_ROTATION_TICKERS),
|
| 1273 |
-
key="rm_tickers",
|
| 1274 |
)
|
| 1275 |
-
benchmark
|
| 1276 |
-
trail_weeks
|
| 1277 |
lookback_years = st.slider("History to fetch (years)", 1, 5, 2, 1, key="rm_lookback")
|
| 1278 |
-
window
|
| 1279 |
-
if st.button("
|
| 1280 |
compute_rotation_data.clear()
|
| 1281 |
|
| 1282 |
tickers = tuple(t.strip().upper() for t in tickers_input.split(",") if t.strip())
|
|
@@ -1291,18 +1169,17 @@ def render_rotation_map():
|
|
| 1291 |
st.error("Not enough data to build the rotation map. Check the benchmark and ticker symbols.")
|
| 1292 |
return
|
| 1293 |
|
| 1294 |
-
rs_ratio
|
| 1295 |
-
rs_momentum
|
| 1296 |
valid_tickers = result["valid_tickers"]
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
tail_mom = rs_momentum.tail(trail_weeks)
|
| 1300 |
|
| 1301 |
if tail_ratio.empty:
|
| 1302 |
st.error("Not enough history after the rolling window β try a shorter window or longer lookback.")
|
| 1303 |
return
|
| 1304 |
|
| 1305 |
-
fig
|
| 1306 |
colors = px.colors.qualitative.Dark24
|
| 1307 |
|
| 1308 |
for i, ticker in enumerate(valid_tickers):
|
|
@@ -1310,15 +1187,14 @@ def render_rotation_map():
|
|
| 1310 |
continue
|
| 1311 |
x_data = tail_ratio[ticker].dropna().tolist()
|
| 1312 |
y_data = tail_mom[ticker].dropna().tolist() if ticker in tail_mom.columns else []
|
| 1313 |
-
n
|
| 1314 |
if n == 0:
|
| 1315 |
continue
|
| 1316 |
x_data, y_data = x_data[-n:], y_data[-n:]
|
| 1317 |
color = colors[i % len(colors)]
|
| 1318 |
fig.add_trace(go.Scatter(
|
| 1319 |
x=x_data, y=y_data, mode="lines+markers+text", name=ticker,
|
| 1320 |
-
text=[""] * (n - 1) + [ticker],
|
| 1321 |
-
textposition="top center",
|
| 1322 |
textfont=dict(size=12, color=color),
|
| 1323 |
marker=dict(size=[6] * (n - 1) + [16], color=color, symbol="circle"),
|
| 1324 |
line=dict(width=2, color=color, shape="spline"),
|
|
@@ -1326,44 +1202,47 @@ def render_rotation_map():
|
|
| 1326 |
|
| 1327 |
fig.add_hline(y=100, line_dash="dash", line_color="gray", opacity=0.5)
|
| 1328 |
fig.add_vline(x=100, line_dash="dash", line_color="gray", opacity=0.5)
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
(
|
| 1332 |
-
|
| 1333 |
-
(95, 95, "LAGGING", "red"),
|
| 1334 |
-
(95, 105, "IMPROVING", "blue"),
|
| 1335 |
-
]
|
| 1336 |
-
for x, y, text, color in quadrants:
|
| 1337 |
-
fig.add_annotation(
|
| 1338 |
-
x=x, y=y, text=text, showarrow=False,
|
| 1339 |
-
font=dict(size=20, color=color), xref="x", yref="y", opacity=0.15,
|
| 1340 |
-
)
|
| 1341 |
|
| 1342 |
fig.update_layout(
|
| 1343 |
-
title=f"
|
| 1344 |
xaxis_title="Relative Strength (RS-Ratio)",
|
| 1345 |
yaxis_title="Momentum (RS-Momentum)",
|
| 1346 |
-
height=700,
|
| 1347 |
-
template="plotly_white",
|
| 1348 |
-
hovermode="closest",
|
| 1349 |
-
showlegend=False,
|
| 1350 |
)
|
| 1351 |
-
|
| 1352 |
st.plotly_chart(fig, use_container_width=True)
|
| 1353 |
st.caption(
|
| 1354 |
"Tip: a Bottom Scanner hit sitting deep in Lagging with its tail starting to curl toward "
|
| 1355 |
-
"Improving is
|
| 1356 |
-
"
|
| 1357 |
)
|
| 1358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1359 |
|
| 1360 |
# =========================================================
|
| 1361 |
# MAIN LAYOUT
|
| 1362 |
# =========================================================
|
| 1363 |
|
| 1364 |
-
tab1, tab2, tab3, tab4, tab5 = st.tabs(
|
| 1365 |
-
|
| 1366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
|
| 1368 |
with tab1:
|
| 1369 |
render_whale_monitor()
|
|
@@ -1378,4 +1257,7 @@ with tab4:
|
|
| 1378 |
render_signal_tracker()
|
| 1379 |
|
| 1380 |
with tab5:
|
| 1381 |
-
render_rotation_map()
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import plotly.graph_objects as go
|
| 13 |
import plotly.express as px
|
| 14 |
from numpy.lib.stride_tricks import sliding_window_view
|
| 15 |
+
from scipy.stats import spearmanr, binom as binom_dist
|
| 16 |
from sklearn.ensemble import GradientBoostingClassifier
|
| 17 |
from huggingface_hub import HfApi, hf_hub_download
|
| 18 |
|
|
|
|
| 144 |
|
| 145 |
@st.cache_data(ttl=3600, show_spinner="Fetching prior-close reference prices...")
|
| 146 |
def get_daily_reference_data(tickers_tuple):
|
|
|
|
|
|
|
| 147 |
ref = {}
|
| 148 |
batch_size = 10
|
| 149 |
total_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
|
|
|
|
| 191 |
@st.cache_data(ttl=300, show_spinner=False)
|
| 192 |
def run_premarket_scan(tickers_tuple, min_gap_pct, direction):
|
| 193 |
ref_data = get_daily_reference_data(tickers_tuple)
|
|
|
|
| 194 |
results = []
|
| 195 |
batch_size = 10
|
| 196 |
total_batches = max(1, (len(tickers_tuple) + batch_size - 1) // batch_size)
|
|
|
|
| 214 |
df = raw.dropna(subset=["Close"])
|
| 215 |
if df.empty:
|
| 216 |
continue
|
|
|
|
| 217 |
if df.index.tz is not None:
|
| 218 |
df = df.copy()
|
| 219 |
df.index = df.index.tz_convert("America/New_York")
|
| 220 |
else:
|
| 221 |
df = df.copy()
|
| 222 |
df.index = df.index.tz_localize("America/New_York")
|
|
|
|
| 223 |
premarket = df[df.index.time < dt_time(9, 30)]
|
| 224 |
if premarket.empty:
|
| 225 |
continue
|
|
|
|
| 226 |
ref = ref_data.get(ticker)
|
| 227 |
if not ref or not ref.get("prev_close"):
|
| 228 |
continue
|
| 229 |
prev_close = ref["prev_close"]
|
|
|
|
| 230 |
last_price = float(premarket["Close"].iloc[-1])
|
| 231 |
if not prev_close:
|
| 232 |
continue
|
| 233 |
gap_pct = (last_price - prev_close) / prev_close * 100
|
|
|
|
| 234 |
if abs(gap_pct) < min_gap_pct:
|
| 235 |
continue
|
| 236 |
if direction == "Up only" and gap_pct <= 0:
|
| 237 |
continue
|
| 238 |
if direction == "Down only" and gap_pct >= 0:
|
| 239 |
continue
|
|
|
|
| 240 |
pm_volume = int(premarket["Volume"].sum())
|
|
|
|
| 241 |
results.append({
|
| 242 |
"Ticker": ticker,
|
| 243 |
"Prev Close": round(prev_close, 2),
|
|
|
|
| 262 |
"close as the signal."
|
| 263 |
)
|
| 264 |
st.info(
|
| 265 |
+
"π Why price, not volume: Yahoo's free feed updates the price tick during extended hours "
|
| 266 |
+
"but doesn't populate real trade volume. This scan uses premarket price gap instead. "
|
| 267 |
+
"Premarket volume is still shown when available, just not filtered on."
|
|
|
|
|
|
|
|
|
|
| 268 |
)
|
| 269 |
st.warning(
|
| 270 |
+
"β οΈ A premarket gap is not a reliable 'buy/short the open' signal by itself β it's exactly "
|
| 271 |
+
"the setup behind the classic 'gap and fade.' Treat a hit here as 'worth checking the news "
|
| 272 |
+
"on,' not a standalone trade signal."
|
|
|
|
| 273 |
)
|
| 274 |
|
| 275 |
with st.sidebar:
|
|
|
|
| 278 |
direction = st.radio("Direction", ["Both", "Up only", "Down only"], key="pm_direction")
|
| 279 |
|
| 280 |
universe = get_clean_premarket_universe()
|
| 281 |
+
st.caption(f"Universe: {len(universe)} tickers from Trebor1009/clean-premarket-data.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
if st.button("π Run Premarket Scan", key="pm_scan_btn"):
|
| 284 |
if not universe:
|
| 285 |
+
st.error("No tickers loaded β check the dataset is reachable.")
|
| 286 |
else:
|
| 287 |
results_df = run_premarket_scan(tuple(universe), min_gap_pct, direction)
|
| 288 |
st.session_state["pm_results"] = results_df
|
|
|
|
| 292 |
if results_df.empty:
|
| 293 |
st.info("No tickers cleared the gap threshold. Try lowering the minimum %.")
|
| 294 |
else:
|
| 295 |
+
EXTREME_GAP_CUTOFF = 50
|
| 296 |
extreme = results_df[results_df["Gap %"].abs() >= EXTREME_GAP_CUTOFF].copy()
|
| 297 |
+
normal = results_df[results_df["Gap %"].abs() < EXTREME_GAP_CUTOFF].copy()
|
|
|
|
| 298 |
sorted_df = normal.reindex(
|
| 299 |
normal["Gap %"].abs().sort_values(ascending=False).index
|
| 300 |
).reset_index(drop=True)
|
| 301 |
gainers = sorted_df[sorted_df["Gap %"] > 0].reset_index(drop=True)
|
| 302 |
+
losers = sorted_df[sorted_df["Gap %"] < 0].reset_index(drop=True)
|
| 303 |
+
st.success(f"{len(sorted_df)} ticker(s) past the gap threshold β {len(gainers)} up, {len(losers)} down.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
st.markdown("### Premarket Gainers")
|
| 305 |
st.dataframe(gainers, use_container_width=True, hide_index=True)
|
| 306 |
st.markdown("### Premarket Losers")
|
| 307 |
st.dataframe(losers, use_container_width=True, hide_index=True)
|
|
|
|
| 308 |
if not extreme.empty:
|
| 309 |
st.markdown("### β οΈ Extreme moves β verify before trusting")
|
| 310 |
+
st.caption(f"Gaps of {EXTREME_GAP_CUTOFF}%+ are almost always a stock split or data artefact.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
st.dataframe(extreme.reset_index(drop=True), use_container_width=True, hide_index=True)
|
| 312 |
|
| 313 |
|
|
|
|
| 352 |
return
|
| 353 |
|
| 354 |
median_vol = df[df["Volume"] > 0]["Volume"].median()
|
| 355 |
+
mean_vol = df[df["Volume"] > 0]["Volume"].mean()
|
| 356 |
|
| 357 |
df["Price_Change"] = df["Close"].diff()
|
| 358 |
+
df["Pct_Change"] = df["Close"].pct_change() * 100
|
| 359 |
+
df["Vol_Ratio"] = df["Volume"] / median_vol
|
| 360 |
+
df["is_notable"] = df["Volume"] > (median_vol * multiplier) if not show_all else True
|
| 361 |
|
| 362 |
df[["Delta", "Sentiment"]] = df.apply(lambda r: pd.Series(get_sentiment(r)), axis=1)
|
| 363 |
df["Cum_Delta"] = df["Delta"].cumsum()
|
| 364 |
+
df["Size"] = df["Volume"].apply(lambda v: classify_size(v, median_vol))
|
| 365 |
|
| 366 |
st.subheader(f"{symbol} β Session: {last_session.date()}")
|
| 367 |
c1, c2, c3, c4 = st.columns(4)
|
| 368 |
c1.metric("Median 1-min Volume", f"{median_vol:,.0f}")
|
| 369 |
+
c2.metric("Mean 1-min Volume", f"{mean_vol:,.0f}")
|
| 370 |
+
c3.metric("Flag Threshold", f"{median_vol * multiplier:,.0f}")
|
| 371 |
c4.metric(
|
| 372 |
"Last Price",
|
| 373 |
f"${df['Close'].iloc[-1]:.2f}",
|
|
|
|
| 386 |
table.style.format(
|
| 387 |
{"Volume": "{:,.0f}", "Vol_Ratio": "{:.1f}x", "Close": "${:.2f}", "Pct_Change": "{:+.2f}%"}
|
| 388 |
),
|
| 389 |
+
use_container_width=True, height=400,
|
|
|
|
| 390 |
)
|
| 391 |
|
| 392 |
st.markdown("### Cumulative Buy/Sell Delta")
|
|
|
|
| 394 |
chart_df.index = chart_df.index.strftime("%H:%M")
|
| 395 |
st.line_chart(chart_df)
|
| 396 |
|
| 397 |
+
total_buy = df[df["Delta"] > 0]["Delta"].sum()
|
| 398 |
total_sell = abs(df[df["Delta"] < 0]["Delta"].sum())
|
| 399 |
+
net = total_buy - total_sell
|
| 400 |
+
whale_buys = df[(df["Size"] == "WHALE") & (df["Sentiment"] == "BUY")]["Volume"].sum()
|
|
|
|
| 401 |
whale_sells = df[(df["Size"] == "WHALE") & (df["Sentiment"] == "SELL")]["Volume"].sum()
|
| 402 |
+
inst_buys = df[(df["Size"].isin(["INSTITUTIONAL", "WHALE"])) & (df["Sentiment"] == "BUY")]["Volume"].sum()
|
| 403 |
+
inst_sells = df[(df["Size"].isin(["INSTITUTIONAL", "WHALE"])) & (df["Sentiment"] == "SELL")]["Volume"].sum()
|
| 404 |
|
| 405 |
st.markdown("### Session Analysis")
|
| 406 |
colA, colB, colC = st.columns(3)
|
|
|
|
| 428 |
rows = []
|
| 429 |
total_vol = df["Volume"].sum()
|
| 430 |
for size in ["WHALE", "INSTITUTIONAL", "LARGE", "MEDIUM", "RETAIL"]:
|
| 431 |
+
vol = df[df["Size"] == size]["Volume"].sum()
|
| 432 |
count = len(df[df["Size"] == size])
|
| 433 |
+
pct = (vol / total_vol * 100) if total_vol > 0 else 0
|
| 434 |
rows.append({"Size": size, "Volume": vol, "% of Total": round(pct, 1), "Candles": count})
|
| 435 |
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 436 |
|
|
|
|
| 474 |
def add_peak_features(df: pd.DataFrame, vol_sensitivity: float) -> pd.DataFrame:
|
| 475 |
d = df.copy()
|
| 476 |
delta = d["Close"].diff()
|
| 477 |
+
gain = (delta.where(delta > 0, 0)).rolling(14).mean()
|
| 478 |
+
loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
|
| 479 |
d["RSI"] = 100 - (100 / (1 + (gain / (loss + 1e-9))))
|
| 480 |
d["Div_Warning"] = ((d["High"] >= d["High"].shift(1)) & (d["RSI"] < d["RSI"].shift(1))).astype(int)
|
| 481 |
d["Vol_Rel"] = d["Volume"] / d["Volume"].rolling(20).mean()
|
|
|
|
| 519 |
with st.sidebar:
|
| 520 |
st.markdown("---")
|
| 521 |
st.header("Peak Detector Settings")
|
| 522 |
+
pd_ticker = st.selectbox("Ticker", DEFAULT_PEAK_TICKERS, index=0, key="pd_ticker")
|
| 523 |
+
pd_custom = st.text_input("...or enter a custom ticker", value="", key="pd_custom")
|
| 524 |
+
target_drop = st.slider("Drop % that counts as a peak", 0.03, 0.15, 0.06, 0.01, key="pd_drop")
|
| 525 |
+
window = st.slider("Forward warning window (days)", 3, 15, 5, 1, key="pd_window")
|
| 526 |
vol_sensitivity = st.slider("Volume sensitivity", 0.5, 3.0, 1.0, 0.1, key="pd_vol")
|
| 527 |
if st.button("π Retrain model", key="pd_refresh"):
|
| 528 |
run_peak_model.clear()
|
|
|
|
| 539 |
|
| 540 |
st.subheader(f"{ticker}")
|
| 541 |
c1, c2, c3 = st.columns(3)
|
| 542 |
+
c1.metric("Status", status)
|
| 543 |
+
c2.metric("Risk %", f"{latest['Risk_Pct']:.1f}%")
|
| 544 |
+
c3.metric("RSI", f"{latest['RSI']:.1f}")
|
| 545 |
|
| 546 |
plot_df = data.iloc[-240:].copy()
|
| 547 |
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True,
|
| 548 |
+
gridspec_kw={"height_ratios": [2.5, 1]})
|
| 549 |
ax1.plot(plot_df["Date"], plot_df["Close"], color="black", linewidth=1)
|
| 550 |
ax1.fill_between(plot_df["Date"], plot_df["Close"], plot_df["RSI_Blowoff_Band"], color="red", alpha=0.1)
|
|
|
|
| 551 |
risk_days = plot_df[plot_df["Risk_Pct"] > 50]
|
| 552 |
ax1.scatter(risk_days["Date"], risk_days["High"] * 1.02, color="red", marker="D", s=50)
|
| 553 |
for _, row in risk_days.iterrows():
|
| 554 |
ax1.text(row["Date"], row["High"] * 1.03, "!", color="red", fontweight="bold", ha="center")
|
|
|
|
| 555 |
ax1.set_title(f"{ticker} | Price vs Risk Heatmap")
|
|
|
|
| 556 |
ax2.fill_between(plot_df["Date"], 0, plot_df["Risk_Pct"], color="purple", alpha=0.4)
|
| 557 |
ax2.axhline(50, color="red", linestyle="--", alpha=0.3)
|
| 558 |
ax2.set_ylabel("Drop Risk %")
|
|
|
|
| 559 |
plt.tight_layout()
|
| 560 |
st.pyplot(fig)
|
| 561 |
plt.close(fig)
|
|
|
|
| 567 |
|
| 568 |
|
| 569 |
# =========================================================
|
| 570 |
+
# TAB 3: BOTTOM SCANNER
|
| 571 |
# =========================================================
|
| 572 |
|
| 573 |
def sniper_rsi(series, period=14):
|
| 574 |
delta = series.diff()
|
| 575 |
+
gain = (delta.where(delta > 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
|
| 576 |
+
loss = (-delta.where(delta < 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
|
| 577 |
+
rs = gain / (loss + 1e-9)
|
| 578 |
return 100 - (100 / (1 + rs))
|
| 579 |
|
| 580 |
|
| 581 |
def get_strict_stats(df, rsi, sma, current_stretch, rsi_threshold, strict_window,
|
| 582 |
forward_1d=1, forward_5d=5, forward_3m=63):
|
| 583 |
+
close = df["Close"].values
|
| 584 |
stretches = ((close - sma.values) / sma.values) * 100
|
| 585 |
+
ret_1d = (pd.Series(close).shift(-forward_1d).values - close) / close
|
| 586 |
+
ret_5d = (pd.Series(close).shift(-forward_5d).values - close) / close
|
| 587 |
+
ret_3m = (pd.Series(close).shift(-forward_3m).values - close) / close
|
|
|
|
|
|
|
| 588 |
mask_stretch_lower = stretches >= current_stretch - strict_window
|
| 589 |
mask_stretch_upper = stretches <= current_stretch + strict_window
|
| 590 |
mask_rsi_threshold = (rsi < rsi_threshold).values
|
| 591 |
+
mask = mask_stretch_lower & mask_stretch_upper & mask_rsi_threshold
|
|
|
|
| 592 |
valid_mask = mask & (~np.isnan(ret_3m))
|
| 593 |
match_indices = np.where(valid_mask)[0]
|
|
|
|
| 594 |
if len(match_indices) < 10:
|
| 595 |
return None
|
|
|
|
| 596 |
return {
|
| 597 |
+
"count": len(match_indices),
|
| 598 |
+
"avg_1d": np.mean(ret_1d[valid_mask]) * 100,
|
| 599 |
+
"avg_5d": np.mean(ret_5d[valid_mask]) * 100,
|
| 600 |
+
"avg_3m": np.mean(ret_3m[valid_mask]) * 100,
|
| 601 |
+
"win_3m": (ret_3m[valid_mask] > 0).mean() * 100,
|
| 602 |
"match_dates": df.index[match_indices],
|
| 603 |
"match_prices": close[match_indices],
|
| 604 |
}
|
|
|
|
| 608 |
df = df.dropna()
|
| 609 |
if len(df) < 200:
|
| 610 |
return None
|
| 611 |
+
close = df["Close"].astype(float)
|
| 612 |
+
rsi = sniper_rsi(close)
|
| 613 |
+
sma20 = close.rolling(20).mean()
|
|
|
|
|
|
|
| 614 |
current_stretch = ((close.iloc[-1] - sma20.iloc[-1]) / sma20.iloc[-1]) * 100
|
|
|
|
| 615 |
result = {
|
| 616 |
+
"rsi": rsi.iloc[-1], "stretch": current_stretch, "price": close.iloc[-1],
|
| 617 |
+
"stats": None, "fractal_target": None, "df": df,
|
| 618 |
+
"rsi_series": rsi, "signal_met": False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
}
|
|
|
|
| 620 |
try:
|
| 621 |
hist = np.squeeze(close.values[:-90])
|
| 622 |
if hist.ndim == 1 and len(hist) > lookback:
|
| 623 |
+
curr = close.values[-lookback:]
|
| 624 |
curr_n = (curr - np.mean(curr)) / (np.std(curr) + 1e-9)
|
| 625 |
+
wins = sliding_window_view(hist, lookback)
|
| 626 |
wins_n = (wins - np.mean(wins, axis=1, keepdims=True)) / (np.std(wins, axis=1, keepdims=True) + 1e-9)
|
| 627 |
+
corrs = np.dot(wins_n, curr_n) / lookback
|
| 628 |
top_idx = np.argmax(corrs)
|
| 629 |
result["fractal_target"] = close.values[-1] * (
|
| 630 |
close.values[top_idx + lookback] / close.values[top_idx + lookback - 1]
|
| 631 |
)
|
| 632 |
except Exception:
|
| 633 |
pass
|
|
|
|
| 634 |
if result["rsi"] < rsi_threshold and current_stretch < stretch_threshold:
|
| 635 |
stats = get_strict_stats(df, rsi, sma20, current_stretch, rsi_threshold, strict_window)
|
| 636 |
result["stats"] = stats
|
| 637 |
if stats:
|
| 638 |
is_triple_positive = stats["avg_1d"] > 0 and stats["avg_5d"] > 0 and stats["avg_3m"] > 0
|
| 639 |
result["signal_met"] = is_triple_positive and stats["win_3m"] >= win_rate_min
|
|
|
|
| 640 |
return result
|
| 641 |
|
| 642 |
|
|
|
|
| 649 |
return df.dropna()
|
| 650 |
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
@st.cache_data(ttl=12 * 3600, show_spinner=False)
|
| 653 |
def run_full_scan(universe_label, extra_tickers_tuple, rsi_threshold, stretch_threshold,
|
| 654 |
strict_window, lookback, win_rate_min, _tickers):
|
| 655 |
+
results = []
|
| 656 |
batch_size = 10
|
| 657 |
total_batches = max(1, (len(_tickers) + batch_size - 1) // batch_size)
|
| 658 |
+
progress = st.progress(0, text="Starting scan...")
|
| 659 |
|
| 660 |
for b, i in enumerate(range(0, len(_tickers), batch_size)):
|
| 661 |
batch = _tickers[i:i + batch_size]
|
| 662 |
try:
|
| 663 |
+
data = yf.download(batch, period="max", interval="1d", group_by="ticker",
|
| 664 |
+
progress=False, threads=True)
|
| 665 |
except Exception:
|
| 666 |
+
progress.progress((b + 1) / total_batches,
|
| 667 |
+
text=f"Batch {b + 1}/{total_batches} failed, continuing...")
|
| 668 |
continue
|
|
|
|
| 669 |
for ticker in batch:
|
| 670 |
try:
|
| 671 |
try:
|
|
|
|
| 675 |
df = raw.dropna()
|
| 676 |
if len(df) < 200:
|
| 677 |
continue
|
| 678 |
+
result = analyze_ticker(df, rsi_threshold, stretch_threshold,
|
| 679 |
+
strict_window, lookback, win_rate_min)
|
| 680 |
if result and result["signal_met"]:
|
| 681 |
stats = result["stats"]
|
| 682 |
results.append({
|
| 683 |
+
"Ticker": ticker,
|
| 684 |
+
"Price": round(result["price"], 2),
|
| 685 |
+
"RSI": round(result["rsi"], 1),
|
| 686 |
+
"Stretch %": round(result["stretch"], 2),
|
| 687 |
+
"Matches": stats["count"],
|
| 688 |
+
"Avg_1D": round(stats["avg_1d"], 2),
|
| 689 |
+
"Avg_5D": round(stats["avg_5d"], 2),
|
| 690 |
+
"Avg_3M": round(stats["avg_3m"], 2),
|
| 691 |
"WinRate_3M": round(stats["win_3m"], 1),
|
| 692 |
})
|
| 693 |
except Exception:
|
| 694 |
continue
|
|
|
|
| 695 |
progress.progress((b + 1) / total_batches, text=f"Scanned batch {b + 1}/{total_batches}...")
|
| 696 |
|
| 697 |
progress.empty()
|
|
|
|
| 699 |
|
| 700 |
|
| 701 |
def render_sniper_dashboard(ticker, result):
|
| 702 |
+
df = result["df"]
|
| 703 |
rsi_series = result["rsi_series"]
|
| 704 |
+
stats = result["stats"]
|
| 705 |
+
target = result["fractal_target"]
|
| 706 |
|
| 707 |
fig = plt.figure(figsize=(12, 8))
|
| 708 |
+
gs = fig.add_gridspec(2, 2)
|
|
|
|
| 709 |
ax1 = fig.add_subplot(gs[0, :])
|
| 710 |
ax1.plot(df.index, df["Close"], color="gray", alpha=0.3, label="History")
|
| 711 |
recent = df.tail(120)
|
| 712 |
ax1.plot(recent.index, recent["Close"], color="black", linewidth=1.5, label="Current Trend")
|
|
|
|
| 713 |
if stats:
|
| 714 |
+
ax1.scatter(stats["match_dates"], stats["match_prices"],
|
| 715 |
+
color="limegreen", s=20, zorder=5, label="Strict Matches")
|
| 716 |
if target is not None:
|
| 717 |
+
ax1.axhline(target, color="blue", ls="--", alpha=0.6,
|
| 718 |
+
label="Single best-match analog (not a target)")
|
| 719 |
ax1.set_title(f"{ticker}", fontsize=16, fontweight="bold")
|
| 720 |
ax1.legend(loc="upper left", fontsize=9)
|
| 721 |
ax1.grid(True, alpha=0.1)
|
|
|
|
| 722 |
if stats:
|
| 723 |
stats_text = (
|
| 724 |
+
f"Historical Matches: {stats['count']}\\n"
|
| 725 |
+
f"Avg Change 1D: {stats['avg_1d']:+.2f}%\\n"
|
| 726 |
+
f"Avg Change 5D: {stats['avg_5d']:+.2f}%\\n"
|
| 727 |
+
f"Avg Change 3M: {stats['avg_3m']:+.2f}%\\n"
|
| 728 |
f"3M Win Rate: {stats['win_3m']:.1f}% (n={stats['count']})"
|
| 729 |
)
|
| 730 |
ax1.text(0.98, 0.05, stats_text, transform=ax1.transAxes, va="bottom", ha="right",
|
| 731 |
bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="darkgreen"),
|
| 732 |
fontsize=10, family="monospace")
|
|
|
|
| 733 |
ax2 = fig.add_subplot(gs[1, 0])
|
| 734 |
ax2.plot(recent.index, rsi_series.tail(120), color="purple")
|
| 735 |
ax2.axhline(result["rsi"], color="orange", ls=":")
|
| 736 |
ax2.set_title(f"RSI: {result['rsi']:.1f}")
|
|
|
|
| 737 |
ax3 = fig.add_subplot(gs[1, 1])
|
| 738 |
if stats:
|
| 739 |
horizons = ["1D", "5D", "3M"]
|
| 740 |
+
values = [stats["avg_1d"], stats["avg_5d"], stats["avg_3m"]]
|
| 741 |
ax3.bar(horizons, values, color="limegreen", alpha=0.8)
|
| 742 |
ax3.axhline(0, color="black", linewidth=0.8)
|
| 743 |
ax3.set_title("Historical Edge by Horizon")
|
| 744 |
else:
|
| 745 |
ax3.set_title("No matches to show")
|
|
|
|
| 746 |
plt.tight_layout()
|
| 747 |
st.pyplot(fig)
|
| 748 |
plt.close(fig)
|
|
|
|
| 753 |
if raw.empty:
|
| 754 |
st.error(f"No data for '{ticker}'.")
|
| 755 |
return
|
| 756 |
+
anchor_date = raw.index[-1]
|
| 757 |
+
context = raw[raw.index >= (anchor_date - pd.Timedelta(days=90))]
|
|
|
|
| 758 |
current_price = raw["Close"].iloc[-1]
|
| 759 |
+
projection = compute_analog_projection(ticker, str(anchor_date.date()))
|
|
|
|
| 760 |
|
| 761 |
fig, ax = plt.subplots(figsize=(12, 5))
|
| 762 |
if not context.empty:
|
|
|
|
| 764 |
marker="o", markersize=2, label="Price")
|
| 765 |
ax.axvline(anchor_date, color="red", ls="-", alpha=0.4, label="Today")
|
| 766 |
ax.axhline(current_price, color="gray", ls=":", alpha=0.6, label="Current price")
|
|
|
|
| 767 |
if projection:
|
| 768 |
ax.plot(projection["future_dates"], projection["weighted_mean"],
|
| 769 |
color="green", ls="--", linewidth=2, label="Analog-weighted projection")
|
|
|
|
| 772 |
st.caption(
|
| 773 |
f"Projection built from the {projection['match_count']} most similar non-overlapping "
|
| 774 |
f"60-day windows in {ticker}'s own history (robust similarity: "
|
| 775 |
+
f"{projection['robust_similarity']:.1f}%)."
|
|
|
|
| 776 |
)
|
| 777 |
else:
|
| 778 |
st.info("Not enough history for this ticker to build a projection.")
|
|
|
|
| 779 |
ax.set_title(f"{ticker} β forecast as of {anchor_date.date()}")
|
| 780 |
ax.legend(loc="best", fontsize=9)
|
| 781 |
ax.grid(True, alpha=0.1)
|
|
|
|
| 791 |
"forward returns and win rate."
|
| 792 |
)
|
| 793 |
st.warning(
|
| 794 |
+
"β οΈ Two things worth keeping in mind:\\n\\n"
|
| 795 |
"1. **Small samples** β many qualifying setups have only 10-30 historical matches. An '80% win rate' "
|
| 796 |
+
"on 10 matches has a much wider true range than the headline number suggests.\\n\\n"
|
| 797 |
+
"2. **Full-universe scans test thousands of tickers at once.** With that many tests, some will "
|
| 798 |
+
"clear an 80%+ bar by chance alone. A hit is a reason to look closer, not a standalone signal.\\n\\n"
|
| 799 |
+
"The dashed blue line ('best-match analog') is one single closest-matching historical window, "
|
| 800 |
+
"shown for context β not a statistical price target."
|
|
|
|
|
|
|
| 801 |
)
|
| 802 |
|
| 803 |
with st.sidebar:
|
| 804 |
st.markdown("---")
|
| 805 |
st.header("Bottom Scanner Settings")
|
| 806 |
+
rsi_threshold = st.slider("RSI threshold (oversold below this)", 20, 60, 45, 1, key="sn_rsi")
|
| 807 |
stretch_threshold = st.slider("Max SMA20 stretch %", -8.0, 0.0, -2.0, 0.5, key="sn_stretch")
|
| 808 |
+
strict_window = st.slider("Match strictness (Β± stretch %)", 0.1, 2.0, 0.5, 0.1, key="sn_window")
|
| 809 |
+
lookback = st.slider("Fractal lookback (days)", 20, 120, 60, 5, key="sn_lookback")
|
| 810 |
+
win_rate_min = st.slider("Min 3M win rate to flag (%)", 50, 95, 80, 5, key="sn_winrate")
|
| 811 |
|
| 812 |
settings = {
|
| 813 |
"rsi_threshold": rsi_threshold, "stretch_threshold": stretch_threshold,
|
|
|
|
| 815 |
}
|
| 816 |
|
| 817 |
st.subheader("Analyze a single ticker")
|
| 818 |
+
single_ticker = st.text_input("Ticker", value="BTC-USD", key="sn_single").strip().upper()
|
| 819 |
col_analyze, col_forecast = st.columns([1, 1])
|
| 820 |
+
analyze_clicked = col_analyze.button("Analyze", key="sn_analyze_btn")
|
| 821 |
forecast_clicked = col_forecast.button("Forecast", key="sn_forecast_btn")
|
| 822 |
|
| 823 |
if analyze_clicked and single_ticker:
|
|
|
|
| 825 |
if raw.empty:
|
| 826 |
st.error(f"No data for '{single_ticker}'.")
|
| 827 |
else:
|
| 828 |
+
result = analyze_ticker(raw, rsi_threshold, stretch_threshold,
|
| 829 |
+
strict_window, lookback, win_rate_min)
|
| 830 |
if result is None:
|
| 831 |
st.error("Not enough history for this ticker.")
|
| 832 |
else:
|
|
|
|
| 834 |
if not entry_met:
|
| 835 |
st.info(
|
| 836 |
f"RSI {result['rsi']:.1f} / Stretch {result['stretch']:.2f}% β entry conditions "
|
| 837 |
+
f"(RSI < {rsi_threshold}, stretch < {stretch_threshold}%) not currently met."
|
|
|
|
| 838 |
)
|
| 839 |
elif result["stats"] is None:
|
| 840 |
st.info(
|
| 841 |
+
f"Entry conditions met but fewer than 10 historical analogs found."
|
|
|
|
| 842 |
)
|
| 843 |
elif result["signal_met"]:
|
| 844 |
st.success(
|
|
|
|
| 849 |
hit_row = pd.DataFrame([{
|
| 850 |
"Ticker": single_ticker, "Price": round(result["price"], 2),
|
| 851 |
"RSI": round(result["rsi"], 1), "Stretch %": round(result["stretch"], 2),
|
| 852 |
+
"Matches": result["stats"]["count"],
|
| 853 |
+
"Avg_1D": round(result["stats"]["avg_1d"], 2),
|
| 854 |
"Avg_5D": round(result["stats"]["avg_5d"], 2),
|
| 855 |
"Avg_3M": round(result["stats"]["avg_3m"], 2),
|
| 856 |
"WinRate_3M": round(result["stats"]["win_3m"], 1),
|
|
|
|
| 870 |
st.markdown("---")
|
| 871 |
st.subheader("Full universe scan")
|
| 872 |
st.caption(
|
| 873 |
+
"Scans your curated ticker list for setups meeting the full criteria. "
|
| 874 |
+
"First run can take several minutes β results are cached for 12 hours.\\n\\n"
|
| 875 |
+
f"{'β
Hits will be auto-saved to your signal log.' if _log_configured() else 'β οΈ Signal log storage not configured β hits will NOT be saved.'}"
|
|
|
|
| 876 |
)
|
| 877 |
if st.button("π Run Full Universe Scan", key="sn_scan_btn"):
|
| 878 |
tickers = get_clean_premarket_universe()
|
| 879 |
if not tickers:
|
| 880 |
+
st.error("No tickers loaded from the curated list.")
|
| 881 |
else:
|
| 882 |
st.write(f"Scanning {len(tickers)} tickers...")
|
| 883 |
results_df = run_full_scan(
|
|
|
|
| 885 |
strict_window, lookback, win_rate_min, tuple(tickers),
|
| 886 |
)
|
| 887 |
st.session_state["sn_results"] = results_df
|
|
|
|
| 888 |
if not results_df.empty and _log_configured():
|
| 889 |
if append_hits_to_log(results_df, settings):
|
| 890 |
st.success(f"Logged {len(results_df)} hit(s) to the signal tracker.")
|
|
|
|
| 897 |
st.success(f"{len(results_df)} ticker(s) met the criteria.")
|
| 898 |
sorted_df = results_df.sort_values("WinRate_3M", ascending=False).reset_index(drop=True)
|
| 899 |
st.dataframe(sorted_df, use_container_width=True, hide_index=True)
|
|
|
|
| 900 |
pick = st.selectbox("View chart for:", sorted_df["Ticker"].tolist(), key="sn_pick")
|
| 901 |
if pick:
|
| 902 |
raw = get_sniper_ticker_data(pick)
|
| 903 |
if not raw.empty:
|
| 904 |
+
result = analyze_ticker(raw, rsi_threshold, stretch_threshold,
|
| 905 |
+
strict_window, lookback, win_rate_min)
|
| 906 |
if result:
|
| 907 |
render_sniper_dashboard(pick, result)
|
| 908 |
|
| 909 |
|
| 910 |
# =========================================================
|
| 911 |
+
# ANALOG PATH PROJECTION
|
| 912 |
# =========================================================
|
| 913 |
|
| 914 |
+
ANALOG_LOOKBACK = 60
|
| 915 |
+
ANALOG_FORWARD = 60
|
| 916 |
+
ANALOG_TOP_N = 5
|
| 917 |
ANALOG_WEIGHT_STRENGTH = 5
|
| 918 |
|
| 919 |
|
| 920 |
def _similarity_matches(close, end_idx, lookback, forward):
|
| 921 |
if end_idx - lookback < 0:
|
| 922 |
return []
|
| 923 |
+
recent = close.iloc[end_idx - lookback:end_idx]
|
| 924 |
recent_norm = (recent / recent.iloc[0]).values.flatten()
|
| 925 |
+
matches = []
|
| 926 |
+
max_search = end_idx - lookback - forward
|
|
|
|
| 927 |
for i in range(max_search):
|
| 928 |
+
cand = close.iloc[i:i + lookback]
|
| 929 |
cand_norm = (cand / cand.iloc[0]).values.flatten()
|
| 930 |
+
corr = spearmanr(recent_norm, cand_norm)[0]
|
| 931 |
if not np.isnan(corr):
|
| 932 |
matches.append((float(corr), i))
|
| 933 |
return matches
|
|
|
|
| 940 |
hist = get_sniper_ticker_data(ticker)
|
| 941 |
if hist.empty:
|
| 942 |
return None
|
| 943 |
+
close = hist["Close"]
|
| 944 |
flag_date = pd.Timestamp(flag_date_str)
|
| 945 |
+
end_idx = int(close.index.searchsorted(flag_date))
|
| 946 |
+
end_idx = min(end_idx, len(close) - 1)
|
|
|
|
| 947 |
if end_idx - lookback - forward < 5:
|
| 948 |
+
return None
|
|
|
|
| 949 |
matches = _similarity_matches(close, end_idx, lookback, forward)
|
| 950 |
if not matches:
|
| 951 |
return None
|
|
|
|
| 952 |
matches.sort(key=lambda x: x[0], reverse=True)
|
| 953 |
filtered, used = [], []
|
| 954 |
for corr, idx in matches:
|
|
|
|
| 959 |
break
|
| 960 |
if not filtered:
|
| 961 |
return None
|
| 962 |
+
corrs = np.array([c for c, _ in filtered])
|
|
|
|
| 963 |
weights = np.exp(weight_strength * corrs)
|
| 964 |
weights /= weights.sum()
|
|
|
|
| 965 |
anchor_price = close.iloc[end_idx]
|
| 966 |
+
projections = []
|
| 967 |
for corr, idx in filtered:
|
| 968 |
+
fwd = close.iloc[idx + lookback: idx + lookback + forward]
|
| 969 |
fwd_norm = (fwd / fwd.iloc[0]).values.flatten()
|
| 970 |
projections.append(fwd_norm * anchor_price)
|
| 971 |
+
projections = np.vstack(projections)
|
|
|
|
| 972 |
weighted_mean = np.average(projections, axis=0, weights=weights)
|
| 973 |
+
weighted_var = np.average((projections - weighted_mean) ** 2, axis=0, weights=weights)
|
| 974 |
+
weighted_std = np.sqrt(weighted_var)
|
| 975 |
+
future_dates = pd.bdate_range(start=close.index[end_idx], periods=forward + 1)[1:]
|
|
|
|
|
|
|
| 976 |
return {
|
| 977 |
+
"future_dates": future_dates,
|
| 978 |
+
"weighted_mean": weighted_mean,
|
| 979 |
+
"upper": weighted_mean + weighted_std,
|
| 980 |
+
"lower": weighted_mean - weighted_std,
|
| 981 |
"robust_similarity": float(np.sqrt(np.sum(weights * corrs ** 2)) * 100),
|
| 982 |
+
"match_count": len(filtered),
|
| 983 |
}
|
| 984 |
|
| 985 |
|
| 986 |
# =========================================================
|
| 987 |
+
# TAB 4: SIGNAL TRACKER
|
| 988 |
# =========================================================
|
| 989 |
|
| 990 |
def render_signal_tracker():
|
|
|
|
| 997 |
if not _log_configured():
|
| 998 |
st.error(
|
| 999 |
"Signal log storage isn't configured. In this Space's Settings, add a Secret named "
|
| 1000 |
+
"`HF_TOKEN` and a Variable named `HF_DATASET_REPO`, then restart the Space."
|
|
|
|
| 1001 |
)
|
| 1002 |
return
|
| 1003 |
|
| 1004 |
log = load_signal_log()
|
| 1005 |
if log.empty:
|
| 1006 |
+
st.info("No signals logged yet. Run a Full Universe Scan on the Bottom Scanner tab.")
|
|
|
|
|
|
|
|
|
|
| 1007 |
return
|
| 1008 |
|
| 1009 |
+
log["FlagDate"] = pd.to_datetime(log["FlagDate"])
|
| 1010 |
log["DaysSinceFlag"] = (pd.Timestamp.now().normalize() - log["FlagDate"]).dt.days
|
| 1011 |
|
| 1012 |
st.markdown(f"### {len(log)} signals logged across {log['Ticker'].nunique()} tickers")
|
| 1013 |
|
| 1014 |
summary_rows = []
|
| 1015 |
for _, row in log.iterrows():
|
| 1016 |
+
ticker = row["Ticker"]
|
| 1017 |
+
flag_price = row.get("Price", np.nan)
|
| 1018 |
+
days_elapsed = int(row["DaysSinceFlag"])
|
| 1019 |
actual_return = np.nan
|
| 1020 |
try:
|
| 1021 |
hist = get_sniper_ticker_data(ticker)
|
| 1022 |
if not hist.empty and pd.notna(flag_price) and flag_price > 0:
|
| 1023 |
+
actual_return = (hist["Close"].iloc[-1] - flag_price) / flag_price * 100
|
|
|
|
| 1024 |
except Exception:
|
| 1025 |
pass
|
|
|
|
| 1026 |
status = "Complete (90D+)" if days_elapsed >= 90 else f"In progress ({days_elapsed}d/90d)"
|
| 1027 |
summary_rows.append({
|
| 1028 |
+
"Ticker": ticker,
|
| 1029 |
+
"Flag Date": row["FlagDate"].date(),
|
| 1030 |
+
"Days Elapsed": days_elapsed,
|
| 1031 |
+
"Status": status,
|
| 1032 |
+
"Matches": row.get("Matches", None),
|
| 1033 |
+
"Predicted Avg 3M %": row.get("Avg_3M", np.nan),
|
| 1034 |
+
"Predicted Win Rate %": row.get("WinRate_3M", np.nan),
|
| 1035 |
"Actual Return % (to date)": round(actual_return, 2) if pd.notna(actual_return) else None,
|
| 1036 |
+
"On Track": (actual_return > 0) if pd.notna(actual_return) else None,
|
| 1037 |
})
|
| 1038 |
|
| 1039 |
summary_df = pd.DataFrame(summary_rows).sort_values("Flag Date", ascending=False)
|
|
|
|
| 1044 |
hit_rate = (completed["On Track"] == True).mean() * 100
|
| 1045 |
st.metric("Realized hit rate on completed 90-day signals", f"{hit_rate:.0f}% (n={len(completed)})")
|
| 1046 |
else:
|
| 1047 |
+
st.caption("No signals have reached the 90-day mark yet.")
|
| 1048 |
|
| 1049 |
st.download_button(
|
| 1050 |
"β¬οΈ Download full signal log (CSV)",
|
| 1051 |
data=log.to_csv(index=False).encode("utf-8"),
|
| 1052 |
+
file_name="signal_log.csv", mime="text/csv",
|
|
|
|
| 1053 |
)
|
| 1054 |
|
| 1055 |
st.markdown("---")
|
| 1056 |
st.subheader("Chart a specific signal")
|
| 1057 |
options = [f"{r['Ticker']} β flagged {r['Flag Date']}" for _, r in summary_df.iterrows()]
|
| 1058 |
+
pick = st.selectbox("Pick a logged signal", options, key="st_pick")
|
| 1059 |
if pick:
|
| 1060 |
ticker, flag_date_str = pick.split(" β flagged ")
|
| 1061 |
match = log[(log["Ticker"] == ticker) & (log["FlagDate"].dt.date.astype(str) == flag_date_str)]
|
| 1062 |
if not match.empty:
|
| 1063 |
+
row = match.iloc[0]
|
| 1064 |
+
hist = get_sniper_ticker_data(ticker)
|
| 1065 |
if not hist.empty:
|
| 1066 |
flag_date = row["FlagDate"]
|
| 1067 |
+
context = hist[hist.index >= (flag_date - pd.Timedelta(days=45))]
|
| 1068 |
on_or_after = context[context.index >= flag_date]
|
| 1069 |
+
flag_price = row.get("Price", np.nan)
|
| 1070 |
if pd.isna(flag_price):
|
| 1071 |
+
flag_price = on_or_after["Close"].iloc[0] if not on_or_after.empty else (
|
| 1072 |
+
context["Close"].iloc[-1] if not context.empty else np.nan
|
| 1073 |
+
)
|
|
|
|
|
|
|
| 1074 |
fig, ax = plt.subplots(figsize=(12, 5))
|
| 1075 |
if not context.empty:
|
| 1076 |
ax.plot(context.index, context["Close"], color="black", linewidth=1.5,
|
|
|
|
| 1078 |
ax.axvline(flag_date, color="red", ls="-", alpha=0.4, label="Flagged here")
|
| 1079 |
if pd.notna(flag_price):
|
| 1080 |
ax.axhline(flag_price, color="gray", ls=":", alpha=0.6, label="Price at flag")
|
|
|
|
| 1081 |
projection = compute_analog_projection(ticker, str(flag_date.date()))
|
| 1082 |
if projection:
|
| 1083 |
ax.plot(projection["future_dates"], projection["weighted_mean"],
|
|
|
|
| 1086 |
color="green", alpha=0.15, label="Projection band (1Ο)")
|
| 1087 |
st.caption(
|
| 1088 |
f"Projection built from the {projection['match_count']} most similar non-overlapping "
|
| 1089 |
+
f"60-day windows in {ticker}'s own history "
|
| 1090 |
+
f"(robust similarity: {projection['robust_similarity']:.1f}%)."
|
|
|
|
|
|
|
| 1091 |
)
|
|
|
|
| 1092 |
ax.set_title(f"{ticker} β flagged {flag_date.date()}")
|
| 1093 |
ax.legend(loc="best", fontsize=9)
|
| 1094 |
ax.grid(True, alpha=0.1)
|
|
|
|
| 1097 |
|
| 1098 |
|
| 1099 |
# =========================================================
|
| 1100 |
+
# TAB 5: ROTATION MAP
|
| 1101 |
# =========================================================
|
| 1102 |
|
| 1103 |
+
DEFAULT_ROTATION_TICKERS = ["XLK", "XLB", "XLF", "XLE", "XLV", "XLI", "XLU", "XLP", "XLRE", "XLY", "XLC"]
|
| 1104 |
|
| 1105 |
|
| 1106 |
@st.cache_data(ttl=4 * 3600, show_spinner="Building rotation map...")
|
| 1107 |
def compute_rotation_data(tickers_tuple, benchmark, lookback_years, window):
|
| 1108 |
+
symbols = list(tickers_tuple) + [benchmark]
|
| 1109 |
start_date = (pd.Timestamp.now() - pd.DateOffset(years=lookback_years)).strftime("%Y-%m-%d")
|
| 1110 |
try:
|
| 1111 |
data = yf.download(symbols, start=start_date, progress=False)["Close"]
|
|
|
|
| 1113 |
return None, list(tickers_tuple)
|
| 1114 |
if isinstance(data, pd.Series):
|
| 1115 |
data = data.to_frame()
|
| 1116 |
+
weekly = data.resample("W-FRI").last()
|
|
|
|
|
|
|
| 1117 |
missing = [t for t in tickers_tuple if t not in weekly.columns or weekly[t].dropna().empty]
|
| 1118 |
valid_tickers = [t for t in tickers_tuple if t not in missing]
|
|
|
|
| 1119 |
if benchmark not in weekly.columns or weekly[benchmark].dropna().empty or not valid_tickers:
|
| 1120 |
return None, missing
|
| 1121 |
+
rs_df = weekly[valid_tickers].div(weekly[benchmark], axis=0)
|
| 1122 |
+
rs_ratio = 100 + ((rs_df - rs_df.rolling(window).mean()) / rs_df.rolling(window).std()) * 5
|
| 1123 |
+
rs_momentum = 100 + ((rs_ratio - rs_ratio.rolling(window).mean()) / rs_ratio.rolling(window).std()) * 5
|
| 1124 |
+
rs_ratio = rs_ratio.dropna()
|
| 1125 |
+
rs_momentum = rs_momentum.dropna()
|
|
|
|
|
|
|
|
|
|
| 1126 |
return {"rs_ratio": rs_ratio, "rs_momentum": rs_momentum, "valid_tickers": valid_tickers}, missing
|
| 1127 |
|
| 1128 |
|
| 1129 |
def render_rotation_map():
|
| 1130 |
+
st.title("π§ Rotation Map")
|
| 1131 |
st.caption(
|
| 1132 |
"Plots relative strength and momentum versus a benchmark for a basket of tickers, showing "
|
| 1133 |
"which quadrant each one currently sits in and how it's been moving over recent weeks."
|
| 1134 |
)
|
| 1135 |
st.info(
|
| 1136 |
+
"π How to read it: this is RELATIVE positioning vs the benchmark, not absolute "
|
| 1137 |
+
"performance. Quadrants: Leading (strong + improving), Weakening (strong but losing steam), "
|
| 1138 |
+
"Lagging (weak + still losing ground), Improving (weak but starting to turn)."
|
|
|
|
| 1139 |
)
|
| 1140 |
st.warning(
|
| 1141 |
+
"β οΈ 'Momentum' here is a z-score of the relative-strength ratio itself, not a true "
|
| 1142 |
+
"rate-of-change. Small/thinly-traded names show noisier loops than large liquid ETFs β "
|
|
|
|
|
|
|
|
|
|
| 1143 |
"a big swing on a small-cap isn't necessarily more meaningful than a small swing on XLK."
|
| 1144 |
)
|
| 1145 |
|
|
|
|
| 1148 |
st.header("Rotation Map Settings")
|
| 1149 |
tickers_input = st.text_area(
|
| 1150 |
"Tickers (comma-separated)",
|
| 1151 |
+
value=", ".join(DEFAULT_ROTATION_TICKERS), key="rm_tickers",
|
|
|
|
| 1152 |
)
|
| 1153 |
+
benchmark = st.text_input("Benchmark", value="RSP", key="rm_benchmark").strip().upper()
|
| 1154 |
+
trail_weeks = st.slider("Trail length (weeks)", 2, 12, 6, 1, key="rm_trail")
|
| 1155 |
lookback_years = st.slider("History to fetch (years)", 1, 5, 2, 1, key="rm_lookback")
|
| 1156 |
+
window = st.slider("Z-score window (weeks)", 8, 52, 21, 1, key="rm_window")
|
| 1157 |
+
if st.button("π Refresh data", key="rm_refresh"):
|
| 1158 |
compute_rotation_data.clear()
|
| 1159 |
|
| 1160 |
tickers = tuple(t.strip().upper() for t in tickers_input.split(",") if t.strip())
|
|
|
|
| 1169 |
st.error("Not enough data to build the rotation map. Check the benchmark and ticker symbols.")
|
| 1170 |
return
|
| 1171 |
|
| 1172 |
+
rs_ratio = result["rs_ratio"]
|
| 1173 |
+
rs_momentum = result["rs_momentum"]
|
| 1174 |
valid_tickers = result["valid_tickers"]
|
| 1175 |
+
tail_ratio = rs_ratio.tail(trail_weeks)
|
| 1176 |
+
tail_mom = rs_momentum.tail(trail_weeks)
|
|
|
|
| 1177 |
|
| 1178 |
if tail_ratio.empty:
|
| 1179 |
st.error("Not enough history after the rolling window β try a shorter window or longer lookback.")
|
| 1180 |
return
|
| 1181 |
|
| 1182 |
+
fig = go.Figure()
|
| 1183 |
colors = px.colors.qualitative.Dark24
|
| 1184 |
|
| 1185 |
for i, ticker in enumerate(valid_tickers):
|
|
|
|
| 1187 |
continue
|
| 1188 |
x_data = tail_ratio[ticker].dropna().tolist()
|
| 1189 |
y_data = tail_mom[ticker].dropna().tolist() if ticker in tail_mom.columns else []
|
| 1190 |
+
n = min(len(x_data), len(y_data))
|
| 1191 |
if n == 0:
|
| 1192 |
continue
|
| 1193 |
x_data, y_data = x_data[-n:], y_data[-n:]
|
| 1194 |
color = colors[i % len(colors)]
|
| 1195 |
fig.add_trace(go.Scatter(
|
| 1196 |
x=x_data, y=y_data, mode="lines+markers+text", name=ticker,
|
| 1197 |
+
text=[""] * (n - 1) + [ticker], textposition="top center",
|
|
|
|
| 1198 |
textfont=dict(size=12, color=color),
|
| 1199 |
marker=dict(size=[6] * (n - 1) + [16], color=color, symbol="circle"),
|
| 1200 |
line=dict(width=2, color=color, shape="spline"),
|
|
|
|
| 1202 |
|
| 1203 |
fig.add_hline(y=100, line_dash="dash", line_color="gray", opacity=0.5)
|
| 1204 |
fig.add_vline(x=100, line_dash="dash", line_color="gray", opacity=0.5)
|
| 1205 |
+
for x, y, text, color in [(105,105,"LEADING","green"),(105,95,"WEAKENING","orange"),
|
| 1206 |
+
(95,95,"LAGGING","red"),(95,105,"IMPROVING","blue")]:
|
| 1207 |
+
fig.add_annotation(x=x, y=y, text=text, showarrow=False,
|
| 1208 |
+
font=dict(size=20, color=color), xref="x", yref="y", opacity=0.15)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
|
| 1210 |
fig.update_layout(
|
| 1211 |
+
title=f"Rotation Map vs {benchmark} (Last {trail_weeks} Weeks)",
|
| 1212 |
xaxis_title="Relative Strength (RS-Ratio)",
|
| 1213 |
yaxis_title="Momentum (RS-Momentum)",
|
| 1214 |
+
height=700, template="plotly_white", hovermode="closest", showlegend=False,
|
|
|
|
|
|
|
|
|
|
| 1215 |
)
|
|
|
|
| 1216 |
st.plotly_chart(fig, use_container_width=True)
|
| 1217 |
st.caption(
|
| 1218 |
"Tip: a Bottom Scanner hit sitting deep in Lagging with its tail starting to curl toward "
|
| 1219 |
+
"Improving is worth cross-referencing with the Combined Scanner (Tab 6) to check whether "
|
| 1220 |
+
"the daily RRG turn has started yet."
|
| 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
|
| 1236 |
# =========================================================
|
| 1237 |
|
| 1238 |
+
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
|
| 1239 |
+
"π Whale Monitor",
|
| 1240 |
+
"π Peak Detector",
|
| 1241 |
+
"π Bottom Scanner",
|
| 1242 |
+
"ποΈ Signal Tracker",
|
| 1243 |
+
"π§ Rotation Map",
|
| 1244 |
+
"π― Combined Scanner",
|
| 1245 |
+
])
|
| 1246 |
|
| 1247 |
with tab1:
|
| 1248 |
render_whale_monitor()
|
|
|
|
| 1257 |
render_signal_tracker()
|
| 1258 |
|
| 1259 |
with tab5:
|
| 1260 |
+
render_rotation_map()
|
| 1261 |
+
|
| 1262 |
+
with tab6:
|
| 1263 |
+
render_combined_scanner()
|