CoolFace
Apppublic

sridattapradeep/India_Equity_Scanner

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
scanner.py1309 linesDownload Raw Back to root
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:

Showing the first 1,200 of 1309 lines. Download the file for the rest.