import numpy as np
import pandas as pd
import os
import sys

sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config import get_min_trades
from strategies.library_activation_scores import (
    calculate_activation_score_poc, calculate_positions,
    calculate_bullish_engulfing, calculate_bullish_hammer, calculate_shooting_star,
)

try:
    from numba import cuda
    HAS_GPU = cuda.is_available()
except Exception:
    HAS_GPU = False

# Minimum trade count required for a result to be considered valid.
# Results below this threshold receive a penalty score of -10.0 so the
# optimizer ignores them.  Prevents degenerate "do almost nothing" solutions
# that achieve high Sortino/Sharpe by having near-zero downside deviation.
# Tune this to match the desired strategy activity level.
MIN_TRADES = 30

# Minimum trade count when scoring only the recent window (SCORE_START onwards).
# Raised from 10 → 30 to match MIN_TRADES; prevents Sortino gaming via cherry-picked trades.
MIN_SCORABLE_TRADES = 30

# Round-trip commission rate (per side). Matches TradingView's default 0.5% commission.
# Applied in calculate_metrics on entry and exit execution bars.
# NOTE: The GPU kernel does NOT apply commission — GPU results are pre-filters only.
# The CPU verification path (calculate_metrics) always applies commission, so the
# final optimized scores and validate_strategy.py results include this cost.
COMMISSION_RATE = 0.005


def _apply_trailing_stop(entry_raw_arr, exit_raw_arr, close_arr, high_arr, trail_stop_pct,
                         time_ok_arr=None):
    """
    Simulates positions bar-by-bar with a percentage-based trailing stop.
    Replaces calculate_positions when i_trailing_stop_threshold > 0.

    Matches Pine Script behaviour with fill_orders_on_standard_ohlc=true:
      - Entry signal fires at bar-close T; order fills at open[T+1].
      - Pine sets trail_high = position_avg_price = open[T+1] on the fill bar.
        It does NOT update with high[T+1] on that first bar.
      - From bar T+2 onward: trail_high = max(trail_high, high).
      - Stop fires when close <= trail_high * (1 - trail_stop_pct).
      - Python records signal on bar T; TV records fill on bar T+1 (1-day offset).

    The fill_bar skip: when entry fires on bar T, bar T+1 is the fill bar.
    We initialise trade_high = close[T] ≈ open[T+1] = position_avg_price, and
    skip the trail_high update on T+1 to match Pine's initialisation logic.

    time_ok_arr (optional bool array): Pine gates BOTH strategy.close calls
    (score-exit and trailing-stop) on `timeCondition = time>=startDate and
    time<=endDate`. When supplied, entries and exits (trail OR score) may only
    fire on bars where time_ok_arr[t] is True — so a position open when the
    window ends is held open to the data end, exactly like Pine (it cannot
    close after endDate). The trail high-water mark keeps updating (Pine does
    not gate that), only the close is gated. Default None = no gating (legacy
    behaviour; the activation strategy and optimizer paths pass nothing).

    Returns (in_pos, exec_entry, exec_exit).
    """
    n = len(close_arr)
    in_pos     = np.zeros(n, dtype=np.int32)
    exec_entry = np.zeros(n, dtype=bool)
    exec_exit  = np.zeros(n, dtype=bool)

    position   = False
    trade_high = 0.0
    fill_bar   = -1  # bar index where order fills; trail_high update skipped

    for t in range(n):
        allowed = True if time_ok_arr is None else bool(time_ok_arr[t])
        if position:
            # Skip trail_high update on the fill bar (first bar in position).
            # Matches Pine: trail_high = position_avg_price on that bar, not max(trail_high, high).
            if t != fill_bar and high_arr[t] > trade_high:
                trade_high = high_arr[t]
            stop_price = trade_high * (1.0 - trail_stop_pct)
            trail_hit  = close_arr[t] <= stop_price

            # Gate the close on timeCondition (Pine gates strategy.close) — after
            # endDate the position is held open, never trailed/scored out.
            if (trail_hit or exit_raw_arr[t]) and allowed:
                position  = False
                exec_exit[t] = True
                trade_high = 0.0
                fill_bar   = -1
        else:
            if entry_raw_arr[t] and allowed:
                position   = True
                exec_entry[t] = True
                trade_high = close_arr[t]  # ≈ open[T+1] = position_avg_price
                fill_bar   = t + 1         # next bar is the fill bar; skip its high
        in_pos[t] = 1 if position else 0

    return in_pos, exec_entry, exec_exit


def generate_signals(df, **params):
    """
    Generates entry/exit signals using the activation score framework.

    Reads pre-calculated component values from the TradingView export (TV_Export.csv),
    applies proper normalization via calculate_activation_score_poc (matching the
    Pine Script library function), then implements the same crossunder entry/exit
    logic as the Pine Script strategy.

    Parity notes:
    - Prefers non-DB_ columns (which match the inputs used to compute activation_score_poc).
    - Falls back to DB_ columns if non-DB columns are absent.
    - Entry:  score crosses FROM ABOVE to BELOW i_long_entry_activation_threshold
              (Pine: ta.crossunder(activation_score_poc, entry_threshold))
    - Exit:   score crossed under i_long_exit_activation_threshold one bar ago
              AND current score < i_long_exit_activation_confirmation_threshold
              (Pine: ta.crossunder(activation_score_poc[1], exit_threshold)
                     AND activation_score_poc < exit_conf_threshold)
    """
    df = df.copy()
    df.columns = df.columns.str.lower().str.strip()

    # --- Divergence window: extend RSID divergence flags for N bars ---
    # When i_div_window > 1, a divergence detected within the last N bars stays
    # "active" so the setup window remains open past the single bar it fired on.
    # RSID signals are binary {0, 1} — rolling max extends the 1 forward without
    # changing sign semantics (the negative/positive contribution comes from the weight).
    # Note: _prepare_features() (GPU path) uses raw signals as a window=1 approximation;
    # CPU verification via generate_signals() uses the correct per-sample window.
    _div_window = int(params.get('i_div_window', 1))
    if _div_window > 1:
        _rsid_cols = [
            'rsid_reg_bull_norm', 'rsid_reg_bear_norm',
            'rsid_hid_bull_norm', 'rsid_hid_bear_norm',
            'rsid_rt_bull_norm',  'rsid_rt_bear_norm',
            'rsid_slow_bull_norm', 'rsid_slow_bear_norm',
            'rsid_delayed_peak_norm', 'rsid_delayed_dip_norm',
        ]
        for _col in _rsid_cols:
            if _col in df.columns:
                df[_col] = df[_col].rolling(_div_window, min_periods=1).max()

    def get_col(primary, fallback=None):
        """Return column values, preferring primary over fallback, defaulting to zeros."""
        if primary in df.columns:
            return df[primary].fillna(0).values
        if fallback and fallback in df.columns:
            return df[fallback].fillna(0).values
        return np.zeros(len(df))

    # --- Component arrays (pre-normalised by Pine; read directly from CSV) ---
    # All columns below are in [-1, +1] — no further normalisation needed here.
    # Pine exports the normalised form via plotchar; column names end in _norm.
    # If a column is absent (old CSV before re-export) get_col returns 0 → signal off.
    stoch_n      = get_col('stoch_norm')        # (stoch - 50) / 50
    macd_pred_n  = get_col('macd_pred_norm')    # sign of macd_prediction
    osc_n        = get_col('osc_norm')          # (rsi_1D - 50) / 50
    totalvol_n   = get_col('totalvol_norm')      # on-chain transfer vol percentrank [-1,+1]
    m3_mom_n     = get_col('m3_momentum_norm')  # sign of m3_momentum
    m2_tiny_n    = get_col('m2_tiny_norm')      # epsilon-gated sign of m2_diff_abs
    newaddr_n    = get_col('newaddr_norm')       # new addresses percentrank [-1,+1]
    stoch_div_n  = get_col('stoch_div_norm')    # 3-way sign of stoch_div_osc
    vwap_div_n   = get_col('vwap_div_norm')     # clip(vwap_div_osc, -1, 1)
    stoch_peak_n = get_col('stoch_peak_norm')   # -1 if peaking, else 0
    sendaddr_n   = get_col('sendaddr_norm')      # sending addresses percentrank [-1,+1]
    m3_div_n     = get_col('m3_div_norm')       # 3-way sign of m3_div_osc
    m2_div_n     = get_col('m2_div_norm')       # 3-way sign of m2_div_osc_tiny
    m2_nooff_n   = get_col('m2_nooff_norm')     # 3-way sign of m2_div_osc_noOffset
    bearish_n    = get_col('bearish_engulfing_score', 'db_bearish')  # pre-scored by Pine library
    bull_ham_n   = get_col('bullish_hammer_score')
    bull_eng_n   = get_col('bullish_engulfing_score')
    star_n       = get_col('shooting_star_score')

    close_arr = df['close'].values.astype(np.float64)
    high_arr  = df['high'].values.astype(np.float64)

    # --- Weight parameters (matching Pine Script i_w_* input names) ---
    w_stoch               = params.get('i_w_stoch',               6.52)
    w_macd_pred           = params.get('i_w_macd_pred',           69.0)
    w_osc                 = params.get('i_w_osc',                 -5.6)
    w_totalvol            = params.get('i_w_totalvol',            0.0)
    w_m3_momentum         = params.get('i_w_m3_momentum',         0.6912)
    w_m2_tiny             = params.get('i_w_m2_tiny',             68.0)
    w_newaddr             = params.get('i_w_newaddr',             0.0)
    w_stoch_div_osc       = params.get('i_w_stoch_div_osc',       -4.73)
    w_vwap_div_osc        = params.get('i_w_vwap_div_osc',        23.0)
    w_stoch_peaking       = params.get('i_w_stoch_peaking',       -7.26)
    w_sendaddr            = params.get('i_w_sendaddr',            0.0)
    w_m3_div_osc          = params.get('i_w_m3_div_osc',          -3.148)
    w_m2_div_osc          = params.get('i_w_m2_div_osc',          54.0)
    w_m2_div_osc_nooffset = params.get('i_w_m2_div_osc_noOffset', 1.0)
    w_bearish_engulfing   = params.get('i_w_bearish_engulfing',   39.0)
    w_bullish_hammer      = params.get('i_w_bullish_hammer',       0.0)
    w_bullish_engulfing   = params.get('i_w_bullish_engulfing',    0.0)
    w_shooting_star       = params.get('i_w_shooting_star',        0.0)
    w_btc_spx_corr        = params.get('i_w_btc_spx_corr',         0.0)
    w_dxy                 = params.get('i_w_dxy',                  0.0)
    w_vix                 = params.get('i_w_vix',                  0.0)
    w_btc_dom             = params.get('i_w_btc_dom',              0.0)
    w_us10y               = params.get('i_w_us10y',                0.0)
    w_spy                 = params.get('i_w_spy',                  0.0)
    w_gold                = params.get('i_w_gold',                 0.0)
    w_mvrv                = params.get('i_w_mvrv',                 0.0)
    w_mvrv_cont           = params.get('i_w_mvrv_cont',            0.0)
    w_nupl                = params.get('i_w_nupl',                 0.0)
    w_fed_net_liq         = params.get('i_w_fed_net_liq',          0.0)
    w_gc_position         = params.get('i_w_gc_position',          0.0)
    w_us2y                = params.get('i_w_us2y',                 0.0)
    w_yield_curve         = params.get('i_w_yield_curve',          0.0)
    w_qqq_spy_ratio       = params.get('i_w_qqq_spy_ratio',        0.0)
    w_rsid_reg_bull       = params.get('i_w_rsid_reg_bull',        0.0)
    w_rsid_reg_bear       = params.get('i_w_rsid_reg_bear',        0.0)
    w_rsid_hid_bull       = params.get('i_w_rsid_hid_bull',        0.0)
    w_rsid_hid_bear       = params.get('i_w_rsid_hid_bear',        0.0)
    w_rsid_rt_bull        = params.get('i_w_rsid_rt_bull',         0.0)
    w_rsid_rt_bear        = params.get('i_w_rsid_rt_bear',         0.0)
    w_rsid_slow_bull      = params.get('i_w_rsid_slow_bull',       0.0)
    w_rsid_slow_bear      = params.get('i_w_rsid_slow_bear',       0.0)
    w_rsid_delayed_peak   = params.get('i_w_rsid_delayed_peak',    0.0)
    w_rsid_delayed_dip    = params.get('i_w_rsid_delayed_dip',     0.0)
    w_oi_roc              = params.get('i_w_oi_roc',               0.0)
    w_usdt_d              = params.get('i_w_usdt_d',               0.0)
    w_basis               = params.get('i_w_basis',                0.0)
    w_fear_greed          = params.get('i_w_fear_greed',           0.0)
    w_btc_gold            = params.get('i_w_btc_gold',             0.0)
    w_rsi_subtf           = params.get('i_w_rsi_subtf',            0.0)

    # --- Macro / on-chain signal columns (pre-normalised by Pine, read directly from CSV) ---
    btc_spx_corr     = get_col('btc_spx_corr_30')
    dxy_roc_norm     = get_col('dxy_roc_norm')
    vix_pr_inv       = get_col('vix_pctrank_inv')
    btc_dom_sign     = get_col('btc_dom_roc_sign')
    us10y_inv_sign   = get_col('us10y_roc_inv_sign')
    spy_200ema       = get_col('spy_above_200ema')
    gold_pctrank     = get_col('gold_roc_pctrank')
    mvrv_zscore_val  = get_col('mvrv_zscore_value')   # {-1,-0.5,-0.25,0.25,0.5,1} discrete
    mvrv_zscore_cont = get_col('mvrv_zscore_cont')    # continuous [-1,+1]; exported by Pine
    nupl_norm        = get_col('nupl_norm')
    fed_net_liq_sign = get_col('fed_net_liq_sign')
    gc_position      = get_col('gc_position')
    us2y_inv_sign    = get_col('us2y_roc_inv_sign')
    yield_curve_n    = get_col('yield_curve_sign')
    qqq_spy_n        = get_col('qqq_spy_roc_sign')
    rsid_reg_bull_n  = get_col('rsid_reg_bull_norm')   # 1.0 when regular bullish divergence
    rsid_reg_bear_n  = get_col('rsid_reg_bear_norm')   # 1.0 when regular bearish divergence
    rsid_hid_bull_n  = get_col('rsid_hid_bull_norm')   # 1.0 when hidden bullish divergence
    rsid_hid_bear_n  = get_col('rsid_hid_bear_norm')   # 1.0 when hidden bearish divergence
    rsid_rt_bull_n   = get_col('rsid_rt_bull_norm')    # 1.0 when real-time bullish divergence
    rsid_rt_bear_n   = get_col('rsid_rt_bear_norm')    # 1.0 when real-time bearish divergence
    rsid_slow_bull_n = get_col('rsid_slow_bull_norm')  # 1.0 when slowing bullish momentum (oversold)
    rsid_slow_bear_n = get_col('rsid_slow_bear_norm')  # 1.0 when slowing bearish momentum (overbought)
    rsid_dpeak_n     = get_col('rsid_delayed_peak_norm') # 1.0 when delayed RSI peak detected
    rsid_ddip_n      = get_col('rsid_delayed_dip_norm')  # 1.0 when delayed RSI dip detected (fixed)
    # Note: RSID columns were already extended by the in-place rolling above (lines ~120-131).
    # No second _extend() call needed here.

    oi_roc_n         = get_col('oi_roc_norm')    # perp volume ROC (OI proxy), percentrank-normalised [-1, +1]
    usdt_d_n         = get_col('usdt_d_norm')    # USDT dominance ROC, negated (falling = bullish)
    basis_n          = get_col('basis_norm')     # spot-perp basis %, clipped ±0.5%
    fear_greed_n     = get_col('fear_greed_norm')         # Crypto F&G → [-1,+1]; disabled in Pine (symbol TBD); returns 0 until re-export
    btc_gold_n         = get_col('btc_gold_norm')         # BTC/Gold ratio pctrank-normalised; high = overbought vs gold → bearish
    rsi_subtf_n      = get_col('rsi_subtf_norm')           # Dynamic sub-TF RSI (half chart period) normalised → [-1,+1]

    # --- Activation score: weighted sum of pre-normalised components ---
    # All signals are already in [-1, +1]; just multiply by weight and sum.
    # max_score = sum(|w_i|) normalises to fixed [-1000, +1000] scale so
    # thresholds have stable meaning regardless of weight magnitudes.
    scores = (
        stoch_n      * w_stoch              +
        macd_pred_n  * w_macd_pred          +
        osc_n        * w_osc                +
        totalvol_n   * w_totalvol           +
        m3_mom_n     * w_m3_momentum        +
        m2_tiny_n    * w_m2_tiny            +
        newaddr_n    * w_newaddr            +
        stoch_div_n  * w_stoch_div_osc      +
        vwap_div_n   * w_vwap_div_osc       +
        stoch_peak_n * w_stoch_peaking      +
        sendaddr_n   * w_sendaddr           +
        m3_div_n     * w_m3_div_osc         +
        bearish_n    * w_bearish_engulfing   +
        m2_nooff_n   * w_m2_div_osc_nooffset +
        m2_div_n     * w_m2_div_osc         +
        bull_ham_n   * w_bullish_hammer     +
        bull_eng_n   * w_bullish_engulfing  +
        star_n       * w_shooting_star      +
        btc_spx_corr * w_btc_spx_corr      +
        dxy_roc_norm * w_dxy               +
        vix_pr_inv   * w_vix               +
        btc_dom_sign * w_btc_dom           +
        us10y_inv_sign * w_us10y           +
        spy_200ema   * w_spy               +
        gold_pctrank * w_gold              +
        mvrv_zscore_val  * w_mvrv          +
        mvrv_zscore_cont * w_mvrv_cont     +
        nupl_norm    * w_nupl              +
        fed_net_liq_sign * w_fed_net_liq   +
        gc_position  * w_gc_position       +
        us2y_inv_sign * w_us2y            +
        yield_curve_n * w_yield_curve     +
        qqq_spy_n    * w_qqq_spy_ratio    +
        rsid_reg_bull_n  * w_rsid_reg_bull  +
        rsid_reg_bear_n  * w_rsid_reg_bear  +
        rsid_hid_bull_n  * w_rsid_hid_bull  +
        rsid_hid_bear_n  * w_rsid_hid_bear  +
        rsid_rt_bull_n   * w_rsid_rt_bull   +
        rsid_rt_bear_n   * w_rsid_rt_bear   +
        rsid_slow_bull_n * w_rsid_slow_bull +
        rsid_slow_bear_n * w_rsid_slow_bear +
        rsid_dpeak_n     * w_rsid_delayed_peak +
        rsid_ddip_n      * w_rsid_delayed_dip  +
        oi_roc_n         * w_oi_roc            +
        usdt_d_n         * w_usdt_d            +
        basis_n          * w_basis             +
        fear_greed_n     * w_fear_greed        +
        btc_gold_n       * w_btc_gold          +
        rsi_subtf_n      * w_rsi_subtf
    )
    max_score = (
        abs(w_stoch) + abs(w_macd_pred) + abs(w_osc) + abs(w_totalvol) +
        abs(w_m3_momentum) + abs(w_m2_tiny) + abs(w_newaddr) + abs(w_stoch_div_osc) +
        abs(w_vwap_div_osc) + abs(w_stoch_peaking) + abs(w_sendaddr) + abs(w_m3_div_osc) +
        abs(w_bearish_engulfing) + abs(w_m2_div_osc_nooffset) + abs(w_m2_div_osc) +
        abs(w_bullish_hammer) + abs(w_bullish_engulfing) + abs(w_shooting_star) +
        abs(w_btc_spx_corr) + abs(w_dxy) + abs(w_vix) + abs(w_btc_dom) +
        abs(w_us10y) + abs(w_spy) + abs(w_gold) +
        abs(w_mvrv) + abs(w_mvrv_cont) + abs(w_nupl) +
        abs(w_fed_net_liq) + abs(w_gc_position) +
        abs(w_us2y) + abs(w_yield_curve) + abs(w_qqq_spy_ratio) +
        abs(w_rsid_reg_bull) + abs(w_rsid_reg_bear) +
        abs(w_rsid_hid_bull) + abs(w_rsid_hid_bear) +
        abs(w_rsid_rt_bull)  + abs(w_rsid_rt_bear)  +
        abs(w_rsid_slow_bull) + abs(w_rsid_slow_bear) +
        abs(w_rsid_delayed_peak) + abs(w_rsid_delayed_dip) +
        abs(w_oi_roc) + abs(w_usdt_d) + abs(w_basis) +
        abs(w_fear_greed) + abs(w_btc_gold) + abs(w_rsi_subtf)
    )
    if max_score > 0:
        scores = scores / max_score * 1000.0
    df['activation_score'] = scores

    # --- Threshold parameters ---
    entry_threshold     = params.get('i_long_entry_activation_threshold',              106.0)
    exit_threshold      = params.get('i_long_exit_activation_threshold',               140.5)
    exit_conf_threshold = params.get('i_long_exit_activation_confirmation_threshold',  32.4125)
    use_exit_conf       = params.get('i_use_long_exit_confirmation',                   1.0)
    use_entry_conf      = params.get('i_use_long_entry_confirmation',                  False)

    score = df['activation_score']

    # --- Regime filter ---
    # Compute a rolling mean of the activation score to capture multi-bar trend direction.
    # Only gates entries (not exits). i_regime_window=0 disables the filter entirely.
    # NOTE: GPU kernel does NOT apply this filter (Option C from spec). CPU verification
    # in verify_top_results applies the full filter; GPU acts as a pre-filter only.
    regime_window = int(params.get('i_regime_window', 0))
    regime_min    = float(params.get('i_regime_entry_min_score', -1000.0))
    if regime_window > 0:
        regime_ok = score.rolling(window=regime_window, min_periods=1).mean() > regime_min
    else:
        regime_ok = pd.Series(True, index=df.index)

    # --- MVRV bear-regime suppression ---
    # Mirrors Pine: longCondition := longCondition and (not i_mvrv_suppress_bear or mvrv_regime >= 0)
    # mvrv_regime column: -1 = Bear, 0 = Sideways, 1 = Bull (pre-classified by Pine, threshold = MVRV_BEAR_THRESHOLD)
    suppress_bear = bool(params.get('i_mvrv_suppress_bear', False))
    if suppress_bear and 'mvrv_regime' in df.columns:
        regime_ok = regime_ok & (df['mvrv_regime'] >= 0)

    # --- Exit signal (computed first; referenced by entry confirmation) ---
    # Pine: ta.crossunder(activation_score_poc[1], exit_threshold)
    #       AND activation_score_poc < exit_conf_threshold
    # ta.crossunder(score[1], threshold) on bar t means:
    #   score[t-2] >= threshold AND score[t-1] < threshold
    if use_exit_conf:
        exit_raw = (
            (score.shift(2) >= exit_threshold) &
            (score.shift(1) < exit_threshold) &
            (score < exit_conf_threshold)
        )
    else:
        # Pine: ta.crossunder(activation_score_poc[1], exit_threshold) AND stoch_is_peaking
        # stoch_peak_n == -1.0 when peaking (Pine maps peaking → -1, else 0)
        stoch_peak = pd.Series(stoch_peak_n != 0, index=df.index)
        exit_raw = (
            (score.shift(2) >= exit_threshold) &
            (score.shift(1) < exit_threshold) &
            stoch_peak
        )

    # --- Entry signal ---
    if use_entry_conf:
        # Pine: ta.crossunder(activation_score_poc[1], entry_threshold)
        #       AND activation_score_poc > activation_score_poc[1]   (score is rising)
        #       AND not longExitCondition[1]
        # ta.crossunder(score[1], thr) at bar t = score[t-2] >= thr AND score[t-1] < thr
        entry_raw = (
            (score.shift(2) >= entry_threshold) &
            (score.shift(1) < entry_threshold) &
            (score > score.shift(1)) &
            ~exit_raw.shift(1, fill_value=False)
        )
    else:
        # Pine: ta.crossunder(activation_score_poc, entry_threshold)
        # = score[t-1] >= thr AND score[t] < thr
        entry_raw = (score.shift(1) >= entry_threshold) & (score < entry_threshold)

    # Apply regime gate: suppress entries when regime score is below minimum
    entry_raw = entry_raw & regime_ok

    # --- Compute executed positions (entry only if flat, exit only if long) ---
    trail_stop_pct = float(params.get('i_trailing_stop_threshold', 0.0)) / 100.0
    if trail_stop_pct > 0.0:
        in_pos, exec_entry, exec_exit = _apply_trailing_stop(
            entry_raw.fillna(False).values.astype(bool),
            exit_raw.fillna(False).values.astype(bool),
            close_arr,
            high_arr,
            trail_stop_pct,
        )
    else:
        in_pos, exec_entry, exec_exit = calculate_positions(
            entry_raw.fillna(False).values.astype(bool),
            exit_raw.fillna(False).values.astype(bool),
        )

    df['position']      = in_pos.astype(int)
    df['execute_entry'] = exec_entry
    df['execute_exit']  = exec_exit

    return df


