CoolFace
Apppublic

sridattapradeep/India_Equity_Scanner

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
backtest.py531 linesDownload Raw Back to root
1"""2backtest.py — walk-forward screener backtest (Phase 12).3 4Replays the live screener rules over a multi-year OHLCV window and measures5what actually happened next, with NO lookahead:6 7  • Momentum  — on each day a stock NEWLY enters the Minervini template8                (incl. a point-in-time cross-sectional RS rank), measure9                forward 5/10/20/60-session returns vs NIFTY. Plus a daily-10                rebalanced equal-weight equity curve of all template members.11  • Swing     — replays the exact live setup (entry = signal-day close,12                SL = entry − 3.0×ATR14, T1 = entry + 1.5×risk; exit-tuned13                2026-06-10 via scripts/tune_swing_exits.py) bar by bar.14                If SL and T1 are both touched on the same bar, the STOP is15                assumed to fill first (conservative).16  • Reversal  — forward returns from the day a stock first completes17                `reversal_qualify_days` consecutive closes above its 200 DMA18                (the day it would first appear on the live watchlist).19 20Point-in-time discipline:21  - All indicators on day t use data through t only (the live scanner is also22    close-based at EOD). Entries are at day-t close; outcomes start at t+1.23  - RS Rating is re-ranked cross-sectionally for EVERY day from each stock's24    own composite return — the same formula compute_rs_ratings() uses live.25 26Honest limitations (also surfaced in the API payload):27  - The universe is TODAY'S Nifty 500 — stocks that fell out of the index in28    the past are missing, which biases results upward (survivorship bias).29  - SMC and VCP are not replayed (their per-day detection is loop-heavy);30    this covers momentum / swing / reversal.31 32Like breadth, results are a pure function of the price window: recomputed33once per trading day in memory, so they survive Railway's ephemeral DB.34"""35from __future__ import annotations36 37import logging38from datetime import datetime, timezone39from typing import Optional40 41import numpy as np42import pandas as pd43 44logger = logging.getLogger(__name__)45 46HORIZONS = (5, 10, 20, 60)          # forward sessions for the event studies47SWING_MAX_HOLD = 40                  # bars before a swing trade times out48REVERSAL_QUALIFY_DAYS = 15           # consecutive closes above 200 DMA49 50 51# ── helpers ───────────────────────────────────────────────────────────────────52 53def _round(v, d=2) -> Optional[float]:54    try:55        f = float(v)56        return None if (np.isnan(f) or np.isinf(f)) else round(f, d)57    except (TypeError, ValueError):58        return None59 60 61def _wilder_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:62    """Same ATR the live scanner uses (scanner._compute_indicators)."""63    high, low, close = df["high"], df["low"], df["close"]64    tr1 = high - low65    tr2 = (high - close.shift(1)).abs()66    tr3 = (low - close.shift(1)).abs()67    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)68    return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()69 70 71def _consecutive_true(arr: np.ndarray) -> np.ndarray:72    """Length of the running True streak ending at each position."""73    n = len(arr)74    idx = np.arange(n)75    reset = np.where(~arr, idx, -1)76    last_reset = np.maximum.accumulate(reset)77    return np.where(arr, idx - last_reset, 0)78 79 80def _horizon_stats(signal_mask: pd.DataFrame, closes: pd.DataFrame,81                   bench: pd.Series, horizons=HORIZONS) -> dict:82    """Forward absolute + NIFTY-excess return stats at each horizon, measured83    from the close of every True cell in signal_mask."""84    out = {}85    sig = signal_mask.fillna(False).to_numpy()86    for h in horizons:87        fwd = closes.shift(-h) / closes - 188        bench_fwd = bench.shift(-h) / bench - 189        excess = fwd.sub(bench_fwd, axis=0)90 91        abs_vals = fwd.to_numpy()[sig]92        exc_vals = excess.to_numpy()[sig]93        abs_vals = abs_vals[~np.isnan(abs_vals)]94        exc_vals = exc_vals[~np.isnan(exc_vals)]95 96        out[str(h)] = {97            "events":          int(len(exc_vals)),98            "win_rate":        _round(100 * np.mean(abs_vals > 0)) if len(abs_vals) else None,99            "win_rate_excess": _round(100 * np.mean(exc_vals > 0)) if len(exc_vals) else None,100            "avg_return":      _round(100 * np.mean(abs_vals)) if len(abs_vals) else None,101            "avg_excess":      _round(100 * np.mean(exc_vals)) if len(exc_vals) else None,102            "median_excess":   _round(100 * np.median(exc_vals)) if len(exc_vals) else None,103        }104    return out105 106 107def _max_drawdown_pct(curve: pd.Series) -> Optional[float]:108    if curve.empty:109        return None110    return _round(100 * (curve / curve.cummax() - 1).min())111 112 113# ── shared panels ─────────────────────────────────────────────────────────────114 115def _template_panel(dfs: dict[str, pd.DataFrame], rs_min: float = 70.0) -> dict:116    """Point-in-time Minervini-template panels shared by the production117    backtest and the exit-tuning harness. Returns118    {closes, sma200, template, entries} (all date × symbol DataFrames)."""119    closes = pd.DataFrame({s: d["close"] for s, d in dfs.items()}).sort_index()120 121    sma50  = closes.rolling(50).mean()122    sma150 = closes.rolling(150).mean()123    sma200 = closes.rolling(200).mean()124    sma200_rising = sma200 > sma200.shift(20)125    h52 = closes.rolling(252).max()126    l52 = closes.rolling(252).min()127 128    composite = (129        closes.pct_change(63)  * 0.40130        + closes.pct_change(126) * 0.20131        + closes.pct_change(189) * 0.20132        + closes.pct_change(252) * 0.20133    )134    # Cross-sectional percentile per day — same formula as compute_rs_ratings.135    # Stocks with an incomplete composite (NaN) are simply ineligible that day.136    rs = composite.rank(axis=1, pct=True) * 100137 138    template = (139        (closes > sma50) & (closes > sma150) & (closes > sma200)140        & (sma50 > sma150) & (sma150 > sma200)141        & sma200_rising142        & (closes >= 0.75 * h52)143        & (closes >= 1.30 * l52)144        & (rs >= rs_min)145    )146    entries = template & ~template.shift(1, fill_value=False)147    return {"closes": closes, "sma200": sma200, "rs": rs,148            "template": template, "entries": entries}149 150 151# ── swing trade simulation (parameterised — used for exit-rule tuning) ────────152 153def simulate_swing_trades(154    dfs: dict[str, pd.DataFrame],155    entries: pd.DataFrame,156    *,157    stop_atr: float = 1.5,158    target_r: Optional[float] = 2.5,159    max_hold: int = SWING_MAX_HOLD,160    breakeven_at_r: Optional[float] = None,161    trail_atr: Optional[float] = None,162    collect_trades: bool = False,163) -> dict:164    """165    Bar-by-bar replay of swing trades opened at the close of each True cell in166    `entries`.167 168    Exit rules (all measured against risk = stop_atr × ATR14 at entry):169      stop_atr        initial stop = entry − stop_atr × ATR170      target_r        fixed target = entry + target_r × risk (None = no target)171      breakeven_at_r  once the high (from the NEXT bar on) reaches entry +172                      N × risk, raise the stop to entry173      trail_atr       chandelier trail: stop = max(stop, highest high since174                      entry − trail_atr × ATR-at-entry), evaluated with highs175                      through the PREVIOUS bar (no same-bar lookahead)176      max_hold        exit at close after this many bars177 178    Same-bar conservatism: if a bar touches both the stop and the target, the179    STOP is assumed to fill first. R is always relative to the INITIAL risk.180    """181    closed_r: list[float] = []182    hold_bars: list[int] = []183    trade_log: list[dict] = []184    n_target = n_stop = n_timeout = n_open = 0185 186    for sym in entries.columns:187        df = dfs.get(sym)188        if df is None:189            continue190        col = entries[sym]191        ent_dates = col.index[col.fillna(False)]192        if len(ent_dates) == 0:193            continue194        atr = _wilder_atr(df)195        highs_a  = df["high"].to_numpy(dtype=float)196        lows_a   = df["low"].to_numpy(dtype=float)197        closes_a = df["close"].to_numpy(dtype=float)198        n = len(df)199        busy_until = -1   # skip overlapping signals while a trade is open200 201        for dt in ent_dates:202            try:203                p = df.index.get_loc(dt)204            except KeyError:205                continue206            if p <= busy_until:207                continue208            entry = closes_a[p]209            a = float(atr.iloc[p]) if not np.isnan(atr.iloc[p]) else 0.0210            if a <= 0 or entry <= 0:211                continue212            risk = stop_atr * a213            stop = entry - risk214            target = entry + target_r * risk if target_r is not None else None215            be_trigger = entry + breakeven_at_r * risk if breakeven_at_r is not None else None216 217            outcome = None218            exit_bar = None219            r_val = 0.0220            highest_high = -np.inf      # highs AFTER entry only (no day-p high)221            last_bar = min(p + max_hold, n - 1)222 223            for j in range(p + 1, last_bar + 1):224                # Raise the stop using information through bar j-1 only.225                if trail_atr is not None and np.isfinite(highest_high):226                    stop = max(stop, highest_high - trail_atr * a)227                if be_trigger is not None and highest_high >= be_trigger:228                    stop = max(stop, entry)229 230                if lows_a[j] <= stop:               # stop first — conservative231                    outcome, exit_bar = "stop", j232                    r_val = (stop - entry) / risk233                    break234                if target is not None and highs_a[j] >= target:235                    outcome, exit_bar = "target", j236                    r_val = float(target_r)237                    break238                highest_high = max(highest_high, highs_a[j])239 240            if outcome is None:241                if last_bar == p + max_hold:242                    outcome, exit_bar = "timeout", last_bar243                    r_val = (closes_a[last_bar] - entry) / risk244                else:245                    n_open += 1               # ran out of data — still open246                    busy_until = n            # nothing later can be evaluated247                    continue248 249            closed_r.append(r_val)250            if outcome == "target":  n_target += 1251            elif outcome == "stop":  n_stop += 1252            else:                    n_timeout += 1253            hold_bars.append(exit_bar - p)254            if collect_trades:255                trade_log.append({"symbol": sym, "entry_date": dt,256                                  "r": r_val, "hold": exit_bar - p,257                                  "outcome": outcome,258                                  "entry": entry, "stop": entry - risk,259                                  "target": target,260                                  "exit_date": df.index[exit_bar]})261            busy_until = exit_bar262 263    r = np.array(closed_r, dtype=float)264    n_closed = len(r)265    gross_win  = float(r[r > 0].sum()) if n_closed else 0.0266    gross_loss = float(-r[r < 0].sum()) if n_closed else 0.0267    stats = {268        "trades_closed": n_closed,269        "trades_open": n_open,270        "win_rate": _round(100 * np.mean(r > 0)) if n_closed else None,271        "avg_r": _round(float(r.mean())) if n_closed else None,272        "total_r": _round(float(r.sum()), 1) if n_closed else None,273        "profit_factor": _round(gross_win / gross_loss) if gross_loss > 0 else None,274        "target_pct":  _round(100 * n_target / n_closed) if n_closed else None,275        "stop_pct":    _round(100 * n_stop / n_closed) if n_closed else None,276        "timeout_pct": _round(100 * n_timeout / n_closed) if n_closed else None,277        "avg_hold_bars": _round(float(np.mean(hold_bars)), 1) if hold_bars else None,278    }279    if collect_trades:280        stats["trades"] = trade_log281    return stats282 283 284# ── single-stock backtest (on-demand, no storage) ────────────────────────────285 286def backtest_single_stock(287    symbol: str,288    df: pd.DataFrame,289    horizons: tuple[int, ...] = HORIZONS,290    stop_atr: float = 3.0,291    target_r: float = 1.5,292    max_hold: int = SWING_MAX_HOLD,293    max_bars: int = 2500,294) -> dict:295    """296    Walk-forward backtest of ONE stock over up to `max_bars` (~10y) of its own297    history. Pure function of the price series — nothing is persisted.298 299    Template = the 8 PRICE criteria of the Minervini trend template. The RS300    percentile criterion is cross-sectional (needs the whole universe per day)301    and is excluded here, exactly as compute_breadth_history does — flagged in302    the payload so the UI can say so.303 304    Swing trades replay the LIVE exit rules (stop_atr/target_r mirror305    scanner.scan_swing_setups). The exposure curve compares "long only while306    the stock is in the template (decided at the prior close)" against buy &307    hold over the same window.308    """309    df = df.tail(max_bars)310    if len(df) < 300:311        return {"status": "insufficient_history", "bars": int(len(df))}312 313    close = df["close"]314    sma50  = close.rolling(50).mean()315    sma150 = close.rolling(150).mean()316    sma200 = close.rolling(200).mean()317    sma200_rising = sma200 > sma200.shift(20)318    h52 = close.rolling(252).max()319    l52 = close.rolling(252).min()320 321    template = (322        (close > sma50) & (close > sma150) & (close > sma200)323        & (sma50 > sma150) & (sma150 > sma200)324        & sma200_rising325        & (close >= 0.75 * h52)326        & (close >= 1.30 * l52)327    ).fillna(False)328    entries = template & ~template.shift(1, fill_value=False)329 330    # ── swing replay with the live exit rules ────────────────────────────────331    sim = simulate_swing_trades(332        {symbol: df}, entries.to_frame(symbol),333        stop_atr=stop_atr, target_r=target_r, max_hold=max_hold,334        collect_trades=True,335    )336    trade_log = sim.pop("trades", [])337    trades = [338        {339            "entry_date": pd.Timestamp(t["entry_date"]).strftime("%Y-%m-%d"),340            "exit_date":  pd.Timestamp(t["exit_date"]).strftime("%Y-%m-%d"),341            "entry": _round(t["entry"]), "stop": _round(t["stop"]),342            "target": _round(t["target"]),343            "outcome": t["outcome"], "r": _round(t["r"]), "hold": int(t["hold"]),344        }345        for t in trade_log346    ]347    trades.reverse()           # newest first for display348 349    # ── forward returns from each template entry (absolute — single stock) ───350    ent_mask = entries.to_numpy()351    horizon_stats = {}352    for h in horizons:353        fwd = (close.shift(-h) / close - 1).to_numpy()[ent_mask]354        fwd = fwd[~np.isnan(fwd)]355        horizon_stats[str(h)] = {356            "events": int(len(fwd)),357            "win_rate": _round(100 * np.mean(fwd > 0)) if len(fwd) else None,358            "avg_return": _round(100 * np.mean(fwd)) if len(fwd) else None,359            "median_return": _round(100 * np.median(fwd)) if len(fwd) else None,360        }361 362    # ── template-exposure curve vs buy & hold (weekly-sampled for payload) ───363    ret = close.pct_change().fillna(0.0)364    in_pos = template.shift(1, fill_value=False)365    strat = (1 + ret.where(in_pos, 0.0)).cumprod() * 100366    bh    = (1 + ret).cumprod() * 100367    idx = list(range(0, len(strat), 5))368    if idx[-1] != len(strat) - 1:369        idx.append(len(strat) - 1)370    curve = [371        {"date": strat.index[i].strftime("%Y-%m-%d"),372         "strategy": _round(float(strat.iloc[i])),373         "benchmark": _round(float(bh.iloc[i]))}374        for i in idx375    ]376 377    return {378        "status": "ok",379        "symbol": symbol,380        "computed_at": datetime.now(timezone.utc).isoformat(),381        "data_from": df.index[0].strftime("%Y-%m-%d"),382        "data_through": df.index[-1].strftime("%Y-%m-%d"),383        "bars": int(len(df)),384        "pct_days_in_template": _round(100 * float(template.mean()), 1),385        "entry_signals": int(entries.sum()),386        "swing": {**sim, "rules": f"SL = entry − {stop_atr}×ATR14 · "387                                  f"T1 = entry + {target_r}×risk · timeout {max_hold} bars"},388        "horizons": horizon_stats,389        "exposure": {390            "strategy_return_pct":  _round(float(strat.iloc[-1]) - 100),391            "benchmark_return_pct": _round(float(bh.iloc[-1]) - 100),392            "strategy_max_dd_pct":  _max_drawdown_pct(strat),393            "benchmark_max_dd_pct": _max_drawdown_pct(bh),394        },395        "equity_curve": curve,396        "trades": trades[:60],397        "caveats": [398            "Single-stock template uses the 8 price criteria only — the RS "399            "percentile criterion needs the whole universe per day and is "400            "excluded (same approach as the breadth reconstruction).",401            "Returns are absolute (not NIFTY-excess) and pre-costs.",402        ],403    }404 405 406# ── core ──────────────────────────────────────────────────────────────────────407 408def run_backtest(409    dfs: dict[str, pd.DataFrame],410    benchmark_df: Optional[pd.DataFrame],411    horizons: tuple[int, ...] = HORIZONS,412    rs_min: float = 70.0,413    swing_max_hold: int = SWING_MAX_HOLD,414    reversal_qualify_days: int = REVERSAL_QUALIFY_DAYS,415) -> dict:416    """Replay the screeners over `dfs` (symbol → OHLCV with lowercase columns,417    DatetimeIndex). Returns a JSON-safe dict; {"status": "no_data"} when the418    inputs are unusable."""419    if not dfs:420        return {"status": "no_data"}421 422    panel = _template_panel(dfs, rs_min=rs_min)423    closes = panel["closes"]424    if closes.empty or len(closes) < 300:425        return {"status": "no_data"}426 427    # ── benchmark, aligned to the union calendar ─────────────────────────────428    bench = None429    if benchmark_df is not None and len(benchmark_df):430        b = benchmark_df.copy()431        b.columns = [c.lower() for c in b.columns]432        bench = b["close"].reindex(closes.index).ffill()433    if bench is None or bench.dropna().empty:434        # Degraded mode: equal-weight universe average stands in for NIFTY.435        bench = closes.mean(axis=1)436 437    # ── point-in-time template panel (shared with the tuning harness) ────────438    sma200   = panel["sma200"]439    rs       = panel["rs"]440    template = panel["template"]441    entries  = panel["entries"]442 443    # ── momentum: entry-event study ──────────────────────────────────────────444    momentum = {445        "signals": int(entries.to_numpy().sum()),446        "horizons": _horizon_stats(entries, closes, bench, horizons),447    }448 449    # ── momentum: equal-weight equity curve (membership decided at t-1 close) ─450    ret = closes.pct_change()451    members_lag = template.shift(1, fill_value=False)452    active = members_lag & ret.notna()453    port_ret = ret.where(active).mean(axis=1)454    member_count = active.sum(axis=1)455 456    has_members = member_count > 0457    equity_curve: list[dict] = []458    curve_stats: dict = {}459    if has_members.any():460        start = has_members.idxmax()461        pr = port_ret.loc[start:].fillna(0.0)462        strat = (1 + pr).cumprod() * 100463        br = bench.loc[start:].pct_change().fillna(0.0)464        bcurve = (1 + br).cumprod() * 100465 466        n_days = max(len(strat) - 1, 1)467        curve_stats = {468            "start": start.strftime("%Y-%m-%d"),469            "trading_days": int(n_days),470            "strategy_return_pct":  _round(strat.iloc[-1] - 100),471            "benchmark_return_pct": _round(bcurve.iloc[-1] - 100),472            "strategy_ann_pct":  _round(100 * ((strat.iloc[-1] / 100) ** (252 / n_days) - 1)),473            "benchmark_ann_pct": _round(100 * ((bcurve.iloc[-1] / 100) ** (252 / n_days) - 1)),474            "strategy_max_dd_pct":  _max_drawdown_pct(strat),475            "benchmark_max_dd_pct": _max_drawdown_pct(bcurve),476            "avg_holdings": _round(float(member_count.loc[start:].mean()), 1),477        }478        equity_curve = [479            {480                "date": ts.strftime("%Y-%m-%d"),481                "strategy": _round(sv),482                "benchmark": _round(bv),483            }484            for ts, sv, bv in zip(strat.index, strat.values, bcurve.values)485        ]486    momentum["portfolio"] = curve_stats487 488    # ── swing: bar-by-bar trade replay on template entry days ────────────────489    # Parameters MUST mirror scanner.scan_swing_setups (exit-tuned 2026-06-10).490    swing = simulate_swing_trades(491        dfs, entries,492        stop_atr=3.0, target_r=1.5, max_hold=swing_max_hold,493    )494    swing["rules"] = (495        "entry = signal close · SL = entry − 3.0×ATR14 · T1 = entry + 1.5×risk · "496        f"timeout {swing_max_hold} bars · same-bar SL+T1 counts as stop"497    )498 499    # ── reversal: first day with N consecutive closes above 200 DMA ──────────500    above200 = (closes > sma200).fillna(False)501    qual = pd.DataFrame(502        {s: _consecutive_true(above200[s].to_numpy()) for s in above200.columns},503        index=above200.index,504    ) == reversal_qualify_days505    reversal = {506        "signals": int(qual.to_numpy().sum()),507        "qualify_days": reversal_qualify_days,508        "horizons": _horizon_stats(qual, closes, bench, horizons),509    }510 511    first_eval = rs.dropna(how="all").index512    return {513        "status": "ok",514        "computed_at": datetime.now(timezone.utc).isoformat(),515        "data_from": closes.index[0].strftime("%Y-%m-%d"),516        "data_through": closes.index[-1].strftime("%Y-%m-%d"),517        "eval_from": first_eval[0].strftime("%Y-%m-%d") if len(first_eval) else None,518        "symbols": int(closes.shape[1]),519        "horizons": list(horizons),520        "momentum": momentum,521        "swing": swing,522        "reversal": reversal,523        "equity_curve": equity_curve,524        "caveats": [525            "Backtest replays TODAY'S Nifty 500 constituents — past index "526            "dropouts are missing (survivorship bias inflates results).",527            "Signals use end-of-day closes only, matching the live scanner.",528            "SMC and VCP screeners are not replayed in this version.",529        ],530    }531