sridattapradeep/India_Equity_Scanner
0
1"""2scanner.py — Momentum & Trend Template Scanner3Indian Equity Scanner | Backend Module4 5Implements the Minervini Trend Template (all 6 criteria) plus:6 - compute_rs_ratings() : IBD-style 0-100 percentile RS Rating7 - scan_momentum() : filters universe to trend-aligned stocks8 - scan_swing_setups() : ATR-based entry/SL/target generation9 - scan_reversal_watchlist() : early 200 DMA crossover detection10 11All price data via yfinance. No scraping. No TA-Lib dependency.12"""13 14from __future__ import annotations15 16import logging17import os18import time19from dataclasses import dataclass, field20from datetime import datetime21from io import StringIO22from typing import Optional23 24import numpy as np25import pandas as pd26import requests27import yfinance as yf28 29from backtest import SWING_MAX_HOLD30 31logger = logging.getLogger(__name__)32 33# ── Rate limiting: 0.3s between yfinance calls ──────────────────────────────34FETCH_DELAY_SEC = 0.335 36 37# ============================================================38# STOCK UNIVERSE — loaded from nifty500.csv (504 stocks)39# Falls back to Nifty 50 subset if the file is missing.40# ============================================================41 42_FALLBACK_UNIVERSE = [43 "RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "INFY.NS", "ICICIBANK.NS",44 "HINDUNILVR.NS", "ITC.NS", "SBIN.NS", "BHARTIARTL.NS", "KOTAKBANK.NS",45 "LT.NS", "AXISBANK.NS", "ASIANPAINT.NS", "MARUTI.NS", "TITAN.NS",46 "SUNPHARMA.NS", "ULTRACEMCO.NS", "BAJFINANCE.NS", "NESTLEIND.NS",47 "WIPRO.NS", "TECHM.NS", "HCLTECH.NS", "POWERGRID.NS", "NTPC.NS",48 "ONGC.NS", "COALINDIA.NS", "TATASTEEL.NS", "JSWSTEEL.NS",49 "ADANIPORTS.NS", "DRREDDY.NS",50]51 52 53# Nifty 500 index CSV — fast import-time universe + the FALLBACK source when54# the nsepython build is unavailable. Also carries industry classification used55# by the sector views. (Primary universe is the full NSE list via universe_nse.)56_NSE_ARCHIVE_URL = (57 "https://nsearchives.nseindia.com/content/indices/ind_nifty500list.csv"58)59# NSE master equity list (~2374 rows: SYMBOL + NAME OF COMPANY) — used to label60# the full scanned universe in the search autocomplete.61_NSE_EQUITY_MASTER_URL = (62 "https://nsearchives.nseindia.com/content/equities/EQUITY_L.csv"63)64_NSE_HEADERS = {65 "User-Agent": (66 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "67 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"68 ),69 "Accept": "text/csv,text/plain,*/*",70 "Referer": "https://www.nseindia.com/",71}72 73 74def _fetch_live_universe() -> list[str]:75 """76 Download the Nifty 500 constituent list from NSE India's public archive.77 Returns [] on failure (caller falls back to local CSV / hardcoded list).78 """79 try:80 resp = requests.get(_NSE_ARCHIVE_URL, headers=_NSE_HEADERS, timeout=15)81 resp.raise_for_status()82 df = pd.read_csv(StringIO(resp.text))83 col = next((c for c in df.columns if "symbol" in c.lower()), None)84 if col is None:85 return []86 symbols = (df[col].dropna().str.strip() + ".NS").tolist()87 symbols = [s for s in symbols if s.endswith(".NS") and len(s) > 4]88 if symbols:89 logger.info("[UNIVERSE] Fetched %d symbols from ind_nifty500list.csv", len(symbols))90 return symbols91 except Exception as exc:92 logger.warning("[UNIVERSE] Archive fetch failed: %s", exc)93 return []94 95 96def _load_universe() -> list[str]:97 # 1. Try the live NSE India archive (always current after index rebalancing)98 live = _fetch_live_universe()99 if len(live) >= 400:100 logger.info("[UNIVERSE] Live fetch: %d stocks from NSE India archive", len(live))101 return live102 103 # 2. Fall back to local CSV (committed to git, manually refreshed)104 csv_path = os.path.join(os.path.dirname(__file__), "nifty500.csv")105 try:106 tickers = pd.read_csv(csv_path, header=None)[0].dropna().str.strip().tolist()107 tickers = [t for t in tickers if t.endswith(".NS") and len(t) > 3]108 if tickers:109 logger.info("[UNIVERSE] CSV fallback: %d stocks from nifty500.csv", len(tickers))110 return tickers111 except Exception as exc:112 logger.warning("[UNIVERSE] Could not load nifty500.csv: %s", exc)113 114 # 3. Last resort: hardcoded Nifty 50 subset115 logger.warning("[UNIVERSE] Using hardcoded 30-stock fallback")116 return _FALLBACK_UNIVERSE117 118 119def refresh_universe() -> int:120 """121 Rebuild the active universe and update the in-memory list IN-PLACE.122 (NIFTY_50_UNIVERSE is an alias for the same list object, so it stays in123 sync automatically.)124 125 Source priority:126 1. Full NSE equity list via nsepython + liquidity filter (~1100-1200)127 2. NSE index archive CSV (Total Market / Nifty 500, ~504) — fallback128 Heavy (~90s for the liquidity pass), so the caller runs it OFF the import129 path: once in the startup background thread and daily at 01:00 UTC.130 Returns the new universe size (0 if refresh failed; universe unchanged).131 """132 fresh: list[str] = []133 try:134 import universe_nse # lazy: never blocks scanner import if nsepython is absent135 fresh = universe_nse.build_universe()136 except Exception as exc:137 logger.warning("[UNIVERSE] nsepython build failed: %s", exc)138 139 # Fallback to the archive CSV if the full build under-delivers.140 if len(fresh) < 600:141 archive = _fetch_live_universe()142 if len(archive) > len(fresh):143 logger.info("[UNIVERSE] Using archive CSV (%d) over nsepython build (%d)",144 len(archive), len(fresh))145 fresh = archive146 147 if len(fresh) < 400:148 logger.warning("[UNIVERSE] Refresh skipped — only %d symbols", len(fresh))149 return 0150 151 NIFTY_500_UNIVERSE.clear()152 NIFTY_500_UNIVERSE.extend(fresh)153 logger.info("[UNIVERSE] Refresh complete — %d stocks", len(NIFTY_500_UNIVERSE))154 return len(NIFTY_500_UNIVERSE)155 156 157NIFTY_500_UNIVERSE: list[str] = _load_universe()158 159# Keep old name as alias so any external code still works160NIFTY_50_UNIVERSE = NIFTY_500_UNIVERSE161 162BENCHMARK_SYMBOL = "^NSEI" # NIFTY 50 index163 164 165# ============================================================166# DATA CLASSES167# ============================================================168 169@dataclass170class MomentumData:171 """Raw per-stock data collected before RS Rating computation."""172 symbol: str173 close: float174 prev_close: float175 change_pct: float176 sma_50: float177 sma_150: float178 sma_200: float179 sma_200_20d_ago: float180 high_52w: float181 low_52w: float182 rsi_14: float183 atr_14: float184 volume: float185 vol_20d_avg: float186 # composite return for RS Rating calculation (set by _compute_composite_return)187 ret_63d: float = 0.0188 ret_126d: float = 0.0189 ret_189d: float = 0.0190 ret_252d: float = 0.0191 composite_return: float = 0.0192 # populated by compute_rs_ratings() after all stocks fetched193 rs_rating: float = 0.0194 rs_score_raw: float = 0.0 # raw % vs NIFTY 63d (for reference)195 data_error: bool = False196 error_msg: str = ""197 # VCP fields — populated by scan_momentum() after template filter198 vcp_score: float = 0.0199 vcp_contractions: int = 0200 vcp_stage: str = ""201 vcp_pct_from_pivot: Optional[float] = None202 203 # ── Derived properties ────────────────────────────────────────────────────204 @property205 def trend_aligned(self) -> bool:206 """207 Minervini Trend Template — ALL conditions must be true:208 1. Price above 150 DMA209 2. Price above 200 DMA210 3. 150 DMA > 200 DMA211 4. Price above 50 DMA212 5. 50 DMA > 150 DMA (and therefore 50 > 150 > 200)213 """214 return bool(215 self.close > self.sma_50216 and self.close > self.sma_150217 and self.close > self.sma_200218 and self.sma_50 > self.sma_150219 and self.sma_150 > self.sma_200220 )221 222 @property223 def sma_200_rising(self) -> bool:224 """225 200 DMA trending upward for at least 1 month (20 trading days).226 Minervini criterion 6: 200 DMA rising.227 """228 if np.isnan(self.sma_200_20d_ago):229 return False230 return bool(self.sma_200 > self.sma_200_20d_ago)231 232 @property233 def within_25pct_of_high(self) -> bool:234 """Price within 25% of 52-week high (closer to new high = better)."""235 return bool(self.close >= self.high_52w * 0.75)236 237 @property238 def above_30pct_of_low(self) -> bool:239 """Price at least 30% above 52-week low."""240 return bool(self.close >= self.low_52w * 1.30)241 242 @property243 def pct_from_52w_high(self) -> float:244 """How far below 52-week high (negative = below high)."""245 if self.high_52w == 0:246 return 0.0247 return round((self.close / self.high_52w - 1) * 100, 2)248 249 @property250 def pct_above_52w_low(self) -> float:251 """How far above 52-week low (positive = above)."""252 if self.low_52w == 0:253 return 0.0254 return round((self.close / self.low_52w - 1) * 100, 2)255 256 @property257 def volume_ratio(self) -> float:258 if self.vol_20d_avg == 0:259 return 1.0260 return round(self.volume / self.vol_20d_avg, 2)261 262 @property263 def passes_minervini_template(self) -> bool:264 """265 Full Minervini Trend Template — all 6 criteria:266 1. Price > 150 DMA AND > 200 DMA267 2. 150 DMA > 200 DMA268 3. 200 DMA rising for 1+ month269 4. Price > 50 DMA (or near), 50 DMA > 150 DMA > 200 DMA270 5. RS Rating >= 70 (requires compute_rs_ratings() called first)271 6. Price >= 30% above 52-week low272 7. Price within 25% of 52-week high273 """274 return bool(275 self.trend_aligned276 and self.sma_200_rising277 and self.rs_rating >= 70 # criterion 5 — must be pre-computed278 and self.above_30pct_of_low # criterion 6279 and self.within_25pct_of_high # criterion 7280 )281 282 283@dataclass284class SwingSetup:285 """ATR-based swing trade entry/exit levels."""286 symbol: str287 entry_price: float288 stop_loss: float289 target_1: float290 target_2: float291 atr_14: float292 risk_pct: float # (entry - sl) / entry * 100293 rr_ratio: float # (t1 - entry) / (entry - sl)294 position_size_pct: float # shares for 1% portfolio risk on a ₹10,00,000 portfolio295 rsi_14: float296 rs_rating: float297 volume_ratio: float298 bars_since_signal: Optional[int] = None # bars since the qualifying condition first held (0 = today)299 is_stale: bool = False # bars_since_signal > SWING_MAX_HOLD (backtest's timeout)300 301 302@dataclass303class ReversalCandidate:304 """Early trend reversal: stock that recently crossed above 200 DMA."""305 symbol: str306 close: float307 sma_200: float308 cross_date: datetime309 days_above_200: int310 rsi_14: float311 rsi_improving: bool # RSI > RSI 5 bars ago312 volume_on_cross: float # volume ratio on the crossover day313 rs_rating: float314 315 316# ============================================================317# FETCH HELPERS318# ============================================================319 320_BATCH_CHUNK = 100 # tickers per yfinance batch call321_BATCH_PAUSE = 2.0 # seconds between chunks322 323 324def _fetch_ohlcv_batch(325 symbols: list[str],326 period: str = "18mo",327) -> dict[str, pd.DataFrame]:328 """329 Batch-download OHLCV for many symbols using yfinance's parallel downloader.330 Splits into chunks of _BATCH_CHUNK to stay within Yahoo rate limits.331 Returns {symbol: DataFrame}, silently dropping symbols with < 60 bars.332 """333 result: dict[str, pd.DataFrame] = {}334 chunks = [symbols[i:i + _BATCH_CHUNK]335 for i in range(0, len(symbols), _BATCH_CHUNK)]336 337 for chunk_idx, chunk in enumerate(chunks):338 if chunk_idx > 0:339 time.sleep(_BATCH_PAUSE)340 try:341 raw = yf.download(342 chunk,343 period=period,344 interval="1d",345 auto_adjust=True,346 threads=True,347 progress=False,348 )349 if raw is None or raw.empty:350 continue351 352 for sym in chunk:353 try:354 if len(chunk) == 1:355 # Single ticker → flat DataFrame356 df = raw.copy()357 if isinstance(df.columns, pd.MultiIndex):358 df.columns = df.columns.get_level_values(0).str.lower()359 else:360 df.columns = [c.lower() for c in df.columns]361 else:362 # Multiple tickers → MultiIndex columns: (field, ticker)363 top = raw.columns.get_level_values(0).unique()364 df = pd.DataFrame(365 {field.lower(): raw[field][sym]366 for field in ["Close", "High", "Low", "Open", "Volume"]367 if field in top},368 index=raw.index,369 )370 371 df = df.dropna(subset=["open", "high", "low", "close", "volume"])372 if len(df) >= 60:373 result[sym] = df374 else:375 logger.warning(f"[BATCH] {sym}: only {len(df)} bars — skipped")376 except Exception as exc:377 logger.warning(f"[BATCH] {sym}: extraction failed — {exc}")378 379 except Exception as exc:380 logger.error(f"[BATCH] Chunk {chunk_idx} download failed: {exc}")381 382 logger.info(f"[BATCH] {len(result)}/{len(symbols)} symbols fetched successfully")383 return result384 385 386def _fetch_ohlcv(symbol: str, period: str = "18mo") -> Optional[pd.DataFrame]:387 """388 Download OHLCV data from yfinance with error handling.389 18 months gives enough history for 200 DMA + RS calculations.390 """391 try:392 time.sleep(FETCH_DELAY_SEC)393 df = yf.download(symbol, period=period, interval="1d", progress=False, auto_adjust=True)394 if df.empty or len(df) < 60:395 logger.warning(f"[FETCH] {symbol}: insufficient data ({len(df)} bars)")396 return None397 if isinstance(df.columns, pd.MultiIndex):398 df.columns = df.columns.get_level_values(0).str.lower()399 else:400 df.columns = [c.lower() for c in df.columns]401 df = df.dropna(subset=["open", "high", "low", "close", "volume"])402 return df403 except Exception as exc:404 logger.error(f"[FETCH] {symbol}: {exc}")405 return None406 407 408def _compute_indicators(df: pd.DataFrame) -> dict:409 """Compute all required indicators for one stock's DataFrame."""410 close = df["close"]411 high = df["high"]412 low = df["low"]413 volume = df["volume"]414 415 # SMAs416 sma_50 = close.rolling(50).mean()417 sma_150 = close.rolling(150).mean()418 sma_200 = close.rolling(200).mean()419 420 # 52-week range (252 trading days)421 high_52w = close.rolling(252).max()422 low_52w = close.rolling(252).min()423 424 # ATR (Wilder, 14-period)425 tr1 = high - low426 tr2 = (high - close.shift(1)).abs()427 tr3 = (low - close.shift(1)).abs()428 tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)429 atr_14 = tr.ewm(alpha=1 / 14, min_periods=14, adjust=False).mean()430 431 # RSI (Wilder, 14-period)432 delta = close.diff()433 gain = delta.clip(lower=0).ewm(alpha=1 / 14, adjust=False).mean()434 loss = (-delta.clip(upper=0)).ewm(alpha=1 / 14, adjust=False).mean()435 rsi_14 = 100 - (100 / (1 + gain / loss.replace(0, np.nan)))436 437 # Volume438 vol_20d_avg = volume.rolling(20).mean()439 440 # Multi-period returns for RS composite441 ret_63d = close.pct_change(63)442 ret_126d = close.pct_change(126)443 ret_189d = close.pct_change(189)444 ret_252d = close.pct_change(252)445 446 def last(s: pd.Series) -> float:447 v = s.iloc[-1]448 return float(v) if not np.isnan(v) else float("nan")449 450 return {451 "close": last(close),452 "prev_close": float(close.iloc[-2]) if len(close) >= 2 else float("nan"),453 "sma_50": last(sma_50),454 "sma_150": last(sma_150),455 "sma_200": last(sma_200),456 # iloc[-21] = 20 trading days before the last bar (iloc[-20] is only 19;457 # keeps "200 DMA rising for 1 month" consistent with breadth's shift(20))458 "sma_200_20d": float(sma_200.iloc[-21]) if len(sma_200) >= 221 else float("nan"),459 "high_52w": last(high_52w),460 "low_52w": last(low_52w),461 "atr_14": last(atr_14),462 "rsi_14": last(rsi_14),463 "volume": last(volume),464 "vol_20d_avg": last(vol_20d_avg),465 "ret_63d": last(ret_63d),466 "ret_126d": last(ret_126d),467 "ret_189d": last(ret_189d),468 "ret_252d": last(ret_252d),469 # for reversal detection: full series470 "_close_series": close,471 "_sma_200_series": sma_200,472 "_rsi_series": rsi_14,473 "_volume_series": volume,474 "_vol_avg_series": vol_20d_avg,475 }476 477 478# ============================================================479# VCP — Volatility Contraction Pattern (Minervini)480# ============================================================481 482def _intra_phase_vol_contracting(phase_volumes: np.ndarray) -> bool:483 """True if volume trails off WITHIN a single contraction phase (first half484 vs second half), not just phase-to-phase. Phases under 4 bars are too short485 to split meaningfully and are treated as neutral (True) so they don't486 penalize a fast contraction. 5% tolerance absorbs day-to-day noise."""487 if len(phase_volumes) < 4:488 return True489 mid = len(phase_volumes) // 2490 first_half_avg = np.mean(phase_volumes[:mid])491 second_half_avg = np.mean(phase_volumes[mid:])492 return second_half_avg <= first_half_avg * 1.05493 494 495def detect_vcp(df: pd.DataFrame, lookback: int = 90,496 swing_threshold: float = 0.03) -> dict:497 """498 Detect a Volatility Contraction Pattern in the last `lookback` bars.499 500 Algorithm:501 1. Zigzag: find alternating swing highs/lows (3% reversal threshold).502 2. Pair each H→L as one contraction; need ≥ 2 confirmed pairs.503 3. Score tightening, volume dry-up, last coil tightness, pivot proximity.504 505 Returns:506 score — 0-100507 contractions — number of H→L pairs found (2-4)508 contraction_pcts— list of each contraction size as %509 vol_contracting — True if volume declines across most phases (between-phase)510 intra_phase_contracting — True if volume also trails off WITHIN each phase,511 not just phase-to-phase (catches choppy-but-flat phases)512 pct_from_pivot — % from last swing high (negative = below pivot)513 stage — "Coiling" (≥65) | "Forming" (≥40) | ""514 """515 _empty = {516 "score": 0, "contractions": 0, "contraction_pcts": [],517 "vol_contracting": False, "intra_phase_contracting": False,518 "pct_from_pivot": None, "stage": "",519 }520 521 if len(df) < max(lookback, 30):522 return _empty523 524 rec = df.tail(lookback).reset_index(drop=True)525 closes = rec["close"].to_numpy(dtype=float)526 highs = rec["high"].to_numpy(dtype=float)527 lows = rec["low"].to_numpy(dtype=float)528 volumes = rec["volume"].to_numpy(dtype=float)529 530 # ── 1. Zigzag swing detection ─────────────────────────────────────────────531 swings: list[tuple[int, float, str]] = []532 direction: Optional[str] = None533 ext_idx = 0534 ext_close = closes[0]535 536 for i in range(1, len(closes)):537 c = closes[i]538 if direction is None:539 if c >= ext_close * (1 + swing_threshold):540 direction, ext_idx, ext_close = "up", i, c541 elif c <= ext_close * (1 - swing_threshold):542 direction, ext_idx, ext_close = "dn", i, c543 elif direction == "up":544 if c > ext_close:545 ext_idx, ext_close = i, c546 elif c <= ext_close * (1 - swing_threshold):547 swings.append((ext_idx, highs[ext_idx], "H"))548 direction, ext_idx, ext_close = "dn", i, c549 else: # "dn"550 if c < ext_close:551 ext_idx, ext_close = i, c552 elif c >= ext_close * (1 + swing_threshold):553 swings.append((ext_idx, lows[ext_idx], "L"))554 direction, ext_idx, ext_close = "up", i, c555 556 # Ensure strict H-L alternation (merge consecutive same-type extremes)557 deduped: list[tuple[int, float, str]] = []558 for sw in swings:559 if deduped and sw[2] == deduped[-1][2]:560 keep = sw if (561 (sw[2] == "H" and sw[1] > deduped[-1][1]) or562 (sw[2] == "L" and sw[1] < deduped[-1][1])563 ) else deduped[-1]564 deduped[-1] = keep565 else:566 deduped.append(sw)567 swings = deduped568 569 # VCP base begins at a swing high — drop a leading L570 if swings and swings[0][2] == "L":571 swings = swings[1:]572 573 # Build H→L contraction pairs574 pairs: list[tuple[tuple, tuple]] = []575 j = 0576 while j + 1 < len(swings):577 h, lo = swings[j], swings[j + 1]578 if h[2] == "H" and lo[2] == "L":579 pairs.append((h, lo))580 j += 2581 582 if len(pairs) < 2:583 return _empty584 585 pairs = pairs[-4:] # use at most the last 4 contractions586 n = len(pairs)587 588 # ── 2. Contraction sizes ──────────────────────────────────────────────────589 contraction_pcts = [590 round((h[1] - lo[1]) / h[1] * 100, 1)591 for h, lo in pairs592 ]593 594 # ── 3. Tightening ─────────────────────────────────────────────────────────595 strict_dec = all(contraction_pcts[k] < contraction_pcts[k - 1]596 for k in range(1, n))597 ratio_ok = strict_dec and all(598 contraction_pcts[k] / contraction_pcts[k - 1] <= 0.80599 for k in range(1, n) if contraction_pcts[k - 1] > 0600 )601 602 # ── 4. Volume contraction per phase ───────────────────────────────────────603 vol_avgs = []604 intra_phase_ok = []605 for (h_idx, _, _), (lo_idx, _, _) in pairs:606 phase = volumes[h_idx: lo_idx + 1]607 vol_avgs.append(float(np.mean(phase)) if len(phase) > 0 else 0.0)608 intra_phase_ok.append(_intra_phase_vol_contracting(phase))609 610 vol_dec = sum(611 1 for k in range(1, len(vol_avgs))612 if vol_avgs[k] < vol_avgs[k - 1] and vol_avgs[k - 1] > 0613 )614 vol_contracting = vol_dec >= max(1, len(vol_avgs) - 1)615 intra_phase_contracting = all(intra_phase_ok)616 617 # ── 5. Pivot proximity ────────────────────────────────────────────────────618 last_pivot = pairs[-1][0][1] # price of the most recent swing H619 pct_from_pivot = round((closes[-1] / last_pivot - 1) * 100, 1)620 621 # ── 6. Score (0-100) ─────────────────────────────────────────────────────622 score = 0623 624 # A) Contraction count (0-35)625 score += {2: 20, 3: 30}.get(n, 35)626 627 # B) Tightening quality (0-30)628 if ratio_ok:629 score += 30630 elif strict_dec:631 score += 20632 else:633 partial = sum(1 for k in range(1, n)634 if contraction_pcts[k] < contraction_pcts[k - 1])635 if partial >= n - 1:636 score += 5637 638 # C) Volume contraction (0-20) — between-phase decline alone used to earn639 # full marks; now also requires volume to actually trail off WITHIN each640 # phase (intra_phase_ok), since a phase can have a flat/declining average641 # while still being choppy in the middle.642 between_ok = vol_dec >= len(vol_avgs) - 1643 intra_ok_count = sum(intra_phase_ok)644 if between_ok and intra_ok_count == len(intra_phase_ok):645 score += 20646 elif between_ok or intra_ok_count >= len(intra_phase_ok) - 1:647 score += 12648 elif vol_dec >= len(vol_avgs) // 2:649 score += 6650 651 # D) Last contraction tightness (0-10)652 last_c = contraction_pcts[-1]653 if last_c < 5: score += 10654 elif last_c < 8: score += 7655 elif last_c < 12: score += 4656 elif last_c < 18: score += 2657 658 # E) Price within base, near pivot (0-5)659 if -15 <= pct_from_pivot <= 2:660 dist = abs(pct_from_pivot)661 if dist <= 3: score += 5662 elif dist <= 7: score += 3663 else: score += 1664 665 score = min(score, 100)666 stage = "Coiling" if score >= 65 else "Forming" if score >= 40 else ""667 668 return {669 "score": score,670 "contractions": n,671 "contraction_pcts": contraction_pcts,672 "vol_contracting": vol_contracting,673 "intra_phase_contracting": intra_phase_contracting,674 "pct_from_pivot": pct_from_pivot,675 "stage": stage,676 }677 678 679# ============================================================680# MARKET BREADTH — historical, reconstructed from price681# ============================================================682 683def compute_breadth_history(684 dfs: dict[str, pd.DataFrame],685 lookback_days: int = 120,686) -> list[dict]:687 """688 Reconstruct historical market breadth from the already-fetched OHLCV batch.689 690 Breadth is a pure function of price history, so a full multi-month curve can691 be backfilled on every scan. This is what makes the trend survive redeploys:692 the ephemeral SQLite DB is wiped on each Railway deploy, but breadth is693 recomputed from the 18-month price window each run — so the chart is never694 empty and never resets.695 696 For every trading day in the lookback window we measure, across the universe:697 - pct_above_50 : % of stocks trading above their 50 DMA (fast breadth)698 - pct_above_200 : % above their 200 DMA (classic breadth-thrust gauge)699 - pct_template : % satisfying Minervini's price-based trend template700 (the RS-percentile criterion is excluded — it is701 cross-sectional and cannot be reconstructed for a past702 day without re-ranking the whole universe that day)703 704 A stock is only counted on days where its 200 DMA is defined, so the705 denominator (`sample_size`) grows in as more history becomes available.706 707 Returns a chronological list of dicts (oldest first).708 """709 above50: dict[str, pd.Series] = {}710 above200: dict[str, pd.Series] = {}711 template: dict[str, pd.Series] = {}712 713 for sym, df in dfs.items():714 close = df["close"]715 if len(close) < 200:716 continue717 sma50 = close.rolling(50).mean()718 sma150 = close.rolling(150).mean()719 sma200 = close.rolling(200).mean()720 high_52w = close.rolling(252, min_periods=100).max()721 low_52w = close.rolling(252, min_periods=100).min()722 sma200_rising = sma200 > sma200.shift(20)723 724 # A stock counts on a given day only once its 200 DMA exists.725 valid = sma200.notna()726 727 above50[sym] = (close > sma50).astype(float).where(valid)728 above200[sym] = (close > sma200).astype(float).where(valid)729 template[sym] = (730 (close > sma50) & (close > sma150) & (close > sma200)731 & (sma50 > sma150) & (sma150 > sma200)732 & sma200_rising733 & (close >= 0.75 * high_52w) # within 25% of 52-week high734 & (close >= 1.30 * low_52w) # 30%+ above 52-week low735 ).astype(float).where(valid)736 737 if not above200:738 return []739 740 # pd.DataFrame aligns the per-stock series on the union of their date indexes.741 df50 = pd.DataFrame(above50).tail(lookback_days)742 df200 = pd.DataFrame(above200).tail(lookback_days)743 dft = pd.DataFrame(template).tail(lookback_days)744 745 out: list[dict] = []746 for date in df200.index:747 row200 = df200.loc[date]748 denom = int(row200.notna().sum()) # stocks with a valid 200 DMA that day749 if denom == 0:750 continue751 pass_ct = int(dft.loc[date].sum())752 out.append({753 "date": date.strftime("%Y-%m-%d"),754 "sample_size": denom,755 "pct_above_50": float(round(100 * df50.loc[date].sum() / denom, 1)),756 "pct_above_200": float(round(100 * row200.sum() / denom, 1)),757 "pct_template": float(round(100 * pass_ct / denom, 1)),758 "count_template": pass_ct,759 })760 return out761 762 763# ============================================================764# RS RATING — IBD/Minervini style percentile ranking765# ============================================================766 767def compute_rs_ratings(768 momentum_data: list[MomentumData],769 benchmark_df: Optional[pd.DataFrame] = None770) -> list[MomentumData]:771 """772 Compute IBD-style RS Rating (0-100 percentile) for each stock.773 774 Method (matches IBD composite RS formula):775 composite = ret_63d × 0.40 ← most recent quarter, highest weight776 + ret_126d × 0.20777 + ret_189d × 0.20778 + ret_252d × 0.20779 780 Each stock is then ranked against the full universe by composite return.781 A rating of 85 means the stock outperformed 85% of the universe.782 783 Minervini minimum: RS Rating >= 70.784 Preferred: RS Rating >= 80.785 786 Args:787 momentum_data : list of MomentumData (must have ret_63d … ret_252d set)788 benchmark_df : NIFTY 50 OHLCV df (used to compute raw rs_score_raw only)789 790 Modifies each MomentumData in-place (sets rs_rating and rs_score_raw).791 Returns the same list for chaining.792 """793 if not momentum_data:794 return momentum_data795 796 # Compute composite return for each stock797 for md in momentum_data:798 if md.data_error:799 md.composite_return = float("nan")800 continue801 rets = [md.ret_63d, md.ret_126d, md.ret_189d, md.ret_252d]802 if any(np.isnan(r) for r in rets):803 # Use available periods with equal weight fallback804 valid = [r for r in rets if not np.isnan(r)]805 md.composite_return = float(np.mean(valid)) if valid else float("nan")806 else:807 md.composite_return = (808 md.ret_63d * 0.40809 + md.ret_126d * 0.20810 + md.ret_189d * 0.20811 + md.ret_252d * 0.20812 )813 814 # Rank by composite return → percentile (0-100)815 # NaN composite = incomplete data (data_error, or a listing too new for the816 # return windows). Map it to -inf so it ranks at the BOTTOM percentile.817 # NOTE: pandas na_option="bottom" assigns NaN the *highest* ascending rank,818 # which would wrongly hand incomplete-data stocks an RS Rating of 100.819 composites = pd.Series(820 {i: (md.composite_return if not np.isnan(md.composite_return) else float("-inf"))821 for i, md in enumerate(momentum_data)}822 )823 ranked = composites.rank(pct=True) * 100824 825 for i, md in enumerate(momentum_data):826 md.rs_rating = round(float(ranked.iloc[i]), 1)827 828 # Compute raw rs_score_raw vs NIFTY benchmark (informational only)829 if benchmark_df is not None and len(benchmark_df) >= 63:830 bdf = benchmark_df.copy()831 bdf.columns = [c.lower() for c in bdf.columns]832 bench_ret_63d = float(bdf["close"].pct_change(63).iloc[-1])833 for md in momentum_data:834 if not md.data_error and not np.isnan(md.ret_63d):835 md.rs_score_raw = round((md.ret_63d - bench_ret_63d) * 100, 2)836 837 logger.info(838 f"[RS Rating] Computed for {len(momentum_data)} stocks. "839 f"Stocks with RS >= 70: {sum(1 for md in momentum_data if md.rs_rating >= 70)}. "840 f"Stocks with RS >= 80: {sum(1 for md in momentum_data if md.rs_rating >= 80)}."841 )842 return momentum_data843 844 845# ============================================================846# SCAN: MOMENTUM847# Minervini Trend Template filter + RS Rating848# ============================================================849 850def scan_momentum(851 universe: list[str] = NIFTY_50_UNIVERSE,852 rs_min: int = 70,853 dfs: Optional[dict[str, pd.DataFrame]] = None,854) -> list[MomentumData]:855 """856 Fetch all stocks, compute indicators, assign RS Ratings,857 then filter by the full Minervini Trend Template.858 859 Returns (passing, rs_by_symbol) where rs_by_symbol maps every non-error860 symbol to its RS rating — used by the SMC scan which runs independently861 of the Minervini filter and still needs RS scores for the confluence model.862 863 Minervini Trend Template (all must pass):864 1. Price > 150 DMA865 2. Price > 200 DMA866 3. 150 DMA > 200 DMA867 4. 200 DMA rising for >= 1 month (20 trading days)868 5. Price > 50 DMA, 50 DMA > 150 DMA > 200 DMA869 6. RS Rating >= 70 (preferably 80+)870 7. Price >= 30% above 52-week low871 8. Price within 25% of 52-week high872 873 Returns list sorted by rs_rating descending.874 """875 logger.info(f"[SCAN] Starting momentum scan on {len(universe)} stocks...")876 raw_data: list[MomentumData] = []877 878 # Step 1: Batch-fetch all OHLCV data in parallel chunks.879 # Reuse a caller-provided batch (the full scan fetches once and shares it880 # across momentum + SMC + reversal) to avoid downloading the universe 2-3x.881 if dfs is None:882 dfs = _fetch_ohlcv_batch(universe, period="18mo")883 884 for symbol in universe:885 if symbol not in dfs:886 raw_data.append(MomentumData(887 symbol=symbol,888 close=0, prev_close=0, change_pct=0,889 sma_50=0, sma_150=0, sma_200=0, sma_200_20d_ago=float("nan"),890 high_52w=0, low_52w=0, rsi_14=0, atr_14=0,891 volume=0, vol_20d_avg=0,892 data_error=True, error_msg="yfinance fetch failed"893 ))894 continue895 896 df = dfs[symbol]897 ind = _compute_indicators(df)898 close_price = ind["close"]899 prev_close = ind["prev_close"]900 change_pct = ((close_price - prev_close) / prev_close * 100901 if prev_close and not np.isnan(prev_close) else 0.0)902 903 raw_data.append(MomentumData(904 symbol=symbol,905 close=close_price,906 prev_close=prev_close,907 change_pct=round(change_pct, 2),908 sma_50=ind["sma_50"],909 sma_150=ind["sma_150"],910 sma_200=ind["sma_200"],911 sma_200_20d_ago=ind["sma_200_20d"],912 high_52w=ind["high_52w"],913 low_52w=ind["low_52w"],914 rsi_14=ind["rsi_14"],915 atr_14=ind["atr_14"],916 volume=ind["volume"],917 vol_20d_avg=ind["vol_20d_avg"],918 ret_63d=ind["ret_63d"],919 ret_126d=ind["ret_126d"],920 ret_189d=ind["ret_189d"],921 ret_252d=ind["ret_252d"],922 ))923 924 # Step 2: Fetch benchmark for rs_score_raw925 bdf = _fetch_ohlcv(BENCHMARK_SYMBOL, period="18mo")926 927 # Step 3: Compute RS Ratings across full universe928 raw_data = compute_rs_ratings(raw_data, benchmark_df=bdf)929 930 # Step 4: Apply Minervini Trend Template filter. The template itself uses931 # the standard RS >= 70 floor; rs_min only tightens it further when raised.932 passing = [933 md for md in raw_data934 if not md.data_error and md.passes_minervini_template and md.rs_rating >= rs_min935 ]936 937 # Sort by RS Rating descending (strongest momentum first)938 passing.sort(key=lambda x: x.rs_rating, reverse=True)939 940 # Step 5: VCP detection — only for stocks that pass the template941 for md in passing:942 df = dfs.get(md.symbol) if dfs else None943 if df is not None:944 vcp = detect_vcp(df)945 md.vcp_score = vcp["score"]946 md.vcp_contractions = vcp["contractions"]947 md.vcp_stage = vcp["stage"]948 md.vcp_pct_from_pivot = vcp["pct_from_pivot"]949 950 # Build a symbol→RS rating map for the FULL universe so the SMC scan951 # (which is independent of Minervini) can still pass meaningful RS scores.952 rs_by_symbol: dict[str, float] = {953 md.symbol: md.rs_rating954 for md in raw_data955 if not md.data_error and md.rs_rating is not None956 }957 958 logger.info(959 f"[SCAN] Momentum scan complete. "960 f"{len(passing)}/{len(universe)} stocks pass Minervini template "961 f"(RS >= {rs_min})."962 )963 # raw_data (all 504 with computed indicators + RS ratings) is returned so964 # callers that are independent of the Minervini filter (e.g. swing setups)965 # can iterate the full universe without a redundant OHLCV fetch.966 return passing, rs_by_symbol, raw_data967 968 969# ============================================================970# SCAN: SWING SETUPS971# ATR-based entry/SL/target generation for trend-aligned stocks972# ============================================================973 974def _bars_since_swing_qualify(975 close_series: pd.Series, sma_200_series: pd.Series, rsi_series: pd.Series,976) -> Optional[int]:977 """Bars since the stock most recently transitioned into the swing-setup978 qualifying condition (close >= 200 DMA and RSI >= 40) and has stayed979 qualifying every bar since — 0 means it just qualified today.980 981 This is an approximation, not a true trade-open-since date: it tracks982 the qualifying CONDITION, not a remembered entry (no position-tracking983 system exists in this scanner — the same tradeoff scan_reversal_watchlist984 already accepts for its cross_date/days_above_200 fields). If the985 condition has flickered false/true recently, this resets and can986 under-report how long ago a hypothetical trader's setup first appeared.987 988 Returns None if the series is too short or the latest bar doesn't989 currently qualify (shouldn't happen — callers only call this after990 confirming today's bar qualifies)."""991 qualifying = (close_series >= sma_200_series) & (rsi_series >= 40)992 if qualifying.empty or not bool(qualifying.iloc[-1]):993 return None994 streak = 0995 for ok in qualifying.to_numpy()[::-1]:996 if not ok:997 break998 streak += 1999 return streak - 11000 1001 1002def scan_swing_setups(1003 stocks: list[MomentumData],1004 min_rr: float = 1.5,1005 dfs: Optional[dict[str, pd.DataFrame]] = None,1006) -> list[SwingSetup]:1007 """1008 Generate ATR-based swing trade setups for a list of stocks.1009 Runs independently of the Minervini screener — any stock with valid1010 OHLCV data can produce a setup.1011 1012 Entry logic (exit-tuned 2026-06-10 — see scripts/tune_swing_exits.py):1013 entry = current close1014 stop_loss = entry - 3.0 × ATR(14) ← wide stop; ~5-7% on NSE large caps1015 target_1 = entry + 1.5 × risk ← = entry + 4.5 × ATR1016 target_2 = entry + 2.5 × risk ← = entry + 7.5 × ATR1017 1018 Why 3.0×ATR / 1.5R: a 3-year walk-forward replay (~1,300-1,800 trades per1019 config, identical entries) showed the old 1.5×ATR stop was hit 70% of the1020 time — converting noise into losses (PF 0.94). Wider stops improved results1021 monotonically across the whole grid; 3.0×ATR/1.5R dominated on avg R1022 (-0.04 → +0.01), profit factor (0.94 → 1.02) and stop-rate (70% → 46%),1023 and beat the old rules in BOTH date halves. Breakeven moves and chandelier1024 trails tested worse than a plain wide bracket.1025 1026 Position sizing:1027 risk_per_trade = 1% of ₹10,00,000 portfolio = ₹10,0001028 shares = risk_per_trade / (entry - stop_loss)1029 1030 Only returns setups where R:R >= min_rr (default 1.5).1031 Returns sorted by R:R descending.1032 1033 If `dfs` (symbol -> OHLCV DataFrame) is supplied, each setup also gets1034 bars_since_signal / is_stale — see _bars_since_swing_qualify(). Without1035 `dfs` these stay None/False (e.g. the inline single-stock path computes1036 them separately from its own already-fetched df).1037 """1038 setups: list[SwingSetup] = []1039 1040 for md in stocks:1041 if md.data_error or md.atr_14 == 0 or np.isnan(md.atr_14):1042 continue1043 # Only long setups on stocks above their 200 DMA (basic uptrend confirmation).1044 # RSI > 40 screens out stocks in a deep momentum collapse.1045 # Neither criterion is as strict as Minervini — this keeps the screener1046 # independent while filtering obviously poor long candidates.1047 if md.sma_200 <= 0 or md.close < md.sma_200:1048 continue1049 if md.rsi_14 < 40:1050 continue1051 1052 entry = md.close1053 stop_loss = entry - (3.0 * md.atr_14)1054 risk_per_share = entry - stop_loss # = 3.0 × ATR1055 if risk_per_share <= 0:1056 continue1057 1058 # Targets are risk multiples so R:R equals the stated ratio exactly:1059 # T1 = entry + 1.5 × risk → R:R = 1.51060 # T2 = entry + 2.5 × risk → R:R = 2.51061 target_1 = entry + (1.5 * risk_per_share)1062 target_2 = entry + (2.5 * risk_per_share)1063 1064 rr_ratio = (target_1 - entry) / risk_per_share # always == 1.51065 if rr_ratio < min_rr:1066 continue1067 1068 risk_pct = round(risk_per_share / entry * 100, 2)1069 1070 # 1% portfolio risk sizing (₹10,00,000 portfolio)1071 portfolio_value = 1_000_0001072 risk_amount = portfolio_value * 0.011073 position_size_pct = round(risk_amount / risk_per_share, 0)1074 1075 bars_since_signal = None1076 is_stale = False1077 if dfs is not None and md.symbol in dfs:1078 ind = _compute_indicators(dfs[md.symbol])1079 bars_since_signal = _bars_since_swing_qualify(1080 ind["_close_series"], ind["_sma_200_series"], ind["_rsi_series"],1081 )1082 is_stale = bars_since_signal is not None and bars_since_signal > SWING_MAX_HOLD1083 1084 setups.append(SwingSetup(1085 symbol=md.symbol,1086 entry_price=round(entry, 2),1087 stop_loss=round(stop_loss, 2),1088 target_1=round(target_1, 2),1089 target_2=round(target_2, 2),1090 atr_14=round(md.atr_14, 2),1091 risk_pct=risk_pct,1092 rr_ratio=round(rr_ratio, 2),1093 position_size_pct=position_size_pct,1094 rsi_14=round(md.rsi_14, 1),1095 rs_rating=md.rs_rating,1096 volume_ratio=md.volume_ratio,1097 bars_since_signal=bars_since_signal,1098 is_stale=is_stale,1099 ))1100 1101 setups.sort(key=lambda x: x.rs_rating, reverse=True)1102 logger.info(f"[SCAN] {len(setups)} swing setups generated (min R:R {min_rr})")1103 return setups1104 1105 1106# ============================================================1107# SCAN: EARLY TREND REVERSAL WATCHLIST1108# Stocks that recently crossed above 200 DMA — potential new uptrends1109# ============================================================1110 1111def scan_reversal_watchlist(1112 universe: list[str] = NIFTY_50_UNIVERSE,1113 days_above_min: int = 15,1114 days_above_max: int = 60,1115 dfs: Optional[dict[str, pd.DataFrame]] = None,1116) -> list[ReversalCandidate]:1117 """1118 Find stocks in early stages of a new uptrend.1119 1120 Filter criteria (must pass to appear in the list):1121 1. Price crossed above 200 DMA within last days_above_max bars1122 2. Price has stayed above 200 DMA for >= days_above_min bars1123 (confirms the cross was not a false breakout)1124 1125 Informational fields (computed and returned, NOT filtered on — the UI1126 surfaces them so the user can judge cross quality):1127 - rsi_improving : RSI(14) today > RSI(14) 5 bars ago1128 - volume_on_cross : volume ratio vs 20-day average on the crossover day1129 - rs_rating : percentile RS computed across this universe1130 1131 Returns sorted by days_above_200 ascending1132 (freshest crosses near the top).1133 """1134 logger.info(f"[WATCHLIST] Scanning {len(universe)} stocks for reversal candidates...")1135 candidates: list[ReversalCandidate] = []1136 raw_data: list[MomentumData] = []1137 ind_map: dict[str, dict] = {} # symbol -> indicators, computed once and reused below1138 1139 # Batch-fetch all OHLCV data in parallel chunks (reuse caller-provided batch).1140 if dfs is None:1141 dfs_map = _fetch_ohlcv_batch(universe, period="18mo")1142 else:1143 dfs_map = dfs1144 1145 for symbol, df in dfs_map.items():1146 ind = _compute_indicators(df)1147 ind_map[symbol] = ind1148 1149 md = MomentumData(1150 symbol=symbol,1151 close=ind["close"], prev_close=ind["prev_close"],1152 change_pct=0,1153 sma_50=ind["sma_50"], sma_150=ind["sma_150"],1154 sma_200=ind["sma_200"], sma_200_20d_ago=ind["sma_200_20d"],1155 high_52w=ind["high_52w"], low_52w=ind["low_52w"],1156 rsi_14=ind["rsi_14"], atr_14=ind["atr_14"],1157 volume=ind["volume"], vol_20d_avg=ind["vol_20d_avg"],1158 ret_63d=ind["ret_63d"], ret_126d=ind["ret_126d"],1159 ret_189d=ind["ret_189d"], ret_252d=ind["ret_252d"],1160 )1161 raw_data.append(md)1162 1163 # Compute RS Ratings across this universe1164 bdf = _fetch_ohlcv(BENCHMARK_SYMBOL, period="18mo")1165 raw_data = compute_rs_ratings(raw_data, benchmark_df=bdf)1166 rs_map = {md.symbol: md.rs_rating for md in raw_data}1167 1168 for md in raw_data:1169 symbol = md.symbol1170 ind = ind_map.get(symbol) # reuse — avoids a second _compute_indicators pass1171 if ind is None:1172 continue1173 1174 close_series = ind["_close_series"]1175 sma_200_series = ind["_sma_200_series"]1176 rsi_series = ind["_rsi_series"]1177 volume_series = ind["_volume_series"]1178 vol_avg_series = ind["_vol_avg_series"]1179 1180 # Find the most recent 200 DMA crossover (close crossed above sma_200)1181 above_200 = close_series > sma_200_series1182 cross_above = above_200 & ~above_200.shift(1, fill_value=False)1183 1184 # Find crossovers within lookback window1185 crosses = cross_above.iloc[-days_above_max:]1186 1187 cross_indices = crosses[crosses].index1188 if len(cross_indices) == 0:1189 continue # no recent cross1190 1191 # Use the MOST RECENT crossover1192 last_cross_ts = cross_indices[-1]1193 last_cross_pos = close_series.index.get_loc(last_cross_ts)1194 1195 # Count consecutive bars above 200 DMA since the cross1196 post_cross = above_200.iloc[last_cross_pos:]1197 # Days above = consecutive True values from cross point1198 days_above = int(post_cross.cumprod().sum())1199 1200 if days_above < days_above_min: