sridattapradeep/India_Equity_Scanner
0
1"""2Regression tests for smc_engine accuracy fixes (2026-06-10 audit):3 - BOS/CHoCH labels must be assigned in chronological (event-time) order,4 not swing-detection order.5 - Premium/Discount classification must match the equilibrium band the6 function itself computes (ICT convention: premium = upper half).7 - detect_fvg returns mitigated FVGs too (consumers filter on .active).8 - _compute_indicators "sma_200_20d" is the value exactly 20 bars back.9"""10import numpy as np11import pandas as pd12import pytest13 14from scanner import _compute_indicators15from smc_engine import (16 SwingPoint,17 detect_fvg,18 detect_market_structure,19 detect_mss,20 detect_premium_discount,21)22 23 24def _df(close: np.ndarray, start="2025-01-01") -> pd.DataFrame:25 idx = pd.date_range(start, periods=len(close), freq="B")26 return pd.DataFrame(27 {"open": close, "high": close + 1, "low": close - 1,28 "close": close, "volume": 1e6},29 index=idx,30 )31 32 33# ── Structure labelling in time order ─────────────────────────────────────────34 35def _out_of_order_scenario():36 """Swing high at bar 10 is crossed AFTER swing low at bar 20 is crossed.37 Chronologically: bear break first (BOS — bias was neutral), then bull38 break (CHoCH — it flips the bear trend)."""39 n = 12040 close = np.full(n, 100.0)41 close[30] = 85.0 # crosses below swing low (90) at bar 3042 close[31:50] = 95.043 close[50:] = 115.0 # crosses above swing high (110) at bar 5044 df = _df(close)45 sh = [SwingPoint(price=110.0, bar_index=10,46 timestamp=df.index[10].to_pydatetime(), kind="high")]47 sl = [SwingPoint(price=90.0, bar_index=20,48 timestamp=df.index[20].to_pydatetime(), kind="low")]49 return df, sh, sl50 51 52def test_structure_labels_follow_event_time_order():53 df, sh, sl = _out_of_order_scenario()54 events = detect_market_structure(df, sh, sl)55 assert [(e.bar_index, e.direction, e.event_type) for e in events] == [56 (30, "bear", "BOS"), # first event ever — bias neutral → BOS57 (50, "bull", "CHoCH"), # flips the bear trend → CHoCH58 ]59 60 61def test_mss_labels_follow_event_time_order():62 df, sh, sl = _out_of_order_scenario()63 events = detect_mss(df, sh, sl)64 # ICT framework: a break against (or from) a non-aligned bias = MSS.65 assert [(e.bar_index, e.direction, e.event_type) for e in events] == [66 (30, "bear", "MSS"), # dir was 0 (> -1) → MSS67 (50, "bull", "MSS"), # dir was -1 (< 1) → MSS68 ]69 70 71def test_structure_events_sorted_chronologically():72 df, sh, sl = _out_of_order_scenario()73 events = detect_market_structure(df, sh, sl)74 bars = [e.bar_index for e in events]75 assert bars == sorted(bars)76 77 78# ── Premium / Discount classification ─────────────────────────────────────────79 80@pytest.mark.parametrize("pct_of_range,expected", [81 (0.99, "PREMIUM"),82 (0.80, "PREMIUM"), # old code mislabelled this EQUILIBRIUM83 (0.55, "PREMIUM"),84 (0.50, "EQUILIBRIUM"),85 (0.48, "EQUILIBRIUM"),86 (0.40, "DISCOUNT"),87 (0.05, "DISCOUNT"),88])89def test_pd_zone_classification(pct_of_range, expected):90 lo, hi = 100.0, 200.091 price = lo + pct_of_range * (hi - lo)92 df = _df(np.full(60, price))93 zone = detect_premium_discount(df, swing_high=hi, swing_low=lo)94 assert zone.current_zone == expected95 # band boundaries themselves are unchanged96 assert zone.equilibrium_top == pytest.approx(lo + 0.525 * (hi - lo))97 assert zone.equilibrium_bottom == pytest.approx(lo + 0.475 * (hi - lo))98 99 100# ── FVG: mitigated gaps are returned (consumers filter on .active) ───────────101 102def test_detect_fvg_returns_mitigated_gaps():103 n = 40104 close = np.full(n, 100.0)105 df = _df(close)106 # Build a clean bull FVG at bar 21: low[21] > high[19] and close[20] > high[19]107 df.iloc[19, df.columns.get_loc("high")] = 101.0108 df.iloc[20, df.columns.get_loc("close")] = 104.0109 df.iloc[21, df.columns.get_loc("low")] = 103.0110 df.iloc[21, df.columns.get_loc("close")] = 105.0111 # Mitigate it: close drops below the gap bottom (101) later112 df.iloc[30, df.columns.get_loc("close")] = 99.0113 114 fvgs = detect_fvg(df, lookback_bars=40)115 bull = [f for f in fvgs if f.fvg_type == "bull"]116 assert bull, "bull FVG should be detected"117 assert any(not f.active for f in bull), "mitigated FVG must be returned"118 119 120# ── sma_200_20d off-by-one ────────────────────────────────────────────────────121 122def test_sma_200_20d_is_exactly_20_bars_back():123 # Strictly increasing close → sma_200 strictly increasing once defined.124 close = pd.Series(np.arange(1.0, 301.0)) # 300 bars125 df = _df(close.to_numpy())126 ind = _compute_indicators(df)127 sma200 = df["close"].rolling(200).mean()128 assert ind["sma_200_20d"] == pytest.approx(float(sma200.iloc[-21]))129 # sanity: it differs from the 19-bars-back value the old code used130 assert ind["sma_200_20d"] != pytest.approx(float(sma200.iloc[-20]))131 