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

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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: checking the data you collected, premarket Volume came back as "
276
- "exactly 0 for every ticker, every premarket minute, across all 1,078 names tested. Yahoo's "
277
- "free feed updates the price tick during extended hours but doesn't populate real trade "
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 the "
284
- "setup behind the classic 'gap and fade,' where moves on thin early liquidity often reverse "
285
- "once the regular session's full liquidity arrives. Treat a hit here as 'worth checking the "
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 from the curated list β€” check the dataset is reachable.")
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 # likely a stock split / data artifact, not a real move
314
  extreme = results_df[results_df["Gap %"].abs() >= EXTREME_GAP_CUTOFF].copy()
315
- normal = results_df[results_df["Gap %"].abs() < EXTREME_GAP_CUTOFF].copy()
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 = sorted_df[sorted_df["Gap %"] < 0].reset_index(drop=True)
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 = df[df["Volume"] > 0]["Volume"].mean()
384
 
385
  df["Price_Change"] = df["Close"].diff()
386
- df["Pct_Change"] = df["Close"].pct_change() * 100
387
- df["Vol_Ratio"] = df["Volume"] / median_vol
388
- df["is_notable"] = df["Volume"] > (median_vol * multiplier) if not show_all else True
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"] = df["Volume"].apply(lambda v: classify_size(v, median_vol))
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", f"{mean_vol:,.0f}")
398
- c3.metric("Flag Threshold", f"{median_vol * multiplier:,.0f}")
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 = df[df["Delta"] > 0]["Delta"].sum()
427
  total_sell = abs(df[df["Delta"] < 0]["Delta"].sum())
428
- net = total_buy - total_sell
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 = df[(df["Size"].isin(["INSTITUTIONAL", "WHALE"])) & (df["Sentiment"] == "BUY")]["Volume"].sum()
433
- inst_sells = df[(df["Size"].isin(["INSTITUTIONAL", "WHALE"])) & (df["Sentiment"] == "SELL")]["Volume"].sum()
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 = df[df["Size"] == size]["Volume"].sum()
462
  count = len(df[df["Size"] == size])
463
- pct = (vol / total_vol * 100) if total_vol > 0 else 0
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 = (delta.where(delta > 0, 0)).rolling(14).mean()
508
- loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
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 = st.selectbox("Ticker", DEFAULT_PEAK_TICKERS, index=0, key="pd_ticker")
553
- pd_custom = st.text_input("...or enter a custom ticker", value="", key="pd_custom")
554
- target_drop = st.slider("Drop % that counts as a peak", 0.03, 0.15, 0.06, 0.01, key="pd_drop")
555
- window = st.slider("Forward warning window (days)", 3, 15, 5, 1, key="pd_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", status)
573
- c2.metric("Risk %", f"{latest['Risk_Pct']:.1f}%")
574
- c3.metric("RSI", f"{latest['RSI']:.1f}")
575
 
576
  plot_df = data.iloc[-240:].copy()
577
- fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True, gridspec_kw={"height_ratios": [2.5, 1]})
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: SNIPER SCANNER (historical analog / triple-positive setup scanner)
605
  # =========================================================
606
 
607
  def sniper_rsi(series, period=14):
608
  delta = series.diff()
609
- gain = (delta.where(delta > 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
610
- loss = (-delta.where(delta < 0, 0)).ewm(alpha=1 / period, adjust=False).mean()
611
- rs = gain / (loss + 1e-9)
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 = df["Close"].values
618
  stretches = ((close - sma.values) / sma.values) * 100
619
-
620
- ret_1d = (pd.Series(close).shift(-forward_1d).values - close) / close
621
- ret_5d = (pd.Series(close).shift(-forward_5d).values - close) / close
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": len(match_indices),
637
- "avg_1d": np.mean(ret_1d[valid_mask]) * 100,
638
- "avg_5d": np.mean(ret_5d[valid_mask]) * 100,
639
- "avg_3m": np.mean(ret_3m[valid_mask]) * 100,
640
- "win_3m": (ret_3m[valid_mask] > 0).mean() * 100,
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
- close = df["Close"].astype(float)
652
- rsi = sniper_rsi(close)
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
- "stretch": current_stretch,
660
- "price": close.iloc[-1],
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 = close.values[-lookback:]
672
  curr_n = (curr - np.mean(curr)) / (np.std(curr) + 1e-9)
673
- wins = sliding_window_view(hist, lookback)
674
  wins_n = (wins - np.mean(wins, axis=1, keepdims=True)) / (np.std(wins, axis=1, keepdims=True) + 1e-9)
675
- corrs = np.dot(wins_n, curr_n) / lookback
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 = st.progress(0, text="Starting scan...")
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", progress=False, threads=True)
 
738
  except Exception:
739
- progress.progress((b + 1) / total_batches, text=f"Batch {b + 1}/{total_batches} failed, continuing...")
 
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, strict_window, lookback, win_rate_min)
 
752
  if result and result["signal_met"]:
753
  stats = result["stats"]
754
  results.append({
755
- "Ticker": ticker,
756
- "Price": round(result["price"], 2),
757
- "RSI": round(result["rsi"], 1),
758
- "Stretch %": round(result["stretch"], 2),
759
- "Matches": stats["count"],
760
- "Avg_1D": round(stats["avg_1d"], 2),
761
- "Avg_5D": round(stats["avg_5d"], 2),
762
- "Avg_3M": round(stats["avg_3m"], 2),
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 = result["df"]
776
  rsi_series = result["rsi_series"]
777
- stats = result["stats"]
778
- target = result["fractal_target"]
779
 
780
  fig = plt.figure(figsize=(12, 8))
781
- gs = fig.add_gridspec(2, 2)
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"], color="limegreen", s=20, zorder=5,
790
- label="Strict Matches")
791
  if target is not None:
792
- ax1.axhline(target, color="blue", ls="--", alpha=0.6, label="Single best-match analog (not a target)")
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 = [stats["avg_1d"], stats["avg_5d"], stats["avg_3m"]]
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
- anchor_date = raw.index[-1]
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}%). Same caveat as the Signal Tracker chart β€” "
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 (8 wins) has a much wider true range than the headline number suggests β€” treat it as "
881
- "a lean, not a precise probability.\n\n"
882
- "2. **Full-universe scans test thousands of tickers at once.** With that many independent tests, some "
883
- "will clear an 80%+ historical win-rate bar by chance alone, with no real edge behind it. A hit showing "
884
- "up in a scan is a reason to look closer, not a standalone signal.\n\n"
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 = st.slider("RSI threshold (oversold below this)", 20, 60, 45, 1, key="sn_rsi")
893
  stretch_threshold = st.slider("Max SMA20 stretch %", -8.0, 0.0, -2.0, 0.5, key="sn_stretch")
894
- strict_window = st.slider("Match strictness (Β± stretch %)", 0.1, 2.0, 0.5, 0.1, key="sn_window")
895
- lookback = st.slider("Fractal lookback (days)", 20, 120, 60, 5, key="sn_lookback")
896
- win_rate_min = st.slider("Min 3M win rate to flag (%)", 50, 95, 80, 5, key="sn_winrate")
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 = st.text_input("Ticker", value="BTC-USD", key="sn_single").strip().upper()
905
  col_analyze, col_forecast = st.columns([1, 1])
906
- analyze_clicked = col_analyze.button("Analyze", key="sn_analyze_btn")
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, strict_window, lookback, win_rate_min)
 
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 (RSI {result['rsi']:.1f}, stretch {result['stretch']:.2f}%) "
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"], "Avg_1D": round(result["stats"]["avg_1d"], 2),
 
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
- "Will scan your curated ticker list (Trebor1009/clean-premarket-data β€” the same list the "
960
- "Premarket Scan uses) for setups meeting the full criteria. First run can take several "
961
- "minutes β€” results are cached for 12 hours after that.\n\n"
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 β€” check the dataset is reachable.")
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, strict_window, lookback,
994
- win_rate_min)
995
  if result:
996
  render_sniper_dashboard(pick, result)
997
 
998
 
999
  # =========================================================
1000
- # ANALOG PATH PROJECTION (used by the Signal Tracker chart)
1001
  # =========================================================
1002
 
1003
- ANALOG_LOOKBACK = 60
1004
- ANALOG_FORWARD = 60
1005
- ANALOG_TOP_N = 5
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 = close.iloc[end_idx - lookback:end_idx]
1013
  recent_norm = (recent / recent.iloc[0]).values.flatten()
1014
-
1015
- matches = []
1016
- max_search = end_idx - lookback - forward
1017
  for i in range(max_search):
1018
- cand = close.iloc[i:i + lookback]
1019
  cand_norm = (cand / cand.iloc[0]).values.flatten()
1020
- corr = spearmanr(recent_norm, cand_norm)[0]
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 = hist["Close"]
1034
  flag_date = pd.Timestamp(flag_date_str)
1035
- end_idx = int(close.index.searchsorted(flag_date))
1036
- end_idx = min(end_idx, len(close) - 1)
1037
-
1038
  if end_idx - lookback - forward < 5:
1039
- return None # not enough pre-flag history for this method
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 = close.iloc[idx + lookback: idx + lookback + forward]
1064
  fwd_norm = (fwd / fwd.iloc[0]).values.flatten()
1065
  projections.append(fwd_norm * anchor_price)
1066
- projections = np.vstack(projections)
1067
-
1068
  weighted_mean = np.average(projections, axis=0, weights=weights)
1069
- weighted_var = np.average((projections - weighted_mean) ** 2, axis=0, weights=weights)
1070
- weighted_std = np.sqrt(weighted_var)
1071
-
1072
- future_dates = pd.bdate_range(start=close.index[end_idx], periods=forward + 1)[1:]
1073
-
1074
  return {
1075
- "future_dates": future_dates,
1076
- "weighted_mean": weighted_mean,
1077
- "upper": weighted_mean + weighted_std,
1078
- "lower": weighted_mean - weighted_std,
1079
  "robust_similarity": float(np.sqrt(np.sum(weights * corrs ** 2)) * 100),
1080
- "match_count": len(filtered),
1081
  }