def calculate_metrics(df, score_start=None, min_trades=None):
    """
    Calculates performance metrics based on the 'position' column.
    position=1 on the entry bar means we entered at that bar's close.
    strategy_return[i] = position[i-1] * pct_change[i]  (next-bar return)

    score_start: optional ISO date string (e.g. "2019-01-01"). When provided, signals
    are generated on the full window but metrics are computed only on bars on/after this
    date. Trades that opened before score_start but are still active at score_start
    contribute returns from score_start onwards. MIN_SCORABLE_TRADES replaces MIN_TRADES
    as the minimum trade guard when a scoring window is active.

    min_trades: optional override for the minimum trade count threshold. When provided,
    takes precedence over MIN_TRADES / MIN_SCORABLE_TRADES. Used by oos_dashboard to
    pass a scaled OOS threshold based on the IS trade rate.
    """
    df = df.copy()
    df['pct_change'] = df['close'].pct_change()
    df['strategy_return'] = df['position'].shift(1) * df['pct_change']

    # Commission: 0.5% on entry (first bar of holding period) and 0.5% on exit bar.
    # Entry: execute_entry fires on bar i → first holding bar is i+1 → shift(1)
    # Exit:  execute_exit fires on bar j → strategy_return[j] is already active → same bar
    df['strategy_return'] -= df['execute_entry'].shift(1, fill_value=False).astype(float) * COMMISSION_RATE
    df['strategy_return'] -= df['execute_exit'].fillna(False).infer_objects(copy=False).astype(float) * COMMISSION_RATE

    # Restrict scoring to recent window if requested.
    # Signal generation already ran on the full window, so crossunder state is correct.
    df_score = df
    if score_start is not None and 'time' in df.columns:
        score_ts = pd.to_datetime(score_start)
        if df['time'].dt.tz is not None:
            score_ts = score_ts.tz_localize('UTC')
        df_score = df[df['time'] >= score_ts].copy()

    df_clean = df_score.dropna(subset=['strategy_return'])

    pct_in_market = float(df_score['position'].mean() * 100) if len(df_score) > 0 else 0.0

    if len(df_clean) == 0:
        return {"Total Trades": 0, "Total P&L %": 0.0,
                "Sharpe Ratio": -10.0, "Sortino Ratio": -10.0, "Calmar Ratio": -10.0,
                "P&L/DD Ratio": 0.0,
                "Max Drawdown %": 0.0, "% In Market": pct_in_market}

    total_return = (1 + df_clean['strategy_return']).prod() - 1
    total_pnl_pct = total_return * 100

    # Compute real drawdown before the few-trades early exit so the dashboard
    # can display an honest DD even when trade count is below the minimum.
    cumulative_returns_pre = (1 + df_clean['strategy_return']).cumprod()
    peak_pre = cumulative_returns_pre.cummax()
    drawdown_pre = (cumulative_returns_pre - peak_pre) / peak_pre
    real_max_drawdown = float(drawdown_pre.min() * 100)

    trades = df_score['position'].diff().value_counts().get(1, 0)
    if min_trades is not None:
        min_required = min_trades
    else:
        # Dynamic floor: proportional to IS window length so SOL (4.7yr) isn't
        # unfairly held to the same absolute count as BTC (8yr).
        try:
            if 'time' in df_score.columns and len(df_score) > 0:
                t0 = pd.to_datetime(df_score['time'].iloc[0])
                t1 = pd.to_datetime(df_score['time'].iloc[-1])
                is_years = max(0.5, (t1 - t0).days / 365.25)
                min_required = get_min_trades(is_years)
            else:
                min_required = MIN_SCORABLE_TRADES if score_start is not None else MIN_TRADES
        except Exception:
            min_required = MIN_SCORABLE_TRADES if score_start is not None else MIN_TRADES

    if trades < min_required:
        return {
            "Total Trades": int(trades),
            "Total P&L %": total_pnl_pct,
            "Sharpe Ratio": -10.0,
            "Sortino Ratio": -10.0,
            "Calmar Ratio": -10.0,
            "P&L/DD Ratio": 0.0,
            "Max Drawdown %": real_max_drawdown,
            "% In Market": pct_in_market,
        }

    mean_return = df_clean['strategy_return'].mean()
    std_return  = df_clean['strategy_return'].std()
    sharpe = (mean_return / std_return) * np.sqrt(365) if std_return > 0 else -10.0

    # Standard Sortino downside deviation: RMS of negative returns across ALL periods
    # (positive returns contribute 0, not excluded). Matches GPU kernel and validate_strategy.py.
    downside_std = np.sqrt(np.mean(np.minimum(0, df_clean['strategy_return'].values) ** 2))
    sortino = (mean_return / downside_std) * np.sqrt(365) if downside_std > 1e-9 else -10.0

    cumulative_returns = (1 + df_clean['strategy_return']).cumprod()
    peak = cumulative_returns.cummax()
    drawdown = (cumulative_returns - peak) / peak
    max_drawdown = drawdown.min() * 100

    # Calmar ratio: annualised return / abs(max drawdown %).
    # Directly captures the recovery asymmetry — a 50% DD costs 2× a 25% DD
    # because it requires 100% vs 33% gain to recover.
    n_years = len(df_clean) / 365.25
    if n_years > 0 and (1 + total_return) > 1e-9 and abs(max_drawdown) > 0.1:
        annualized_return_pct = ((1 + total_return) ** (1.0 / n_years) - 1.0) * 100.0
        calmar = annualized_return_pct / abs(max_drawdown)
    else:
        calmar = -10.0

    # P&L/DD Ratio: total return per unit of max drawdown.
    # Returns 0 if drawdown exceeds the cap — effectively disqualifies the strategy.
    MAX_DRAWDOWN_CAP = 40.0
    if abs(max_drawdown) >= 0.1 and abs(max_drawdown) <= MAX_DRAWDOWN_CAP:
        pnl_dd_ratio = total_pnl_pct / abs(max_drawdown)
    else:
        pnl_dd_ratio = 0.0

    return {
        "Total Trades": int(trades),
        "Total P&L %": total_pnl_pct,
        "Sharpe Ratio": sharpe,
        "Sortino Ratio": sortino,
        "Calmar Ratio": calmar,
        "P&L/DD Ratio": pnl_dd_ratio,
        "Max Drawdown %": max_drawdown,
        "% In Market": pct_in_market,
    }


