# source: https://raw.githubusercontent.com/ayhanarashtasin/MultiLiveBot/e479493782eebf48586320b7bbea8dc212724516/strategies/approved/LITLuxBook001_15M.py
# directory_url: https://github.com/ayhanarashtasin/MultiLiveBot/blob/main/strategies/approved/
# User: ayhanarashtasin
# Repository: MultiLiveBot
# --------------------"""Frozen LITUSDT 15m LuxAlgo Long-Only Champion Book #001 (30 rules / 6 slots).

Ported bar-for-bar from the search that produced it:
``E:\\EC\\Binance-AggTrades\\Test-08-19-Sept`` — ``features.py`` (primitive maths),
``multibook.py`` (multi-slot walk), and ``optimized_strategies.json`` (champion pick).

Thirty rules are distributed across SIX concurrent sleeves (slots 0..5).
On each closed 15m bar, each flat slot evaluates its assigned rules in priority order.
The winning rule owns that slot's barrier geometry (TP, SL, max_hold).
All slots run independently at 1/6th equity each, so total exposure never exceeds 100%.
"""
from __future__ import annotations

import numpy as np
import pandas as pd
from freqtrade.strategy import IStrategy


# ---------------------------------------------------------------------------
# Primitive maths — pure numpy / pandas implementations
# ---------------------------------------------------------------------------
def _rma(v: np.ndarray, period: int) -> np.ndarray:
    n = len(v)
    out = np.full(n, np.nan, dtype=np.float64)
    if n < period:
        return out
    cur = float(np.mean(v[:period]))
    out[period - 1] = cur
    alpha = 1.0 / period
    for i in range(period, n):
        cur = cur * (1.0 - alpha) + v[i] * alpha
        out[i] = cur
    return out


def _ema(v: np.ndarray, period: int) -> np.ndarray:
    return pd.Series(v).ewm(span=period, adjust=False).mean().to_numpy(dtype=np.float64)


def _true_range(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray:
    n = len(c)
    tr = np.empty(n, dtype=np.float64)
    tr[0] = h[0] - l[0]
    tr[1:] = np.maximum.reduce([h[1:] - l[1:], np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])])
    return tr


def _atr(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int = 14) -> np.ndarray:
    return _rma(_true_range(h, l, c), period)


def _supertrend(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int, mult: float):
    """Returns (line, direction, atr) with direction: +1 up / -1 down."""
    n = len(c)
    atr = _rma(_true_range(h, l, c), period)
    hl2 = (h + l) * 0.5
    lb, ub = hl2 - mult * atr, hl2 + mult * atr
    lower = np.full(n, np.nan, dtype=np.float64)
    upper = np.full(n, np.nan, dtype=np.float64)
    line = np.full(n, np.nan, dtype=np.float64)
    direction = np.zeros(n, dtype=np.int8)
    if n < period:
        return line, direction, atr
    s = period - 1
    lower[s], upper[s] = lb[s], ub[s]
    direction[s] = 1 if c[s] > ub[s] else (-1 if c[s] < lb[s] else 1)
    line[s] = lower[s] if direction[s] == 1 else upper[s]
    for i in range(period, n):
        lower[i] = max(lb[i], lower[i - 1]) if c[i - 1] > lower[i - 1] else lb[i]
        upper[i] = min(ub[i], upper[i - 1]) if c[i - 1] < upper[i - 1] else ub[i]
        p = direction[i - 1]
        if p == -1 and c[i] > upper[i - 1]:
            direction[i] = 1
        elif p == 1 and c[i] < lower[i - 1]:
            direction[i] = -1
        else:
            direction[i] = p
        line[i] = lower[i] if direction[i] == 1 else upper[i]
    return line, direction, atr


def _evasive_supertrend(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int, mult: float):
    """LuxAlgo Evasive SuperTrend with its ATR noise-zone band."""
    n = len(c)
    atr = _rma(_true_range(h, l, c), period)
    hl2 = (h + l) * 0.5
    upper, lower = hl2 + mult * atr, hl2 - mult * atr
    band = np.full(n, np.nan, dtype=np.float64)
    direction = np.zeros(n, dtype=np.int8)
    if n < period:
        return band, direction, atr
    start = period - 1
    direction[start] = 1 if c[start] >= hl2[start] else -1
    band[start] = lower[start] if direction[start] == 1 else upper[start]
    for i in range(period, n):
        noisy = abs(c[i] - band[i - 1]) < atr[i]
        if direction[i - 1] == 1:
            candidate = band[i - 1] - atr[i] * 0.5 if noisy else max(lower[i], band[i - 1])
            direction[i] = -1 if c[i] < candidate else 1
            band[i] = upper[i] if direction[i] == -1 else candidate
        else:
            candidate = band[i - 1] + atr[i] * 0.5 if noisy else min(upper[i], band[i - 1])
            direction[i] = 1 if c[i] > candidate else -1
            band[i] = lower[i] if direction[i] == 1 else candidate
    return band, direction, atr


def _supertrend_oscillator(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int, mult: float):
    line, _, atr = _supertrend(h, l, c, period, mult)
    osc = np.clip((c - line) / (2.0 * mult * np.maximum(atr, 1e-12)), -1.0, 1.0)
    hist = osc - _ema(osc, 9)
    return osc, hist


def _rsi(c: np.ndarray, period: int) -> np.ndarray:
    diff = np.diff(c, prepend=c[0])
    gain = np.where(diff > 0, diff, 0.0)
    loss = np.where(diff < 0, -diff, 0.0)
    rs = _rma(gain, period) / np.maximum(_rma(loss, period), 1e-12)
    return 100.0 - (100.0 / (1.0 + rs))


def _stoch_rsi(c: np.ndarray, rsi_len: int, stoch_len: int, k_smooth: int, d_smooth: int):
    r = _rsi(c, rsi_len)
    s = pd.Series(r)
    ll = s.rolling(stoch_len).min().to_numpy()
    hh = s.rolling(stoch_len).max().to_numpy()
    stoch = 100.0 * (r - ll) / np.maximum(hh - ll, 1e-12)
    k = pd.Series(np.nan_to_num(stoch, nan=50.0)).rolling(k_smooth).mean().to_numpy(dtype=np.float64)
    d = pd.Series(k).rolling(d_smooth).mean().to_numpy(dtype=np.float64)
    return k, d


def _wavetrend(h: np.ndarray, l: np.ndarray, c: np.ndarray, ch_len: int, avg_len: int):
    hlc3 = (h + l + c) / 3.0
    esa = _ema(hlc3, ch_len)
    d = _ema(np.abs(hlc3 - esa), ch_len)
    ci = (hlc3 - esa) / np.maximum(0.015 * d, 1e-12)
    wt1 = _ema(ci, avg_len)
    wt2 = pd.Series(wt1).rolling(4).mean().to_numpy(dtype=np.float64)
    return wt1, wt2


def _adx(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int):
    n = len(c)
    tr = _true_range(h, l, c)
    up_move = np.zeros(n)
    dn_move = np.zeros(n)
    up_move[1:] = h[1:] - h[:-1]
    dn_move[1:] = l[:-1] - l[1:]
    plus_dm = np.where((up_move > dn_move) & (up_move > 0), up_move, 0.0)
    minus_dm = np.where((dn_move > up_move) & (dn_move > 0), dn_move, 0.0)
    tr_smooth = _rma(tr, period)
    plus_di = 100.0 * _rma(plus_dm, period) / np.maximum(tr_smooth, 1e-12)
    minus_di = 100.0 * _rma(minus_dm, period) / np.maximum(tr_smooth, 1e-12)
    di_sum = plus_di + minus_di
    dx = 100.0 * np.abs(plus_di - minus_di) / np.maximum(di_sum, 1e-12)
    adx = np.full(n, np.nan, dtype=np.float64)
    offset = period - 1
    if n > offset:
        adx[offset:] = _rma(dx[offset:], period)
    return adx, plus_di, minus_di


def _tsi(c: np.ndarray, r: int, s: int, g: int):
    diff = np.diff(c, prepend=c[0])
    abs_diff = np.abs(diff)
    smooth_diff = _ema(_ema(diff, r), s)
    smooth_abs = _ema(_ema(abs_diff, r), s)
    tsi = 100.0 * smooth_diff / np.maximum(smooth_abs, 1e-12)
    signal = _ema(tsi, g)
    return tsi, signal


def _cmo(c: np.ndarray, period: int) -> np.ndarray:
    diff = np.diff(c, prepend=c[0])
    cz = pd.Series(np.where(diff > 0, diff, 0.0)).rolling(period).sum().to_numpy()
    cf = pd.Series(np.where(diff < 0, -diff, 0.0)).rolling(period).sum().to_numpy()
    denom = cz + cf
    return np.where(denom > 1e-12, 100.0 * (cz - cf) / denom, 0.0)


def _cci(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int) -> np.ndarray:
    tp = (h + l + c) / 3.0
    series = pd.Series(tp)
    sma = series.rolling(period).mean().to_numpy()
    mad = series.rolling(period).apply(lambda x: np.mean(np.abs(x - np.mean(x))), raw=True).to_numpy()
    return (tp - sma) / np.maximum(0.015 * mad, 1e-12)


def _williams_r(h: np.ndarray, l: np.ndarray, c: np.ndarray, period: int) -> np.ndarray:
    hh = pd.Series(h).rolling(period).max().to_numpy()
    ll = pd.Series(l).rolling(period).min().to_numpy()
    rng = hh - ll
    return np.where(rng > 1e-12, -100.0 * (hh - c) / rng, -50.0)


def _bollinger_bands(c: np.ndarray, period: int, mult: float):
    s = pd.Series(c)
    mid = s.rolling(period).mean().to_numpy()
    std = s.rolling(period).std().to_numpy()
    return mid + mult * std, mid, mid - mult * std


def _donchian_channels(h: np.ndarray, l: np.ndarray, period: int):
    du = pd.Series(h).shift(1).rolling(period).max().to_numpy()
    dl = pd.Series(l).shift(1).rolling(period).min().to_numpy()
    return du, dl


def _xup(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    out = np.zeros(len(a), bool)
    out[1:] = (a[1:] > b[1:]) & (a[:-1] <= b[:-1])
    return out


def _rising(a: np.ndarray, k: int = 1) -> np.ndarray:
    out = np.zeros(len(a), bool)
    out[k:] = a[k:] > a[:-k]
    return out


def _pctrank(a: np.ndarray, win: int) -> np.ndarray:
    return pd.Series(a).rolling(win).rank(pct=True).to_numpy()


# ---------------------------------------------------------------------------
# Strategy Class
# ---------------------------------------------------------------------------
class Github_ayhanarashtasin_MultiLiveBot__LITLuxBook001_15M__20260920_073741(IStrategy):
    INTERFACE_VERSION = 3
    timeframe = "15m"
    atr_period = 14
    can_short = False
    stoploss = -0.99
    minimal_roi = {"0": 100.0}
    process_only_new_candles = True
    use_exit_signal = False
    startup_candle_count = 1000
    trailing_stop = False

    slots = 6
    leverage = 1

    # Pre-calculated barrier parameters:
    # (global_rule_id, slot, tp_pct, sl_pct, tp_mult, sl_mult, max_hold)
    # tp_pct/sl_pct: fraction of price (e.g. 0.012 for 1.2%) or None if ATR multiple
    # tp_mult/sl_mult: ATR multiplier or None if fixed percentage
    RULES_GEOMETRY = [
        # Rule 1: Slot 0 | TP atr2.0 | SL atr6.0 | Hold 32
        {"id": 1, "slot": 0, "tp_pct": None, "sl_pct": None, "tp_mult": 2.0, "sl_mult": 6.0, "hold": 32},
        # Rule 2: Slot 1 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 2, "slot": 1, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 3: Slot 2 | TP pct0.012 | SL atr4.0 | Hold 16
        {"id": 3, "slot": 2, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 16},
        # Rule 4: Slot 3 | TP pct0.015 | SL atr4.0 | Hold 16
        {"id": 4, "slot": 3, "tp_pct": 0.015, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 16},
        # Rule 5: Slot 4 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 5, "slot": 4, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 6: Slot 5 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 6, "slot": 5, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 7: Slot 1 | TP pct0.012 | SL atr4.0 | Hold 32
        {"id": 7, "slot": 1, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 32},
        # Rule 8: Slot 4 | TP atr2.0 | SL atr6.0 | Hold 32
        {"id": 8, "slot": 4, "tp_pct": None, "sl_pct": None, "tp_mult": 2.0, "sl_mult": 6.0, "hold": 32},
        # Rule 9: Slot 5 | TP pct0.012 | SL atr4.0 | Hold 32
        {"id": 9, "slot": 5, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 32},
        # Rule 10: Slot 0 | TP pct0.015 | SL atr5.0 | Hold 16
        {"id": 10, "slot": 0, "tp_pct": 0.015, "sl_pct": None, "tp_mult": None, "sl_mult": 5.0, "hold": 16},
        # Rule 11: Slot 3 | TP pct0.012 | SL atr3.0 | Hold 32
        {"id": 11, "slot": 3, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 3.0, "hold": 32},
        # Rule 12: Slot 2 | TP pct0.02 | SL pct0.05 | Hold 32
        {"id": 12, "slot": 2, "tp_pct": 0.020, "sl_pct": 0.050, "tp_mult": None, "sl_mult": None, "hold": 32},
        # Rule 13: Slot 5 | TP atr1.5 | SL atr5.0 | Hold 16
        {"id": 13, "slot": 5, "tp_pct": None, "sl_pct": None, "tp_mult": 1.5, "sl_mult": 5.0, "hold": 16},
        # Rule 14: Slot 0 | TP atr2.5 | SL atr6.0 | Hold 16
        {"id": 14, "slot": 0, "tp_pct": None, "sl_pct": None, "tp_mult": 2.5, "sl_mult": 6.0, "hold": 16},
        # Rule 15: Slot 1 | TP pct0.015 | SL atr5.0 | Hold 16
        {"id": 15, "slot": 1, "tp_pct": 0.015, "sl_pct": None, "tp_mult": None, "sl_mult": 5.0, "hold": 16},
        # Rule 16: Slot 0 | TP atr2.5 | SL atr6.0 | Hold 16
        {"id": 16, "slot": 0, "tp_pct": None, "sl_pct": None, "tp_mult": 2.5, "sl_mult": 6.0, "hold": 16},
        # Rule 17: Slot 3 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 17, "slot": 3, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 18: Slot 2 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 18, "slot": 2, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 19: Slot 5 | TP pct0.012 | SL atr4.0 | Hold 32
        {"id": 19, "slot": 5, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 32},
        # Rule 20: Slot 4 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 20, "slot": 4, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 21: Slot 1 | TP atr1.0 | SL atr2.5 | Hold 96
        {"id": 21, "slot": 1, "tp_pct": None, "sl_pct": None, "tp_mult": 1.0, "sl_mult": 2.5, "hold": 96},
        # Rule 22: Slot 3 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 22, "slot": 3, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 23: Slot 2 | TP atr1.0 | SL atr3.0 | Hold 8
        {"id": 23, "slot": 2, "tp_pct": None, "sl_pct": None, "tp_mult": 1.0, "sl_mult": 3.0, "hold": 8},
        # Rule 24: Slot 1 | TP pct0.015 | SL atr4.0 | Hold 32
        {"id": 24, "slot": 1, "tp_pct": 0.015, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 32},
        # Rule 25: Slot 5 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 25, "slot": 5, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 26: Slot 4 | TP pct0.08 | SL pct0.08 | Hold 16
        {"id": 26, "slot": 4, "tp_pct": 0.080, "sl_pct": 0.080, "tp_mult": None, "sl_mult": None, "hold": 16},
        # Rule 27: Slot 2 | TP pct0.025 | SL pct0.06 | Hold 16
        {"id": 27, "slot": 2, "tp_pct": 0.025, "sl_pct": 0.060, "tp_mult": None, "sl_mult": None, "hold": 16},
        # Rule 28: Slot 0 | TP pct0.012 | SL atr4.0 | Hold 96
        {"id": 28, "slot": 0, "tp_pct": 0.012, "sl_pct": None, "tp_mult": None, "sl_mult": 4.0, "hold": 96},
        # Rule 29: Slot 5 | TP pct0.008 | SL atr2.5 | Hold 96
        {"id": 29, "slot": 5, "tp_pct": 0.008, "sl_pct": None, "tp_mult": None, "sl_mult": 2.5, "hold": 96},
        # Rule 30: Slot 4 | TP pct0.02 | SL pct0.06 | Hold 96
        {"id": 30, "slot": 4, "tp_pct": 0.020, "sl_pct": 0.060, "tp_mult": None, "sl_mult": None, "hold": 96},
    ]

    def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        n = len(dataframe)
        o = dataframe["open"].to_numpy(dtype=np.float64)
        h = dataframe["high"].to_numpy(dtype=np.float64)
        l = dataframe["low"].to_numpy(dtype=np.float64)
        c = dataframe["close"].to_numpy(dtype=np.float64)
        v = dataframe["volume"].to_numpy(dtype=np.float64)
        ot = dataframe["open_time"].to_numpy()

        p = {}

        # 1. Supertrend family
        line7_1, d7_1, atr7_1 = _supertrend(h, l, c, 7, 1.0)
        p["ST[7,1.0]bear"] = d7_1 == -1
        fd7_1 = np.zeros(n, bool); fd7_1[1:] = (d7_1[1:] == -1) & (d7_1[:-1] == 1)
        p["ST[7,1.0]flipDn"] = fd7_1

        line7_15, d7_15, atr7_15 = _supertrend(h, l, c, 7, 1.5)
        fd7_15 = np.zeros(n, bool); fd7_15[1:] = (d7_15[1:] == -1) & (d7_15[:-1] == 1)
        p["ST[7,1.5]flipDn"] = fd7_15
        with np.errstate(invalid="ignore"):
            dist7_15 = (c - line7_15) / np.maximum(atr7_15, 1e-12)
        p["ST[7,1.5]nearBand"] = (d7_15 == 1) & (dist7_15 < 0.5)

        _, d10_1, _ = _supertrend(h, l, c, 10, 1.0)
        fd10_1 = np.zeros(n, bool); fd10_1[1:] = (d10_1[1:] == -1) & (d10_1[:-1] == 1)
        p["ST[10,1.0]flipDn"] = fd10_1

        line10_15, d10_15, atr10_15 = _supertrend(h, l, c, 10, 1.5)
        with np.errstate(invalid="ignore"):
            dist10_15 = (c - line10_15) / np.maximum(atr10_15, 1e-12)
        p["ST[10,1.5]nearBand"] = (d10_15 == 1) & (dist10_15 < 0.5)

        _, d14_4, _ = _supertrend(h, l, c, 14, 4.0)
        p["ST[14,4.0]bull"] = d14_4 == 1

        _, d30_15, _ = _supertrend(h, l, c, 30, 1.5)
        fd30_15 = np.zeros(n, bool); fd30_15[1:] = (d30_15[1:] == -1) & (d30_15[:-1] == 1)
        p["ST[30,1.5]flipDn"] = fd30_15

        _, hist14_2 = _supertrend_oscillator(h, l, c, 14, 2.0)
        p["SThist[14,2.0]>0"] = hist14_2 > 0

        _, ed20_3, _ = _evasive_supertrend(h, l, c, 20, 3.0)
        p["EvaST[20,3.0]bull"] = ed20_3 == 1

        # 2. Oscillators
        p["RSI2<20"] = _rsi(c, 2) < 20
        p["RSI2<40"] = _rsi(c, 2) < 40
        p["RSI3<20"] = _rsi(c, 3) < 20
        rsi7 = _rsi(c, 7)
        p["RSI7>25"] = rsi7 > 25
        p["RSI7>50"] = rsi7 > 50
        p["RSI7rise3"] = _rising(rsi7, 3)
        rsi9 = _rsi(c, 9)
        p["RSI9<30"] = rsi9 < 30
        p["RSI9<35"] = rsi9 < 35
        p["RSI9>45"] = rsi9 > 45
        p["RSI9<65"] = rsi9 < 65
        p["RSI14>50"] = _rsi(c, 14) > 50
        p["RSI21>50"] = _rsi(c, 21) > 50

        kk14, _ = _stoch_rsi(c, 14, 14, 3, 3)
        p["StochRSI[14]k<20"] = kk14 < 20

        wt14_1, wt14_2 = _wavetrend(h, l, c, 14, 28)
        p["WT[14,28]x"] = _xup(wt14_1, wt14_2)

        adx14, _, _ = _adx(h, l, c, 14)
        p["ADX14<25"] = adx14 < 25
        adx21, dip21, dim21 = _adx(h, l, c, 21)
        p["ADX21<30"] = adx21 < 30
        p["DI21x"] = _xup(dip21, dim21)

        tsi25, tsig25 = _tsi(c, 25, 13, 7)
        p["TSI[25,13]x"] = _xup(tsi25, tsig25)

        cmo20 = _cmo(c, 20)
        p["CMO20<-40"] = cmo20 < -40
        p["CMO20<-20"] = cmo20 < -20

        p["CCI14<-150"] = _cci(h, l, c, 14) < -150
        p["CCI20xup-100"] = _xup(_cci(h, l, c, 20), np.full(n, -100.0))

        p["WR9<-90"] = _williams_r(h, l, c, 9) < -90
        p["WR9<-80"] = _williams_r(h, l, c, 9) < -80
        p["WR14<-90"] = _williams_r(h, l, c, 14) < -90
        p["WR14>-80"] = _williams_r(h, l, c, 14) > -80

        # 3. Channels & Structure
        up50, mid50, lo50 = _bollinger_bands(c, 50, 2.0)
        w50 = (up50 - lo50) / np.maximum(mid50, 1e-12)
        p["BB[50,2.0]wide"] = _pctrank(w50, 200) > 0.75

        up10, mid10, lo10 = _bollinger_bands(c, 10, 1.5)
        w10 = (up10 - lo10) / np.maximum(mid10, 1e-12)
        p["BB[10,1.5]squeeze"] = _pctrank(w10, 200) < 0.25
        pctb10 = (c - lo10) / np.maximum(up10 - lo10, 1e-12)
        p["BB[10,1.5]pb<0.3"] = pctb10 < 0.3

        du20, dl20 = _donchian_channels(h, l, 20)
        pos20 = (c - dl20) / np.maximum(du20 - dl20, 1e-12)
        p["DC20pos<0.15"] = pos20 < 0.15

        # 4. Moving Averages
        e21 = _ema(c, 21)
        p["dEMA21<2"] = (c - e21) / np.maximum(e21, 1e-12) * 100 < 2
        e100 = _ema(c, 100)
        p["dEMA100>-2"] = (c - e100) / np.maximum(e100, 1e-12) * 100 > -2
        p["dEMA100<2"] = (c - e100) / np.maximum(e100, 1e-12) * 100 < 2
        e200 = _ema(c, 200)
        p["dEMA200>-4"] = (c - e200) / np.maximum(e200, 1e-12) * 100 > -4
        e400 = _ema(c, 400)
        p["EMA400rise5"] = _rising(e400, 5)
        p["dEMA400>-4"] = (c - e400) / np.maximum(e400, 1e-12) * 100 > -4

        # 5. Momentum & Returns
        mom4 = np.zeros(n); mom4[4:] = c[4:] / np.maximum(c[:-4], 1e-12) - 1
        p["mom1h>-0.015"] = mom4 > -0.015
        mom16 = np.zeros(n); mom16[16:] = c[16:] / np.maximum(c[:-16], 1e-12) - 1
        p["mom4h<-0.03"] = mom16 < -0.03

        r1 = np.zeros(n); r1[1:] = c[1:] / np.maximum(c[:-1], 1e-12) - 1
        p["ret1<-0.01"] = r1 < -0.01
        r5 = np.zeros(n); r5[5:] = c[5:] / np.maximum(c[:-5], 1e-12) - 1
        p["ret5<-0.02"] = r5 < -0.02
        p["ret5<0.02"] = r5 < 0.02
        r8 = np.zeros(n); r8[8:] = c[8:] / np.maximum(c[:-8], 1e-12) - 1
        p["ret8<-0.03"] = r8 < -0.03

        # 6. Order Flow Features (using DataFrame fields with fallback to deltaProxy)
        if "bnot" in dataframe and "snot" in dataframe and dataframe["bnot"].sum() > 0:
            bnot = dataframe["bnot"].to_numpy(float)
            snot = dataframe["snot"].to_numpy(float)
            bbig = dataframe["bbig"].to_numpy(float) if "bbig" in dataframe else np.zeros(n)
            sbig = dataframe["sbig"].to_numpy(float) if "sbig" in dataframe else np.zeros(n)
            lbnot = dataframe["lbnot"].to_numpy(float) if "lbnot" in dataframe else np.zeros(n)
            lsnot = dataframe["lsnot"].to_numpy(float) if "lsnot" in dataframe else np.zeros(n)
            tot = np.maximum(bnot + snot, 1e-9)
            ofi = (bnot - snot) / tot
            bigtot = np.maximum(bbig + sbig, 1e-9)
            bofi = (bbig - sbig) / bigtot
            ltot = np.maximum(lbnot + lsnot, 1e-9)
            lofi = (lbnot - lsnot) / ltot
        else:
            bar_rng = h - l
            d_proxy = np.where(bar_rng > 0, (c - o) / bar_rng * v, 0.0)
            tot = np.maximum(v, 1e-9)
            ofi = d_proxy / tot
            bofi = pd.Series(d_proxy).rolling(5).sum().to_numpy() / np.maximum(pd.Series(v).rolling(5).sum().to_numpy(), 1e-9)
            lofi = pd.Series(d_proxy).rolling(3).sum().to_numpy() / np.maximum(pd.Series(v).rolling(3).sum().to_numpy(), 1e-9)

        p["OFI<0.05"] = ofi < 0.05
        p["bigOFI<-0.2"] = bofi < -0.2
        p["bigOFI>-0.1"] = bofi > -0.1
        p["bigOFI<-0.05"] = bofi < -0.05
        p["bigOFI>0.05"] = bofi > 0.05
        bofima12 = pd.Series(bofi).rolling(12).mean().to_numpy()
        p["bigOFIma12<0"] = bofima12 < 0
        p["lateOFI<0.1"] = lofi < 0.1

        # 7. Volume Z-Score & Session
        vz = (v - pd.Series(v).rolling(96).mean().to_numpy()) / np.maximum(pd.Series(v).rolling(96).std().to_numpy(), 1e-9)
        p["volZ<-0.5"] = vz < -0.5
        p["volZ<0.5"] = vz < 0.5
        p["volZ>1.0"] = vz > 1.0

        hour = pd.to_datetime(ot, unit="ms", utc=True).hour.to_numpy()
        p["sess_eu"] = (hour >= 7) & (hour < 13)

        # 8. Evaluate 30 rules
        rules = [
            # Rule 1 [Slot 0]
            p["CMO20<-40"] & p["WR14<-90"],
            # Rule 2 [Slot 1]
            p["ST[30,1.5]flipDn"] & p["RSI21>50"] & p["BB[50,2.0]wide"],
            # Rule 3 [Slot 2]
            p["ST[7,1.5]flipDn"] & p["sess_eu"],
            # Rule 4 [Slot 3]
            p["ST[30,1.5]flipDn"] & p["sess_eu"],
            # Rule 5 [Slot 4]
            p["ST[30,1.5]flipDn"] & p["RSI21>50"] & p["BB[50,2.0]wide"] & p["ret5<0.02"],
            # Rule 6 [Slot 5]
            p["RSI2<40"] & p["RSI7rise3"] & p["mom4h<-0.03"],
            # Rule 7 [Slot 1]
            p["CCI20xup-100"] & p["mom4h<-0.03"],
            # Rule 8 [Slot 4]
            p["CMO20<-40"] & p["WR9<-90"],
            # Rule 9 [Slot 5]
            p["ST[7,1.0]flipDn"] & p["WR14>-80"] & p["volZ>1.0"],
            # Rule 10 [Slot 0]
            p["ST[30,1.5]flipDn"] & p["ADX21<30"] & p["sess_eu"],
            # Rule 11 [Slot 3]
            p["RSI7rise3"] & p["DC20pos<0.15"] & p["EMA400rise5"],
            # Rule 12 [Slot 2]
            p["CMO20<-40"] & p["WR14<-90"] & p["volZ<0.5"],
            # Rule 13 [Slot 5]
            p["ST[10,1.0]flipDn"] & p["ADX14<25"] & p["dEMA200>-4"] & p["sess_eu"],
            # Rule 14 [Slot 0]
            p["ST[7,1.5]flipDn"] & p["dEMA100<2"] & p["sess_eu"],
            # Rule 15 [Slot 1]
            p["ST[30,1.5]flipDn"] & p["RSI7>25"] & p["ADX21<30"] & p["sess_eu"],
            # Rule 16 [Slot 0]
            p["ST[7,1.0]bear"] & p["RSI2<20"] & p["RSI7>50"] & p["dEMA100>-2"],
            # Rule 17 [Slot 3]
            p["WR9<-90"] & p["BB[50,2.0]wide"] & p["bigOFI>0.05"],
            # Rule 18 [Slot 2]
            p["ST[7,1.5]nearBand"] & p["BB[50,2.0]wide"] & p["dEMA400>-4"] & p["bigOFI<-0.05"],
            # Rule 19 [Slot 5]
            p["RSI3<20"] & p["RSI14>50"],
            # Rule 20 [Slot 4]
            p["ST[30,1.5]flipDn"] & p["ret5<-0.02"],
            # Rule 21 [Slot 1]
            p["EvaST[20,3.0]bull"] & p["RSI9<35"] & p["BB[10,1.5]squeeze"],
            # Rule 22 [Slot 3]
            p["ST[30,1.5]flipDn"] & p["StochRSI[14]k<20"] & p["WR9<-80"] & p["OFI<0.05"],
            # Rule 23 [Slot 2]
            p["CCI14<-150"] & p["ret8<-0.03"] & p["bigOFI<-0.2"],
            # Rule 24 [Slot 1]
            p["ST[7,1.0]flipDn"] & p["WR14>-80"] & p["dEMA21<2"] & p["volZ>1.0"],
            # Rule 25 [Slot 5]
            p["ST[14,4.0]bull"] & p["RSI9>45"] & p["CMO20<-20"],
            # Rule 26 [Slot 4]
            p["DI21x"] & p["mom1h>-0.015"] & p["bigOFI>-0.1"] & p["volZ<-0.5"],
            # Rule 27 [Slot 2]
            p["RSI9<65"] & p["WT[14,28]x"] & p["DI21x"] & p["dEMA200>-4"],
            # Rule 28 [Slot 0]
            p["ST[10,1.5]nearBand"] & p["BB[50,2.0]wide"] & p["BB[10,1.5]pb<0.3"],
            # Rule 29 [Slot 5]
            p["RSI9<30"] & p["ret1<-0.01"] & p["bigOFIma12<0"],
            # Rule 30 [Slot 4]
            p["SThist[14,2.0]>0"] & p["WT[14,28]x"] & p["TSI[25,13]x"] & p["lateOFI<0.1"],
        ]

        atr14 = _atr(h, l, c, 14)
        dataframe["atr"] = atr14

        # 9. Assign winning rule per slot
        for s in range(self.slots):
            slot_rules_idx = [i for i, geom in enumerate(self.RULES_GEOMETRY) if geom["slot"] == s]
            s_rule = np.full(n, -1, dtype=np.int16)
            s_tp_pct = np.full(n, np.nan, dtype=np.float64)
            s_sl_pct = np.full(n, np.nan, dtype=np.float64)
            s_tp_mult = np.full(n, np.nan, dtype=np.float64)
            s_sl_mult = np.full(n, np.nan, dtype=np.float64)
            s_hold = np.zeros(n, dtype=np.int16)

            # Earliest rule in slot wins
            for rid, global_i in enumerate(slot_rules_idx):
                rule_mask = rules[global_i]
                geom = self.RULES_GEOMETRY[global_i]
                new_bars = rule_mask & (s_rule < 0)
                if new_bars.any():
                    s_rule[new_bars] = rid
                    if geom["tp_pct"] is not None:
                        s_tp_pct[new_bars] = geom["tp_pct"]
                    if geom["sl_pct"] is not None:
                        s_sl_pct[new_bars] = geom["sl_pct"]
                    if geom["tp_mult"] is not None:
                        s_tp_mult[new_bars] = geom["tp_mult"]
                    if geom["sl_mult"] is not None:
                        s_sl_mult[new_bars] = geom["sl_mult"]
                    s_hold[new_bars] = geom["hold"]

            dataframe[f"slot_{s}_rule"] = s_rule
            dataframe[f"slot_{s}_tp_pct"] = s_tp_pct
            dataframe[f"slot_{s}_sl_pct"] = s_sl_pct
            dataframe[f"slot_{s}_tp_mult"] = s_tp_mult
            dataframe[f"slot_{s}_sl_mult"] = s_sl_mult
            dataframe[f"slot_{s}_hold"] = s_hold

        return dataframe

    def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        armed = dataframe["atr"].notna() & (dataframe["atr"] > 0)
        any_slot = False
        for s in range(self.slots):
            any_slot = any_slot | (dataframe[f"slot_{s}_rule"] >= 0)
        dataframe["enter_long"] = (any_slot & armed).astype(np.int8)
        dataframe["enter_short"] = 0
        return dataframe

    def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        dataframe["exit_long"] = 0
        dataframe["exit_short"] = 0
        dataframe["exit_reason"] = ""
        dataframe["exit_price"] = np.nan
        return dataframe