1082
 
1083
 
1084
  # =========================================================
1085
- # TAB 4: SIGNAL TRACKER (predicted vs. actual, out-of-sample)
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` (your Hugging Face write token) and a Variable named `HF_DATASET_REPO` "
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"] = pd.to_datetime(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 = row["Ticker"]
1119
- flag_price = row.get("Price", np.nan)
1120
- days_elapsed = int(row["DaysSinceFlag"])
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
- current_price = hist["Close"].iloc[-1]
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": ticker,
1133
- "Flag Date": row["FlagDate"].date(),
1134
- "Days Elapsed": days_elapsed,
1135
- "Status": status,
1136
- "Matches": row.get("Matches", None),
1137
- "Predicted Avg 3M %": row.get("Avg_3M", np.nan),
1138
- "Predicted Win Rate %": row.get("WinRate_3M", np.nan),
1139
  "Actual Return % (to date)": round(actual_return, 2) if pd.notna(actual_return) else None,
1140
- "On Track": (actual_return > 0) if pd.notna(actual_return) else None,
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 full 90-day mark yet β€” hit rate will appear once some do.")
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 = st.selectbox("Pick a logged signal", options, key="st_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 = match.iloc[0]
1169
- hist = get_sniper_ticker_data(ticker)
1170
  if not hist.empty:
1171
  flag_date = row["FlagDate"]
1172
- context = hist[hist.index >= (flag_date - pd.Timedelta(days=45))]
1173
  on_or_after = context[context.index >= flag_date]
1174
- flag_price = row.get("Price", np.nan)
1175
  if pd.isna(flag_price):
1176
- if not on_or_after.empty:
1177
- flag_price = on_or_after["Close"].iloc[0]
1178
- elif not context.empty:
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, as known on the flag date "
1198
- f"(robust similarity: {projection['robust_similarity']:.1f}%). Read this as 'what "
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 (relative strength / momentum vs benchmark)
1212
  # =========================================================
1213
 
1214
- DEFAULT_ROTATION_TICKERS = ["XLK", "XLB", "XLF", "XLE", "XLV", "XLI", "XLU", "XLP", "XLRE", "XLY" , "XLC"]
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 = list(tickers_tuple) + [benchmark]
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 = weekly[valid_tickers].div(weekly[benchmark], axis=0)
1237
- rs_ratio = 100 + ((rs_df - rs_df.rolling(window).mean()) / rs_df.rolling(window).std()) * 5
1238
- rs_momentum = 100 + ((rs_ratio - rs_ratio.rolling(window).mean()) / rs_ratio.rolling(window).std()) * 5
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("\U0001F9ED Rotation Map")
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
- "\U0001F4D0 How to read it: this is RELATIVE positioning vs the benchmark, not absolute "
1254
- "performance β€” a ticker can sit in 'Leading' while still being down in absolute terms, just "
1255
- "falling less than the benchmark. Quadrants: Leading (strong + improving), Weakening (strong "
1256
- "but losing steam), Lagging (weak + still losing ground), Improving (weak but starting to turn)."
1257
  )
1258
  st.warning(
1259
- "⚠️ One honest note on the math: 'momentum' here is a z-score of the relative-strength ratio "
1260
- "itself, not a true rate-of-change β€” it measures how unusual the current ratio is versus its "
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 = st.text_input("Benchmark", value="RSP", key="rm_benchmark").strip().upper()
1276
- trail_weeks = st.slider("Trail length (weeks)", 2, 12, 6, 1, key="rm_trail")
1277
  lookback_years = st.slider("History to fetch (years)", 1, 5, 2, 1, key="rm_lookback")
1278
- window = st.slider("Z-score window (weeks)", 8, 52, 21, 1, key="rm_window")
1279
- if st.button("\U0001F504 Refresh data", key="rm_refresh"):
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 = result["rs_ratio"]
1295
- rs_momentum = result["rs_momentum"]
1296
  valid_tickers = result["valid_tickers"]
1297
-
1298
- tail_ratio = rs_ratio.tail(trail_weeks)
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 = go.Figure()
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 = min(len(x_data), len(y_data))
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
- quadrants = [
1331
- (105, 105, "LEADING", "green"),
1332
- (105, 95, "WEAKENING", "orange"),
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"Momentum & Rotation Map vs {benchmark} (Last {trail_weeks} Weeks)",
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 a nice visual echo of the same 'oversold, maybe turning' idea the scanner tests "
1356
- "for numerically β€” worth treating as two tools corroborating each other, not independent proof."
1357
  )
1358
 
 
 
 
 
 
 
 
 
 
 
1359
 
1360
  # =========================================================
1361
  # MAIN LAYOUT
1362
  # =========================================================
1363
 
1364
- tab1, tab2, tab3, tab4, tab5 = st.tabs(
1365
- ["πŸ‹ Whale Monitor", "πŸ“ˆ Peak Detector", "πŸ“‰ Bottom Scanner", "πŸ—’οΈ Signal Tracker", "🧭 Rotation Map"]
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()