# ---------------------------------------------------------------------------
# GPU Acceleration
# ---------------------------------------------------------------------------
# The GPU kernel avoids re-implementing the normalization logic by having
# prepare_features() pre-apply all normalisations on the CPU before uploading
# to the GPU.  The kernel then only needs to do a weighted dot-product.
#
# CRITICAL — feature column ordering:
# The column order in the feature matrix MUST match the order that
# optimize_strategy.py assigns weights: it iterates over param_names
# (JSON key insertion order) and appends names that start with 'i_w_' into
# weight_names.  The current order from params_strategy_activation_scores.json:
#   col 0  → i_w_stoch               → stoch_norm
#   col 1  → i_w_macd_pred           → macd_pred_norm
#   col 2  → i_w_osc                 → osc_norm
#   col 3  → i_w_totalvol            → totalvol_norm
#   col 4  → i_w_m3_momentum         → m3_momentum_norm
#   col 5  → i_w_m2_tiny             → m2_tiny_norm
#   col 6  → i_w_newaddr             → newaddr_norm
#   col 7  → i_w_stoch_div_osc       → stoch_div_osc_norm
#   col 8  → i_w_vwap_div_osc        → vwap_div_osc_norm
#   col 9  → i_w_stoch_peaking       → stoch_peaking_norm  (-1 if peaking, else 0)
#   col 10 → i_w_sendaddr            → sendaddr_norm
#   col 11 → i_w_m3_div_osc          → m3_div_osc_norm
#   col 12 → i_w_bearish_engulfing   → bearish_engulfing_score (raw 0-1)
#   col 13 → i_w_m2_div_osc_noOffset → m2_div_osc_noOffset_norm
#   col 14 → i_w_m2_div_osc          → m2_div_osc_norm
#   col 15 → i_w_bullish_hammer      → bullish_hammer_score (raw 0-1)
#   col 16 → i_w_bullish_engulfing   → bullish_engulfing_score (raw 0-1)
#   col 17 → i_w_shooting_star       → shooting_star_score (raw 0-1)
#   col 18 → i_w_btc_spx_corr        → btc_spx_corr_30 (clipped -1 to 1)
#   col 19 → i_w_dxy                 → dxy_roc_norm (-1/0/1)
#   col 20 → i_w_vix                 → vix_pctrank_inv (0-1)
#   col 21 → i_w_btc_dom             → btc_dom_roc_sign (-1/0/1)
#   col 22 → i_w_us10y               → us10y_roc_inv_sign (-1/0/1)
#   col 23 → i_w_spy                 → spy_above_200ema (-1 or 1)
#   col 24 → i_w_gold                → gold_roc_pctrank (0-1)
#   col 25 → i_w_mvrv                → mvrv_zscore_value {-1,-0.5,-0.25,0.25,0.5,1}
#   col 26 → i_w_mvrv_cont           → mvrv_zscore_cont (continuous, clipped -1 to 1)
#   col 27 → i_w_nupl                → nupl_norm (clipped -1 to 1)
#   col 28 → i_w_fed_net_liq         → fed_net_liq_sign (+1/-1; 0 until TV re-export)
#   col 29 → i_w_gc_position         → gc_position (+1/-1/0; 0 until TV re-export)
#   col 30 → i_w_us2y                → us2y_roc_inv_sign (-1/0/1; 0 until TV re-export)
#   col 31 → i_w_yield_curve         → yield_curve_sign (-1/0/1; 0 until TV re-export)
#   col 32 → i_w_qqq_spy_ratio       → qqq_spy_roc_sign (-1/0/1; 0 until TV re-export)
#
# If params_strategy_activation_scores.json i_w_* key order changes,
# this column mapping MUST be updated to match.

