Spaces:
Running
Running
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +337 -2
src/streamlit_app.py
CHANGED
|
@@ -6,6 +6,7 @@ import yfinance as yf
|
|
| 6 |
import pandas as pd
|
| 7 |
import numpy as np
|
| 8 |
import matplotlib.pyplot as plt
|
|
|
|
| 9 |
from sklearn.ensemble import GradientBoostingClassifier
|
| 10 |
|
| 11 |
st.set_page_config(page_title="Market Dashboard", page_icon="π", layout="wide")
|
|
@@ -305,14 +306,348 @@ def render_peak_detector():
|
|
| 305 |
)
|
| 306 |
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
# =========================================================
|
| 309 |
# MAIN LAYOUT
|
| 310 |
# =========================================================
|
| 311 |
|
| 312 |
-
tab1, tab2 = st.tabs(["π Whale Monitor", "π Peak Detector"])
|
| 313 |
|
| 314 |
with tab1:
|
| 315 |
render_whale_monitor()
|
| 316 |
|
| 317 |
with tab2:
|
| 318 |
-
render_peak_detector()
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import pandas as pd
|
| 7 |
import numpy as np
|
| 8 |
import matplotlib.pyplot as plt
|
| 9 |
+
from numpy.lib.stride_tricks import sliding_window_view
|
| 10 |
from sklearn.ensemble import GradientBoostingClassifier
|
| 11 |
|
| 12 |
st.set_page_config(page_title="Market Dashboard", page_icon="π", layout="wide")
|
|
|
|
| 306 |
)
|
| 307 |
|
| 308 |
|
| 309 |
+
# =========================================================
|
| 310 |
+
# TAB 3: SNIPER SCANNER (historical analog / triple-positive setup scanner)
|
| 311 |
+
# =========================================================
|
| 312 |
+
|
| 313 |
+
def sniper_rsi(series, period=14):
|
| 314 |
+
delta = series.diff()
|
| 315 |
+
gain = (delta.where(delta > 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
|
| 316 |
+
loss = (-delta.where(delta < 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
|
| 317 |
+
rs = gain / (loss + 1e-9)
|
| 318 |
+
return 100 - (100 / (1 + rs))
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def get_strict_stats(df, rsi, sma, current_stretch, rsi_threshold, strict_window,
|
| 322 |
+
forward_1d=1, forward_5d=5, forward_3m=63):
|
| 323 |
+
close = df["Close"].values
|
| 324 |
+
stretches = ((close - sma.values) / sma.values) * 100
|
| 325 |
+
|
| 326 |
+
ret_1d = (pd.Series(close).shift(-forward_1d).values - close) / close
|
| 327 |
+
ret_5d = (pd.Series(close).shift(-forward_5d).values - close) / close
|
| 328 |
+
ret_3m = (pd.Series(close).shift(-forward_3m).values - close) / close
|
| 329 |
+
|
| 330 |
+
mask_stretch_lower = stretches >= current_stretch - strict_window
|
| 331 |
+
mask_stretch_upper = stretches <= current_stretch + strict_window
|
| 332 |
+
mask_rsi_threshold = (rsi < rsi_threshold).values
|
| 333 |
+
|
| 334 |
+
mask = mask_stretch_lower & mask_stretch_upper & mask_rsi_threshold
|
| 335 |
+
valid_mask = mask & (~np.isnan(ret_3m))
|
| 336 |
+
match_indices = np.where(valid_mask)[0]
|
| 337 |
+
|
| 338 |
+
if len(match_indices) < 10:
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
return {
|
| 342 |
+
"count": len(match_indices),
|
| 343 |
+
"avg_1d": np.mean(ret_1d[valid_mask]) * 100,
|
| 344 |
+
"avg_5d": np.mean(ret_5d[valid_mask]) * 100,
|
| 345 |
+
"avg_3m": np.mean(ret_3m[valid_mask]) * 100,
|
| 346 |
+
"win_3m": (ret_3m[valid_mask] > 0).mean() * 100,
|
| 347 |
+
"match_dates": df.index[match_indices],
|
| 348 |
+
"match_prices": close[match_indices],
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def analyze_ticker(df, rsi_threshold, stretch_threshold, strict_window, lookback, win_rate_min=80):
|
| 353 |
+
df = df.dropna()
|
| 354 |
+
if len(df) < 200:
|
| 355 |
+
return None
|
| 356 |
+
|
| 357 |
+
close = df["Close"].astype(float)
|
| 358 |
+
rsi = sniper_rsi(close)
|
| 359 |
+
sma20 = close.rolling(20).mean()
|
| 360 |
+
|
| 361 |
+
current_stretch = ((close.iloc[-1] - sma20.iloc[-1]) / sma20.iloc[-1]) * 100
|
| 362 |
+
|
| 363 |
+
result = {
|
| 364 |
+
"rsi": rsi.iloc[-1],
|
| 365 |
+
"stretch": current_stretch,
|
| 366 |
+
"stats": None,
|
| 367 |
+
"fractal_target": None,
|
| 368 |
+
"df": df,
|
| 369 |
+
"rsi_series": rsi,
|
| 370 |
+
"signal_met": False,
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
try:
|
| 374 |
+
hist = np.squeeze(close.values[:-90])
|
| 375 |
+
if hist.ndim == 1 and len(hist) > lookback:
|
| 376 |
+
curr = close.values[-lookback:]
|
| 377 |
+
curr_n = (curr - np.mean(curr)) / (np.std(curr) + 1e-9)
|
| 378 |
+
wins = sliding_window_view(hist, lookback)
|
| 379 |
+
wins_n = (wins - np.mean(wins, axis=1, keepdims=True)) / (np.std(wins, axis=1, keepdims=True) + 1e-9)
|
| 380 |
+
corrs = np.dot(wins_n, curr_n) / lookback
|
| 381 |
+
top_idx = np.argmax(corrs)
|
| 382 |
+
result["fractal_target"] = close.values[-1] * (
|
| 383 |
+
close.values[top_idx + lookback] / close.values[top_idx + lookback - 1]
|
| 384 |
+
)
|
| 385 |
+
except Exception:
|
| 386 |
+
pass
|
| 387 |
+
|
| 388 |
+
if result["rsi"] < rsi_threshold and current_stretch < stretch_threshold:
|
| 389 |
+
stats = get_strict_stats(df, rsi, sma20, current_stretch, rsi_threshold, strict_window)
|
| 390 |
+
result["stats"] = stats
|
| 391 |
+
if stats:
|
| 392 |
+
is_triple_positive = stats["avg_1d"] > 0 and stats["avg_5d"] > 0 and stats["avg_3m"] > 0
|
| 393 |
+
result["signal_met"] = is_triple_positive and stats["win_3m"] >= win_rate_min
|
| 394 |
+
|
| 395 |
+
return result
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def get_sniper_ticker_data(ticker: str) -> pd.DataFrame:
|
| 399 |
+
df = yf.download(ticker, period="max", interval="1d", progress=False)
|
| 400 |
+
if df.empty:
|
| 401 |
+
return df
|
| 402 |
+
if isinstance(df.columns, pd.MultiIndex):
|
| 403 |
+
df.columns = df.columns.get_level_values(0)
|
| 404 |
+
return df.dropna()
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@st.cache_data(ttl=24 * 3600, show_spinner="Fetching ticker universe...")
|
| 408 |
+
def get_universe_tickers(include_russell: bool, extra_tickers: tuple):
|
| 409 |
+
tickers = set()
|
| 410 |
+
try:
|
| 411 |
+
sp500 = pd.read_csv(
|
| 412 |
+
"https://raw.githubusercontent.com/datasets/s-and-p-500-companies/master/data/constituents.csv"
|
| 413 |
+
)["Symbol"].tolist()
|
| 414 |
+
tickers.update(t.replace(".", "-") for t in sp500)
|
| 415 |
+
except Exception as e:
|
| 416 |
+
st.warning(f"Could not fetch S&P 500 list: {e}")
|
| 417 |
+
|
| 418 |
+
if include_russell:
|
| 419 |
+
try:
|
| 420 |
+
russell = pd.read_csv(
|
| 421 |
+
"https://raw.githubusercontent.com/ikoniaris/Russell2000/master/russell_2000_components.csv"
|
| 422 |
+
)["Ticker"].tolist()
|
| 423 |
+
tickers.update(t.replace(".", "-") for t in russell)
|
| 424 |
+
except Exception as e:
|
| 425 |
+
st.warning(f"Could not fetch Russell 2000 list: {e}")
|
| 426 |
+
|
| 427 |
+
tickers.update(extra_tickers)
|
| 428 |
+
return sorted(tickers)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@st.cache_data(ttl=12 * 3600, show_spinner=False)
|
| 432 |
+
def run_full_scan(universe_label, extra_tickers_tuple, rsi_threshold, stretch_threshold,
|
| 433 |
+
strict_window, lookback, win_rate_min, _tickers):
|
| 434 |
+
results = []
|
| 435 |
+
batch_size = 150
|
| 436 |
+
total_batches = max(1, (len(_tickers) + batch_size - 1) // batch_size)
|
| 437 |
+
progress = st.progress(0, text="Starting scan...")
|
| 438 |
+
|
| 439 |
+
for b, i in enumerate(range(0, len(_tickers), batch_size)):
|
| 440 |
+
batch = _tickers[i:i + batch_size]
|
| 441 |
+
try:
|
| 442 |
+
data = yf.download(batch, period="max", interval="1d", group_by="ticker", progress=False, threads=True)
|
| 443 |
+
except Exception:
|
| 444 |
+
progress.progress((b + 1) / total_batches, text=f"Batch {b + 1}/{total_batches} failed, continuing...")
|
| 445 |
+
continue
|
| 446 |
+
|
| 447 |
+
for ticker in batch:
|
| 448 |
+
try:
|
| 449 |
+
try:
|
| 450 |
+
raw = data[ticker]
|
| 451 |
+
except Exception:
|
| 452 |
+
raw = data
|
| 453 |
+
df = raw.dropna()
|
| 454 |
+
if len(df) < 200:
|
| 455 |
+
continue
|
| 456 |
+
result = analyze_ticker(df, rsi_threshold, stretch_threshold, strict_window, lookback, win_rate_min)
|
| 457 |
+
if result and result["signal_met"]:
|
| 458 |
+
stats = result["stats"]
|
| 459 |
+
results.append({
|
| 460 |
+
"Ticker": ticker,
|
| 461 |
+
"RSI": round(result["rsi"], 1),
|
| 462 |
+
"Stretch %": round(result["stretch"], 2),
|
| 463 |
+
"Matches": stats["count"],
|
| 464 |
+
"Avg 1D %": round(stats["avg_1d"], 2),
|
| 465 |
+
"Avg 5D %": round(stats["avg_5d"], 2),
|
| 466 |
+
"Avg 3M %": round(stats["avg_3m"], 2),
|
| 467 |
+
"3M Win Rate %": round(stats["win_3m"], 1),
|
| 468 |
+
})
|
| 469 |
+
except Exception:
|
| 470 |
+
continue
|
| 471 |
+
|
| 472 |
+
progress.progress((b + 1) / total_batches, text=f"Scanned batch {b + 1}/{total_batches}...")
|
| 473 |
+
|
| 474 |
+
progress.empty()
|
| 475 |
+
return pd.DataFrame(results)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def render_sniper_dashboard(ticker, result):
|
| 479 |
+
df = result["df"]
|
| 480 |
+
rsi_series = result["rsi_series"]
|
| 481 |
+
stats = result["stats"]
|
| 482 |
+
target = result["fractal_target"]
|
| 483 |
+
|
| 484 |
+
fig = plt.figure(figsize=(12, 8))
|
| 485 |
+
gs = fig.add_gridspec(2, 2)
|
| 486 |
+
|
| 487 |
+
ax1 = fig.add_subplot(gs[0, :])
|
| 488 |
+
ax1.plot(df.index, df["Close"], color="gray", alpha=0.3, label="History")
|
| 489 |
+
recent = df.tail(120)
|
| 490 |
+
ax1.plot(recent.index, recent["Close"], color="black", linewidth=1.5, label="Current Trend")
|
| 491 |
+
|
| 492 |
+
if stats:
|
| 493 |
+
ax1.scatter(stats["match_dates"], stats["match_prices"], color="limegreen", s=20, zorder=5,
|
| 494 |
+
label="Strict Matches")
|
| 495 |
+
if target is not None:
|
| 496 |
+
ax1.axhline(target, color="blue", ls="--", alpha=0.6, label="Single best-match analog (not a target)")
|
| 497 |
+
|
| 498 |
+
ax1.set_title(f"{ticker}", fontsize=16, fontweight="bold")
|
| 499 |
+
ax1.legend(loc="upper left", fontsize=9)
|
| 500 |
+
ax1.grid(True, alpha=0.1)
|
| 501 |
+
|
| 502 |
+
if stats:
|
| 503 |
+
stats_text = (
|
| 504 |
+
f"Historical Matches: {stats['count']}\n"
|
| 505 |
+
f"Avg Change 1D: {stats['avg_1d']:+.2f}%\n"
|
| 506 |
+
f"Avg Change 5D: {stats['avg_5d']:+.2f}%\n"
|
| 507 |
+
f"Avg Change 3M: {stats['avg_3m']:+.2f}%\n"
|
| 508 |
+
f"3M Win Rate: {stats['win_3m']:.1f}% (n={stats['count']})"
|
| 509 |
+
)
|
| 510 |
+
ax1.text(0.98, 0.05, stats_text, transform=ax1.transAxes, va="bottom", ha="right",
|
| 511 |
+
bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="darkgreen"),
|
| 512 |
+
fontsize=10, family="monospace")
|
| 513 |
+
|
| 514 |
+
ax2 = fig.add_subplot(gs[1, 0])
|
| 515 |
+
ax2.plot(recent.index, rsi_series.tail(120), color="purple")
|
| 516 |
+
ax2.axhline(result["rsi"], color="orange", ls=":")
|
| 517 |
+
ax2.set_title(f"RSI: {result['rsi']:.1f}")
|
| 518 |
+
|
| 519 |
+
ax3 = fig.add_subplot(gs[1, 1])
|
| 520 |
+
if stats:
|
| 521 |
+
horizons = ["1D", "5D", "3M"]
|
| 522 |
+
values = [stats["avg_1d"], stats["avg_5d"], stats["avg_3m"]]
|
| 523 |
+
ax3.bar(horizons, values, color="limegreen", alpha=0.8)
|
| 524 |
+
ax3.axhline(0, color="black", linewidth=0.8)
|
| 525 |
+
ax3.set_title("Historical Edge by Horizon")
|
| 526 |
+
else:
|
| 527 |
+
ax3.set_title("No matches to show")
|
| 528 |
+
|
| 529 |
+
plt.tight_layout()
|
| 530 |
+
st.pyplot(fig)
|
| 531 |
+
plt.close(fig)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def render_sniper_tab():
|
| 535 |
+
st.title("π― Sniper Scanner")
|
| 536 |
+
st.caption(
|
| 537 |
+
"Looks for 'triple-positive' historical analog setups: today's RSI/SMA-stretch combination "
|
| 538 |
+
"compared against similar conditions earlier in a ticker's own price history, scored by average "
|
| 539 |
+
"forward returns and win rate."
|
| 540 |
+
)
|
| 541 |
+
st.warning(
|
| 542 |
+
"β οΈ Two things worth keeping in mind:\n\n"
|
| 543 |
+
"1. **Small samples** β many qualifying setups have only 10-30 historical matches. An '80% win rate' "
|
| 544 |
+
"on 10 matches (8 wins) has a much wider true range than the headline number suggests β treat it as "
|
| 545 |
+
"a lean, not a precise probability.\n\n"
|
| 546 |
+
"2. **Full-universe scans test thousands of tickers at once.** With that many independent tests, some "
|
| 547 |
+
"will clear an 80%+ historical win-rate bar by chance alone, with no real edge behind it. A hit showing "
|
| 548 |
+
"up in a scan is a reason to look closer, not a standalone signal.\n\n"
|
| 549 |
+
"The dashed blue line ('best-match analog') is one single closest-matching historical window, shown "
|
| 550 |
+
"for context β not a statistical price target."
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
with st.sidebar:
|
| 554 |
+
st.markdown("---")
|
| 555 |
+
st.header("Sniper Scanner Settings")
|
| 556 |
+
rsi_threshold = st.slider("RSI threshold (oversold below this)", 20, 60, 45, 1, key="sn_rsi")
|
| 557 |
+
stretch_threshold = st.slider("Max SMA20 stretch %", -8.0, 0.0, -2.0, 0.5, key="sn_stretch")
|
| 558 |
+
strict_window = st.slider("Match strictness (Β± stretch %)", 0.1, 2.0, 0.5, 0.1, key="sn_window")
|
| 559 |
+
lookback = st.slider("Fractal lookback (days)", 20, 120, 60, 5, key="sn_lookback")
|
| 560 |
+
win_rate_min = st.slider("Min 3M win rate to flag (%)", 50, 95, 80, 5, key="sn_winrate")
|
| 561 |
+
extra_input = st.text_input("Extra tickers for full scan (comma-separated)", value="GC=F,BTC-USD",
|
| 562 |
+
key="sn_extra")
|
| 563 |
+
include_russell = st.checkbox("Include Russell 2000 in full scan (~2,000 more tickers, much slower)",
|
| 564 |
+
value=False, key="sn_russell")
|
| 565 |
+
|
| 566 |
+
extra_tickers = tuple(t.strip().upper() for t in extra_input.split(",") if t.strip())
|
| 567 |
+
|
| 568 |
+
st.subheader("Analyze a single ticker")
|
| 569 |
+
single_ticker = st.text_input("Ticker", value="BTC-USD", key="sn_single").strip().upper()
|
| 570 |
+
if st.button("Analyze", key="sn_analyze_btn") and single_ticker:
|
| 571 |
+
raw = get_sniper_ticker_data(single_ticker)
|
| 572 |
+
if raw.empty:
|
| 573 |
+
st.error(f"No data for '{single_ticker}'.")
|
| 574 |
+
else:
|
| 575 |
+
result = analyze_ticker(raw, rsi_threshold, stretch_threshold, strict_window, lookback, win_rate_min)
|
| 576 |
+
if result is None:
|
| 577 |
+
st.error("Not enough history for this ticker.")
|
| 578 |
+
else:
|
| 579 |
+
entry_met = result["rsi"] < rsi_threshold and result["stretch"] < stretch_threshold
|
| 580 |
+
if not entry_met:
|
| 581 |
+
st.info(
|
| 582 |
+
f"RSI {result['rsi']:.1f} / Stretch {result['stretch']:.2f}% β entry conditions "
|
| 583 |
+
f"(RSI < {rsi_threshold}, stretch < {stretch_threshold}%) not currently met. "
|
| 584 |
+
"Showing current state for reference."
|
| 585 |
+
)
|
| 586 |
+
elif result["stats"] is None:
|
| 587 |
+
st.info(
|
| 588 |
+
f"Entry conditions met (RSI {result['rsi']:.1f}, stretch {result['stretch']:.2f}%) "
|
| 589 |
+
"but fewer than 10 historical analogs found β not enough data to score."
|
| 590 |
+
)
|
| 591 |
+
elif result["signal_met"]:
|
| 592 |
+
st.success(
|
| 593 |
+
f"β
Full signal met β {result['stats']['win_3m']:.1f}% historical 3M win rate "
|
| 594 |
+
f"over {result['stats']['count']} matches."
|
| 595 |
+
)
|
| 596 |
+
else:
|
| 597 |
+
st.info(
|
| 598 |
+
f"Entry conditions met but didn't clear the win-rate/triple-positive bar "
|
| 599 |
+
f"({result['stats']['win_3m']:.1f}% win rate over {result['stats']['count']} matches)."
|
| 600 |
+
)
|
| 601 |
+
render_sniper_dashboard(single_ticker, result)
|
| 602 |
+
|
| 603 |
+
st.markdown("---")
|
| 604 |
+
st.subheader("Full universe scan")
|
| 605 |
+
universe_label = "sp500_russell2000" if include_russell else "sp500"
|
| 606 |
+
st.caption(
|
| 607 |
+
f"Will scan the {'S&P 500 + Russell 2000' if include_russell else 'S&P 500'} plus your extra tickers "
|
| 608 |
+
f"for setups meeting the full criteria. First run can take several minutes β results are cached for "
|
| 609 |
+
f"12 hours after that. Start with Russell 2000 unchecked to see how long a run takes on this host "
|
| 610 |
+
f"before opting into the larger universe."
|
| 611 |
+
)
|
| 612 |
+
if st.button("π Run Full Universe Scan", key="sn_scan_btn"):
|
| 613 |
+
tickers = get_universe_tickers(include_russell, extra_tickers)
|
| 614 |
+
st.write(f"Scanning {len(tickers)} tickers...")
|
| 615 |
+
results_df = run_full_scan(
|
| 616 |
+
universe_label, extra_tickers, rsi_threshold, stretch_threshold,
|
| 617 |
+
strict_window, lookback, win_rate_min, tickers,
|
| 618 |
+
)
|
| 619 |
+
st.session_state["sn_results"] = results_df
|
| 620 |
+
|
| 621 |
+
if "sn_results" in st.session_state:
|
| 622 |
+
results_df = st.session_state["sn_results"]
|
| 623 |
+
if results_df.empty:
|
| 624 |
+
st.info("No tickers met the full signal criteria with current settings.")
|
| 625 |
+
else:
|
| 626 |
+
st.success(f"{len(results_df)} ticker(s) met the criteria.")
|
| 627 |
+
sorted_df = results_df.sort_values("3M Win Rate %", ascending=False).reset_index(drop=True)
|
| 628 |
+
st.dataframe(sorted_df, use_container_width=True, hide_index=True)
|
| 629 |
+
|
| 630 |
+
pick = st.selectbox("View chart for:", sorted_df["Ticker"].tolist(), key="sn_pick")
|
| 631 |
+
if pick:
|
| 632 |
+
raw = get_sniper_ticker_data(pick)
|
| 633 |
+
if not raw.empty:
|
| 634 |
+
result = analyze_ticker(raw, rsi_threshold, stretch_threshold, strict_window, lookback,
|
| 635 |
+
win_rate_min)
|
| 636 |
+
if result:
|
| 637 |
+
render_sniper_dashboard(pick, result)
|
| 638 |
+
|
| 639 |
+
|
| 640 |
# =========================================================
|
| 641 |
# MAIN LAYOUT
|
| 642 |
# =========================================================
|
| 643 |
|
| 644 |
+
tab1, tab2, tab3 = st.tabs(["π Whale Monitor", "π Peak Detector", "π― Sniper Scanner"])
|
| 645 |
|
| 646 |
with tab1:
|
| 647 |
render_whale_monitor()
|
| 648 |
|
| 649 |
with tab2:
|
| 650 |
+
render_peak_detector()
|
| 651 |
+
|
| 652 |
+
with tab3:
|
| 653 |
+
render_sniper_tab()
|