def _prepare_features(df, feature_cols=None):
    """
    Reads pre-normalised indicator columns from the TV-exported CSV and returns
    a float64 (T x N) array ready for the GPU kernel / MLP forward pass.

    All columns are already in [-1, +1] — Pine exports the normalised form.
    No normalisation is performed here.  Column order must match i_w_* key
    order in params_strategy_activation_scores.json.

    feature_cols: if provided, build the matrix from exactly those column names
    (in order), falling back to 0 for any missing column.  When None, uses the
    full hardcoded 49/50-column list below (perceptron / GPU kernel path).

    col 0  → i_w_stoch               → stoch_norm
    col 1  → i_w_macd_pred           → macd_pred_norm
    col 2  → i_w_osc                 → osc_norm
    col 3  → i_w_totalvol            → totalvol_norm
    col 4  → i_w_m3_momentum         → m3_momentum_norm
    col 5  → i_w_m2_tiny             → m2_tiny_norm
    col 6  → i_w_newaddr             → newaddr_norm
    col 7  → i_w_stoch_div_osc       → stoch_div_norm
    col 8  → i_w_vwap_div_osc        → vwap_div_norm
    col 9  → i_w_stoch_peaking       → stoch_peak_norm
    col 10 → i_w_sendaddr            → sendaddr_norm
    col 11 → i_w_m3_div_osc          → m3_div_norm
    col 12 → i_w_bearish_engulfing   → bearish_engulfing_score
    col 13 → i_w_m2_div_osc_noOffset → m2_nooff_norm
    col 14 → i_w_m2_div_osc          → m2_div_norm
    col 15 → i_w_bullish_hammer      → bullish_hammer_score
    col 16 → i_w_bullish_engulfing   → bullish_engulfing_score
    col 17 → i_w_shooting_star       → shooting_star_score
    col 18 → i_w_btc_spx_corr        → btc_spx_corr_30
    col 19 → i_w_dxy                 → dxy_roc_norm
    col 20 → i_w_vix                 → vix_pctrank_inv
    col 21 → i_w_btc_dom             → btc_dom_roc_sign
    col 22 → i_w_us10y               → us10y_roc_inv_sign
    col 23 → i_w_spy                 → spy_above_200ema
    col 24 → i_w_gold                → gold_roc_pctrank
    col 25 → i_w_mvrv                → mvrv_zscore_value
    col 26 → i_w_mvrv_cont           → mvrv_zscore_cont
    col 27 → i_w_nupl                → nupl_norm
    col 28 → i_w_fed_net_liq         → fed_net_liq_sign
    col 29 → i_w_gc_position         → gc_position
    col 30 → i_w_us2y                → us2y_roc_inv_sign
    col 31 → i_w_yield_curve         → yield_curve_sign
    col 32 → i_w_qqq_spy_ratio       → qqq_spy_roc_sign
    col 33 → i_w_rsid_reg_bull       → rsid_reg_bull_norm
    col 34 → i_w_rsid_reg_bear       → rsid_reg_bear_norm
    col 35 → i_w_rsid_hid_bull       → rsid_hid_bull_norm
    col 36 → i_w_rsid_hid_bear       → rsid_hid_bear_norm
    col 37 → i_w_rsid_rt_bull        → rsid_rt_bull_norm
    col 38 → i_w_rsid_rt_bear        → rsid_rt_bear_norm
    col 39 → i_w_rsid_slow_bull      → rsid_slow_bull_norm
    col 40 → i_w_rsid_slow_bear      → rsid_slow_bear_norm
    col 41 → i_w_rsid_delayed_peak   → rsid_delayed_peak_norm
    col 42 → i_w_rsid_delayed_dip    → rsid_delayed_dip_norm
    col 43 → i_w_oi_roc              → oi_roc_norm
    col 44 → i_w_usdt_d              → usdt_d_norm
    col 45 → i_w_basis               → basis_norm
    col 46 → i_w_fear_greed          → fear_greed_norm
    col 47 → i_w_btc_gold            → btc_gold_norm
    col 48 → i_w_rsi_subtf           → rsi_subtf_norm
    """
    df = df.copy()
    df.columns = df.columns.str.lower().str.strip()

    def get(col, fallback=None):
        if col in df.columns:
            return df[col].fillna(0).values.astype(np.float64)
        if fallback and fallback in df.columns:
            return df[fallback].fillna(0).values.astype(np.float64)
        return np.zeros(len(df), dtype=np.float64)

    if feature_cols is not None:
        return np.column_stack([get(c) for c in feature_cols]).astype(np.float64)

    # fmt: off
    cols = [
        get('stoch_norm'),           # col 0
        get('macd_pred_norm'),       # col 1
        get('osc_norm'),             # col 2
        get('totalvol_norm'),        # col 3
        get('m3_momentum_norm'),     # col 4
        get('m2_tiny_norm'),         # col 5
        get('newaddr_norm'),          # col 6
        get('stoch_div_norm'),       # col 7
        get('vwap_div_norm'),        # col 8
        get('stoch_peak_norm'),      # col 9
        get('sendaddr_norm'),        # col 10
        get('m3_div_norm'),          # col 11
        get('bearish_engulfing_score', 'db_bearish'),  # col 12
        get('m2_nooff_norm'),        # col 13
        get('m2_div_norm'),          # col 14
        get('bullish_hammer_score'), # col 15
        get('bullish_engulfing_score'),  # col 16
        get('shooting_star_score'),  # col 17
        get('btc_spx_corr_30'),      # col 18
        get('dxy_roc_norm'),         # col 19
        get('vix_pctrank_inv'),      # col 20
        get('btc_dom_roc_sign'),     # col 21
        get('us10y_roc_inv_sign'),   # col 22
        get('spy_above_200ema'),     # col 23
        get('gold_roc_pctrank'),     # col 24
        get('mvrv_zscore_value'),    # col 25
        get('mvrv_zscore_cont'),     # col 26
        get('nupl_norm'),            # col 27
        get('fed_net_liq_sign'),     # col 28
        get('gc_position'),          # col 29
        get('us2y_roc_inv_sign'),    # col 30
        get('yield_curve_sign'),     # col 31
        get('qqq_spy_roc_sign'),     # col 32
        get('rsid_reg_bull_norm'),   # col 33
        get('rsid_reg_bear_norm'),   # col 34
        get('rsid_hid_bull_norm'),   # col 35
        get('rsid_hid_bear_norm'),   # col 36
        get('rsid_rt_bull_norm'),    # col 37
        get('rsid_rt_bear_norm'),    # col 38
        get('rsid_slow_bull_norm'),  # col 39
        get('rsid_slow_bear_norm'),  # col 40
        get('rsid_delayed_peak_norm'), # col 41
        get('rsid_delayed_dip_norm'),  # col 42
        get('oi_roc_norm'),            # col 43
        get('usdt_d_norm'),            # col 44
        get('basis_norm'),             # col 45
        get('fear_greed_norm'),        # col 46
        get('btc_gold_norm'),          # col 47
        get('rsi_subtf_norm'),         # col 48
        get('bb_pct_b_norm'),          # col 49
    ]
    # fmt: on
    return np.column_stack(cols).astype(np.float64)


if HAS_GPU:
    @cuda.jit
    def _backtest_kernel(features, weights, thresholds, configs, close, high, low, results):
        """
        GPU kernel: runs one complete backtest per thread (one parameter set per row
        of weights/thresholds/configs).

        features  — (T, 49) pre-normalised float64 feature matrix
        weights   — (N, 49) float64 weight vectors
        thresholds — (N, 4) float64: [entry_threshold, exit_threshold, exit_conf_threshold, trailing_stop_pct]
        configs   — (N, 2) float64: [use_exit_conf, use_entry_conf]
        close     — (T,) float64 close prices
        high      — (T,) float64 bar high prices (for trailing stop high-water mark)
        low       — (T,) float64 bar low prices  (for trailing stop trigger)
        results   — (N, 5) output: [total_pnl_pct, max_dd_pct, sharpe, sortino, trades]

        Signal logic (matches CPU generate_signals):
          Entry (conf=false): 1-bar crossunder — prev_score >= entry_thr AND score < entry_thr
          Entry (conf=true) : 2-bar crossunder — pprev_score >= entry_thr AND prev_score < entry_thr
                              AND score > prev_score (rising) AND NOT prev_exit_cond_raw
          Exit  : 2-bar crossunder + confirmation
                  pprev_score >= exit_thr AND prev_score < exit_thr
                  AND (score < exit_conf_thr  [if use_exit_conf]
                       OR  stoch_is_peaking  [otherwise, feature col 9 < -0.5])
        """
        idx = cuda.grid(1)
        if idx >= weights.shape[0]:
            return

        T = features.shape[0]
        F = features.shape[1]

        in_position = False
        trades = 0
        sum_ret = 0.0
        sum_sq_ret = 0.0
        sum_sq_neg_ret = 0.0
        equity = 1.0
        peak_equity = 1.0
        max_drawdown = 0.0

        entry_thr      = thresholds[idx, 0]
        exit_thr       = thresholds[idx, 1]
        conf_thr       = thresholds[idx, 2]
        trail_stop_pct = thresholds[idx, 3] / 100.0
        use_exit_conf  = configs[idx, 0] > 0.5
        use_entry_conf = configs[idx, 1] > 0.5

        # Compute max_score = sum(|w_i|) for normalisation to [-1000, +1000] scale.
        # Mirrors the CPU path in generate_signals so thresholds are weight-independent.
        max_score = 0.0
        for f in range(F):
            w = weights[idx, f]
            max_score += w if w >= 0.0 else -w
        if max_score == 0.0:
            max_score = 1.0  # guard: all-zero weights → score is 0 anyway

        # Compute score for bar 0
        prev_score = 0.0
        for f in range(F):
            prev_score += features[0, f] * weights[idx, f]
        prev_score = prev_score / max_score * 1000.0
        pprev_score = prev_score  # no bar at t=-1; initialise both to bar 0
        prev_exit_cond_raw = False  # raw exit condition from previous bar (for entry confirmation)
        trade_high = 0.0           # trailing stop high-water mark (0 = not in position)

        for t in range(1, T):
            # Trailing stop: trigger when close crosses below stop level.
            # Matches live-trading behaviour (script runs at bar close; fill = close).
            # trail_high updated with this bar's high (known at close).
            trail_stop_hit = False
            if in_position and trail_stop_pct > 0.0:
                # Update trail_high with today's high first (matches Pine ordering),
                # then check stop against close.
                if high[t] > trade_high:
                    trade_high = high[t]
                stop_price_now = trade_high * (1.0 - trail_stop_pct)
                if close[t] <= stop_price_now:
                    trail_stop_hit = True

            # Daily return: always use bar close (fill = close for all exit types)
            daily_ret = 0.0
            if in_position and close[t - 1] != 0.0:
                daily_ret = (close[t] - close[t - 1]) / close[t - 1]

            sum_ret += daily_ret
            sum_sq_ret += daily_ret * daily_ret
            if daily_ret < 0.0:
                sum_sq_neg_ret += daily_ret * daily_ret

            equity *= (1.0 + daily_ret)
            if equity > peak_equity:
                peak_equity = equity
            dd = (equity - peak_equity) / peak_equity if peak_equity > 0.0 else 0.0
            if dd < max_drawdown:
                max_drawdown = dd

            # Compute score for current bar
            score = 0.0
            for f in range(F):
                score += features[t, f] * weights[idx, f]
            score = score / max_score * 1000.0

            entry_signal = False
            exit_signal = False

            # Compute raw exit condition (not gated by in_position; needed for entry confirmation)
            exit_cond_raw = False
            crossed = pprev_score >= exit_thr and prev_score < exit_thr
            if crossed:
                if use_exit_conf:
                    if score < conf_thr:
                        exit_cond_raw = True
                else:
                    # col 9 = stoch_peaking_norm: -1 when peaking, 0 otherwise
                    if features[t, 9] < -0.5:
                        exit_cond_raw = True

            # Entry signal
            if not in_position:
                if use_entry_conf:
                    # 2-bar crossunder + rising score + exit not firing on prev bar
                    if pprev_score >= entry_thr and prev_score < entry_thr and score > prev_score and not prev_exit_cond_raw:
                        entry_signal = True
                else:
                    # 1-bar crossunder
                    if prev_score >= entry_thr and score < entry_thr:
                        entry_signal = True

            # Exit signal (gated by in_position): score-based OR trailing stop
            if in_position and (exit_cond_raw or trail_stop_hit):
                exit_signal = True

            if in_position:
                if exit_signal:
                    in_position = False
                    trade_high = 0.0
                    # Exit commission (0.5% matching COMMISSION_RATE constant)
                    equity *= (1.0 - 0.005)
                    dd = (equity - peak_equity) / peak_equity if peak_equity > 0.0 else 0.0
                    if dd < max_drawdown:
                        max_drawdown = dd
            else:
                if entry_signal:
                    in_position = True
                    trade_high = close[t]  # Initialise trailing high to entry close
                    trades += 1
                    # Entry commission (0.5% matching COMMISSION_RATE constant)
                    equity *= (1.0 - 0.005)
                    dd = (equity - peak_equity) / peak_equity if peak_equity > 0.0 else 0.0
                    if dd < max_drawdown:
                        max_drawdown = dd

            pprev_score = prev_score
            prev_score = score
            prev_exit_cond_raw = exit_cond_raw

        # Compute final metrics
        total_pnl_pct = (equity - 1.0) * 100.0
        max_dd_pct = abs(max_drawdown * 100.0)
        sharpe = -10.0
        sortino = -10.0
        # Enforce minimum trade count: penalty score if too few trades.
        # Mirrors MIN_TRADES constant in the Python module.
        if trades >= 30 and T > 1:
            n = T - 1
            mean_ret = sum_ret / n
            var_ret = (sum_sq_ret / n) - (mean_ret * mean_ret)
            std_ret = var_ret ** 0.5 if var_ret > 1e-9 else 0.0
            if std_ret > 1e-9:
                sharpe = (mean_ret / std_ret) * (365.0 ** 0.5)
            downside_var = sum_sq_neg_ret / n
            downside_std = downside_var ** 0.5 if downside_var > 1e-9 else 0.0
            if downside_std > 1e-9:
                sortino = (mean_ret / downside_std) * (365.0 ** 0.5)

        results[idx, 0] = total_pnl_pct
        results[idx, 1] = max_dd_pct
        results[idx, 2] = sharpe
        results[idx, 3] = sortino
        results[idx, 4] = trades

    def prepare_gpu_data(df):
        """Normalises features on CPU and uploads static data to the GPU once."""
        df = df.copy()
        df.columns = df.columns.str.lower().str.strip()
        features = _prepare_features(df)
        close = df['close'].values.astype(np.float64)
        high  = df['high'].values.astype(np.float64)
        low   = df['low'].values.astype(np.float64)
        return {
            'features': cuda.to_device(features),
            'close':    cuda.to_device(close),
            'high':     cuda.to_device(high),
            'low':      cuda.to_device(low),
        }

    def execute_gpu_batch(gpu_data, weights, thresholds, configs):
        """Launches the backtest kernel for a batch of N parameter sets."""
        N = weights.shape[0]
        d_weights    = cuda.to_device(weights.astype(np.float64))
        d_thresholds = cuda.to_device(thresholds.astype(np.float64))
        d_configs    = cuda.to_device(configs.astype(np.float64))
        d_results    = cuda.to_device(np.zeros((N, 5), dtype=np.float64))

        threads = 128
        blocks  = (N + threads - 1) // threads
        _backtest_kernel[blocks, threads](
            gpu_data['features'], d_weights, d_thresholds, d_configs,
            gpu_data['close'], gpu_data['high'], gpu_data['low'], d_results,
        )
        return d_results.copy_to_host()
