# Apex NAS100 — Python research harness (synthetic multi-regime validation)
# Validates mechanics/risk/robustness. NOT real-market edge validation.
import numpy as np, pandas as pd, time

import numpy as np, pandas as pd

def gen_nas100_5m(seed=42, days=260, start_price=20000.0):
    """
    Calibrated synthetic NAS100 5-min bars.
    - Hidden-Markov regimes: trend_up, trend_down, range, breakout, quiet
    - GARCH-like vol clustering + Student-t innovations (fat tails)
    - Session-shaped intraday volatility (NY hours hottest), overnight gaps
    - Volume ~ session U-shape * |return| response * regime multiplier
    Real-market microstructure is NOT claimed: this validates logic + risk, not edge.
    """
    rng = np.random.default_rng(seed)
    bars_per_day = 288                      # 24h of M5 (index CFDs trade ~23h; keep 288, filter later)
    n = days * bars_per_day
    # --- regime chain (daily-ish persistence)
    regimes = np.zeros(n, dtype=int)        # 0 up, 1 down, 2 range, 3 breakout, 4 quiet
    P = np.array([                           # transition matrix (per day)
        [0.80, 0.05, 0.10, 0.04, 0.01],
        [0.05, 0.80, 0.10, 0.04, 0.01],
        [0.12, 0.12, 0.65, 0.06, 0.05],
        [0.20, 0.20, 0.15, 0.40, 0.05],
        [0.15, 0.10, 0.35, 0.05, 0.35],
    ])
    r = rng.choice(5, p=[0.30, 0.25, 0.30, 0.10, 0.05])
    drift_ann = {0: 0.55, 1: -0.50, 2: 0.0, 3: 0.0, 4: 0.0}     # annualized drift by regime
    vol_mult  = {0: 1.0, 1: 1.15, 2: 0.75, 3: 1.9, 4: 0.45}
    for d in range(days):
        r = rng.choice(5, p=P[r])
        regimes[d*bars_per_day:(d+1)*bars_per_day] = r
    # --- intraday session shape (GMT hours): quiet Asia, hot London->NY, rollover lull
    hours = np.tile(np.repeat(np.arange(24), 12), days)
    sess_shape = np.array([0.45,0.42,0.40,0.38,0.38,0.40,0.55,0.75,0.95,1.05,1.10,1.05,
                           1.20,1.35,1.45,1.40,1.30,1.20,1.05,0.90,0.75,0.60,0.52,0.48])
    shape = sess_shape[hours]
    # --- GARCH(1,1)-style vol clustering on annualized base vol 22%
    base = 0.22 / np.sqrt(252 * bars_per_day)          # per-bar base vol
    h = np.zeros(n); h[0] = base**2
    eps = rng.standard_t(df=5, size=n) * np.sqrt(3/5)  # unit-variance student-t
    for i in range(1, n):
        h[i] = 0.04*base**2 + 0.90*h[i-1] + 0.06*(eps[i-1]**2)*h[i-1]
    vol = np.sqrt(h) * shape * np.array([vol_mult[x] for x in regimes])
    # --- drift per bar (regime), plus breakout impulses
    mu = np.array([drift_ann[x] for x in regimes]) / (252 * bars_per_day)
    brk = (regimes == 3)
    mu[brk] += rng.choice([-1, 1], size=brk.sum()) * 0.30 / (252 * bars_per_day)
    # --- mean reversion within range regime toward 24h rolling mean (VWAP-ish pull)
    ret = mu + eps * vol
    price = start_price * np.exp(np.cumsum(ret))
    s = pd.Series(price)
    roll_mean = s.rolling(288, min_periods=20).mean().to_numpy()
    pull = np.where(regimes == 2, (roll_mean - price) / price * 0.02, 0.0)
    ret = ret + np.nan_to_num(pull)
    price = start_price * np.exp(np.cumsum(ret))
    # --- OHLC construction
    prev_close = np.roll(price, 1); prev_close[0] = start_price
    wick = np.abs(rng.standard_normal((n, 2))) * vol * price * 0.5
    o = prev_close
    c = price
    hi = np.maximum(o, c) + wick[:, 0]
    lo = np.minimum(o, c) - wick[:, 1]
    # --- volume: session U * |ret| response * regime
    vbase = 1200
    volume = vbase * shape * (1 + 900*np.abs(ret)) * np.array([vol_mult[x] for x in regimes])
    volume *= rng.lognormal(0, 0.35, n)
    # --- overnight gap at day boundary (00:00 GMT): small jump
    idx = pd.date_range("2025-07-01", periods=n, freq="5min", tz="UTC")
    day_break = np.zeros(n, bool); day_break[::bars_per_day] = True
    gap = rng.normal(0, 0.0006, n) * day_break
    o = o * np.exp(gap); hi = np.maximum(hi, o); lo = np.minimum(lo, o)
    df = pd.DataFrame({"open": o, "high": hi, "low": lo, "close": c, "volume": volume,
                       "regime": regimes}, index=idx)
    return df

df = gen_nas100_5m(seed=42)
print(df.describe().loc[["mean","std","min","max"], ["open","volume"]])
print("regime mix:", np.bincount(df.regime) / len(df))
print("bars:", len(df))

# fix broadcasting: make vol 2D
import numpy as np, pandas as pd

def gen_nas100_5m(seed=42, days=260, start_price=20000.0):
    rng = np.random.default_rng(seed)
    bars_per_day = 288
    n = days * bars_per_day
    regimes = np.zeros(n, dtype=int)
    P = np.array([
        [0.80, 0.05, 0.10, 0.04, 0.01],
        [0.05, 0.80, 0.10, 0.04, 0.01],
        [0.12, 0.12, 0.65, 0.06, 0.05],
        [0.20, 0.20, 0.15, 0.40, 0.05],
        [0.15, 0.10, 0.35, 0.05, 0.35],
    ])
    r = rng.choice(5, p=[0.30, 0.25, 0.30, 0.10, 0.05])
    drift_ann = {0: 0.55, 1: -0.50, 2: 0.0, 3: 0.0, 4: 0.0}
    vol_mult  = {0: 1.0, 1: 1.15, 2: 0.75, 3: 1.9, 4: 0.45}
    for d in range(days):
        r = rng.choice(5, p=P[r])
        regimes[d*bars_per_day:(d+1)*bars_per_day] = r
    hours = np.tile(np.repeat(np.arange(24), 12), days)
    sess_shape = np.array([0.45,0.42,0.40,0.38,0.38,0.40,0.55,0.75,0.95,1.05,1.10,1.05,
                           1.20,1.35,1.45,1.40,1.30,1.20,1.05,0.90,0.75,0.60,0.52,0.48])
    shape = sess_shape[hours]
    base = 0.22 / np.sqrt(252 * bars_per_day)
    h = np.zeros(n); h[0] = base**2
    eps = rng.standard_t(df=5, size=n) * np.sqrt(3/5)
    for i in range(1, n):
        h[i] = 0.04*base**2 + 0.90*h[i-1] + 0.06*(eps[i-1]**2)*h[i-1]
    vol = np.sqrt(h) * shape * np.array([vol_mult[x] for x in regimes])
    mu = np.array([drift_ann[x] for x in regimes]) / (252 * bars_per_day)
    brk = (regimes == 3)
    mu[brk] += rng.choice([-1, 1], size=brk.sum()) * 0.30 / (252 * bars_per_day)
    ret = mu + eps * vol
    price = start_price * np.exp(np.cumsum(ret))
    s = pd.Series(price)
    roll_mean = s.rolling(288, min_periods=20).mean().to_numpy()
    pull = np.where(regimes == 2, (roll_mean - price) / price * 0.02, 0.0)
    ret = ret + np.nan_to_num(pull)
    price = start_price * np.exp(np.cumsum(ret))
    prev_close = np.roll(price, 1); prev_close[0] = start_price
    wick = np.abs(rng.standard_normal((n, 2))) * (vol * price * 0.5)[:, None]
    o = prev_close.copy()
    c = price
    hi = np.maximum(o, c) + wick[:, 0]
    lo = np.minimum(o, c) - wick[:, 1]
    vbase = 1200
    volume = vbase * shape * (1 + 900*np.abs(ret)) * np.array([vol_mult[x] for x in regimes])
    volume *= rng.lognormal(0, 0.35, n)
    idx = pd.date_range("2025-07-01", periods=n, freq="5min", tz="UTC")
    day_break = np.zeros(n, bool); day_break[::bars_per_day] = True
    gap = rng.normal(0, 0.0006, n) * day_break
    o = o * np.exp(gap); hi = np.maximum(hi, o); lo = np.minimum(lo, o)
    df = pd.DataFrame({"open": o, "high": hi, "low": lo, "close": c, "volume": volume,
                       "regime": regimes}, index=idx)
    return df

df = gen_nas100_5m(seed=42)
print("bars:", len(df))
print("5m ATR pts (median):", (df.high-df.low).median().round(1))
print("daily ret vol %:", (df.close.pct_change(288).std()*100).round(2))
print("regime mix:", (np.bincount(df.regime)/len(df)).round(3))
print("price range:", df.close.min().round(0), "->", df.close.max().round(0))

def precompute(df, atr_len=14, er_len=20):
    c, h, l, v = df.close.to_numpy(), df.high.to_numpy(), df.low.to_numpy(), df.volume.to_numpy()
    o = df.open.to_numpy()
    typ = (h + l + c) / 3
    atr = wilder_atr(h, l, c, atr_len)
    # anchored VWAPs via grouped cumsums
    idx = df.index
    day_id = idx.floor("D"); week_id = idx.to_period("W"); mon_id = idx.to_period("M"); q_id = idx.to_period("Q")
    def avwap(gid):
        g = pd.Series(typ*v, index=idx).groupby(gid).cumsum() / pd.Series(v, index=idx).groupby(gid).cumsum()
        return g.to_numpy()
    vw_d, vw_w, vw_m, vw_q = avwap(day_id), avwap(week_id), avwap(mon_id), avwap(q_id)
    # delta proxy: close-location split blended 50/50 with sign rule
    rng_ = np.where(h > l, h - l, 1e-9)
    buy_share = np.clip((c - l) / rng_, 0, 1)
    sign = np.sign(c - o)
    buy_share = 0.5 * buy_share + 0.5 * (sign > 0)
    delta = (2 * buy_share - 1) * v
    pressure = np.where(v > 0, delta / v, 0.0)
    # cumdelta per day
    cd = pd.Series(delta, index=idx).groupby(day_id).cumsum().to_numpy()
    # MFI(14)
    mf = typ * v
    pos = np.where(typ > np.roll(typ, 1), mf, 0.0); pos[0] = 0
    neg = np.where(typ < np.roll(typ, 1), mf, 0.0); neg[0] = 0
    pos_s = pd.Series(pos).rolling(14).sum().to_numpy()
    neg_s = pd.Series(neg).rolling(14).sum().to_numpy()
    mfi = np.where(neg_s > 0, 100 - 100 / (1 + pos_s / np.where(neg_s == 0, 1e-9, neg_s)), 100.0)
    # ATR percentile over 20 days (rolling rank of atr within window)
    atr_s = pd.Series(atr)
    atr_pctl = atr_s.rolling(288*20, min_periods=288).apply(lambda x: (x <= x[-1]).mean(), raw=True).to_numpy()
    # efficiency ratio (signed)
    er_num = c - np.roll(c, er_len); er_num[:er_len] = 0
    er_den = pd.Series(np.abs(np.diff(c, prepend=c[0]))).rolling(er_len).sum().to_numpy()
    er = np.where(er_den > 0, er_num / np.where(er_den == 0, 1e-9, er_den), 0.0)
    # EMA50
    ema50 = pd.Series(c).ewm(span=50, adjust=False).mean().to_numpy()
    # momentum ROC12 normalized
    roc = c - np.roll(c, 12); roc[:12] = 0
    mom = np.clip(roc / (2 * np.where(atr > 0, atr, 1e-9)), -1, 1)
    return dict(o=o, h=h, l=l, c=c, v=v, typ=typ, atr=atr, vw_d=vw_d, vw_w=vw_w, vw_m=vw_m, vw_q=vw_q,
                delta=delta, pressure=pressure, cd=cd, mfi=mfi, atr_pctl=atr_pctl, er=er,
                ema50=ema50, mom=mom)

print("precompute ready")

CFG = dict(
    risk_pct=0.5, max_daily_dd=4.0, max_overall_dd=8.0, halt_frac=0.9,
    max_trades_day=6, min_score=70.0,
    W=dict(vwap=15, delta=15, money=10, profile=12, structure=20, amd=10, regime=10, book=8),
    sl_min_atr=0.8, sl_max_atr=2.2, rr=(1.0, 2.0, 3.0),
    tp1_close=0.5, tp2_close=0.3, be_buf_atr=0.2, trail_atr=1.2,
    profile_bars=288, profile_rows=60, swing_len=5, valid_bars=12,
    eq_tol_atr=0.25, fvg_min_atr=0.15, spread_pts=2.0, slip_sl_pts=1.0,
    trade_london=True, trade_ny=True, trade_asia=False,
    news_utc=[(11*60+30, 11*60+50), (13*60, 13*60+10)], friday_cut_utc=18*60,
)

def run_backtest(df, cfg=CFG, start_equity=100000.0, collect=True):
    P = precompute(df)
    o,h,l,c,v = P["o"],P["h"],P["l"],P["c"],P["v"]
    atr, er, ema50, mom = P["atr"], P["er"], P["ema50"], P["mom"]
    vw_d, vw_w, vw_q = P["vw_d"], P["vw_w"], P["vw_q"]
    pressure, cd, mfi = P["pressure"], P["cd"], P["mfi"]
    idx = df.index; n = len(df)
    hours = (idx.hour.to_numpy()*60 + idx.minute.to_numpy())
    gmt_hours = idx.hour.to_numpy()
    dows = idx.dayofweek.to_numpy()
    W = cfg["W"]; wsum = sum(W.values()); sc = 100.0/wsum

    # --- incremental structure state
    SL_, SH_ = [], []              # swing lows/highs: (baridx, price)
    iSL_, iSH_ = [], []            # internal (len=2)
    trend = 0; last_bos = {"dir":0,"bar":-999}; last_choch = {"dir":0,"bar":-999}
    fvgs, obs = [], []             # dicts
    eq_h = {"lvl":None,"bar":-999}; eq_l = {"lvl":None,"bar":-999}
    sweep = {"up":-999,"dn":-999}
    day_h = day_l = None

    equity = start_equity; baseline = start_equity
    day_start_eq = equity; cur_day = None; trades_today = 0
    halted = False; halt_reason = ""
    pos = None                     # dict with trade state
    trades = []; eq_curve = []
    last_sig_bar = -999; last_sig_dir = 0
    pb = cfg["profile_bars"]; rows = cfg["profile_rows"]

    for i in range(60, n-1):
        # ---------- risk tracking ----------
        d = idx[i].date()
        if d != cur_day:
            cur_day = d; day_start_eq = equity; trades_today = 0
            if halted and halt_reason == "DAILY_DD": halted = False; halt_reason = ""
        daily_dd = 100*(day_start_eq-equity)/day_start_eq if day_start_eq>0 else 0
        overall_dd = 100*(baseline-equity)/baseline if baseline>0 else 0
        if not halted:
            if daily_dd >= cfg["halt_frac"]*cfg["max_daily_dd"]: halted=True; halt_reason="DAILY_DD"
            elif overall_dd >= cfg["halt_frac"]*cfg["max_overall_dd"]: halted=True; halt_reason="OVERALL_DD"
        # ---------- manage open position on this bar (intrabar, conservative: SL first) ----------
        if pos is not None:
            hit = None
            if pos["dir"] == 1:
                if l[i] <= pos["sl"]: hit = ("sl", pos["sl"] - cfg["slip_sl_pts"])
                elif h[i] >= pos["tp2"] and not pos["tp2_done"]: hit = ("tp2", pos["tp2"])
                elif h[i] >= pos["tp1"] and not pos["tp1_done"]: hit = ("tp1", pos["tp1"])
                if pos["tp2_done"] and h[i] > pos["wm"]: pos["wm"] = h[i]
            else:
                if h[i] >= pos["sl"]: hit = ("sl", pos["sl"] + cfg["slip_sl_pts"])
                elif l[i] <= pos["tp2"] and not pos["tp2_done"]: hit = ("tp2", pos["tp2"])
                elif l[i] <= pos["tp1"] and not pos["tp1_done"]: hit = ("tp1", pos["tp1"])
                if pos["tp2_done"] and l[i] < pos["wm"]: pos["wm"] = l[i]
            # trailing after tp2
            if pos is not None and pos["tp2_done"] and not (hit and hit[0]=="sl"):
                trail = pos["wm"] - pos["dir"]*cfg["trail_atr"]*pos["atr"]
                if pos["dir"]==1 and trail > pos["sl"]: pos["sl"] = trail
                if pos["dir"]==-1 and trail < pos["sl"]: pos["sl"] = trail
            if hit:
                kind, px = hit
                r_pts = pos["sl_dist"]
                if kind == "tp1":
                    pos["tp1_done"] = True
                    pnl = cfg["tp1_close"] * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    pos["sl"] = pos["entry"] + pos["dir"]*cfg["be_buf_atr"]*pos["atr"]
                    trades.append(dict(pnl=pnl, kind="tp1", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                elif kind == "tp2":
                    pos["tp2_done"] = True
                    pnl = cfg["tp2_close"] * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    trades.append(dict(pnl=pnl, kind="tp2", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                else:  # sl / final
                    frac = 1.0
                    if pos["tp1_done"] or pos["tp2_done"]:
                        frac = 1.0 - (cfg["tp1_close"] if pos["tp1_done"] else 0) - (cfg["tp2_close"] if pos["tp2_done"] else 0)
                    pnl = frac * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    trades.append(dict(pnl=pnl, kind="sl", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                    pos = None
        eq_curve.append(equity)

        # ---------- incremental structure updates ----------
        s = cfg["swing_len"]
        j = i - s
        if j >= s:
            w = h[j-s:j+s+1]
            if h[j] == w.max() and (w < h[j]).sum() == 2*s: SH_.append((j, h[j]))
            w2 = l[j-s:j+s+1]
            if l[j] == w2.min() and (w2 > l[j]).sum() == 2*s: SL_.append((j, l[j]))
            SH_, SL_ = SH_[-10:], SL_[-10:]
        j2 = i - 2
        if j2 >= 2:
            w = h[j2-2:j2+3]
            if h[j2] == w.max() and (w < h[j2]).sum() == 4: iSH_.append((j2, h[j2]))
            w2 = l[j2-2:j2+3]
            if l[j2] == w2.min() and (w2 > l[j2]).sum() == 4: iSL_.append((j2, l[j2]))
            iSH_, iSL_ = iSH_[-10:], iSL_[-10:]
        # trend from last two swings
        if len(SH_) >= 2 and len(SL_) >= 2:
            hh = SH_[-1][1] > SH_[-2][1]; hl = SL_[-1][1] > SL_[-2][1]
            lh = SH_[-1][1] < SH_[-2][1]; ll = SL_[-1][1] < SL_[-2][1]
            trend = 1 if (hh and hl) else (-1 if (lh and ll) else 0)
        # BOS / CHoCH on closes
        if len(SH_) >= 1 and SH_[-1][0] < i and c[i] > SH_[-1][1] and c[i-1] <= SH_[-1][1]:
            if trend == -1: last_choch = {"dir":1,"bar":i}
            else: last_bos = {"dir":1,"bar":i}
            # order block: last down candle within 10 bars
            for k in range(i, max(i-10, 0), -1):
                if c[k] < o[k]:
                    obs.append(dict(lo=l[k], hi=h[k], bull=True, bar=k, vol=v[k]))
                    break
        if len(SL_) >= 1 and SL_[-1][0] < i and c[i] < SL_[-1][1] and c[i-1] >= SL_[-1][1]:
            if trend == 1: last_choch = {"dir":-1,"bar":i}
            else: last_bos = {"dir":-1,"bar":i}
            for k in range(i, max(i-10, 0), -1):
                if c[k] > o[k]:
                    obs.append(dict(lo=l[k], hi=h[k], bull=False, bar=k, vol=v[k]))
                    break
        obs = [z for z in obs if not (z["bull"] and c[i] < z["lo"]) and not (not z["bull"] and c[i] > z["hi"])]
        obs = obs[-20:]
        # FVG at bars (i-1,i,i+1)-> use confirmed triplet ending at i (gap between bar i-2 and i)
        a_ = atr[i] if atr[i] > 0 else 1e-9
        if l[i] > h[i-2] and (l[i]-h[i-2]) >= cfg["fvg_min_atr"]*a_:
            fvgs.append(dict(lo=h[i-2], hi=l[i], bull=True, bar=i))
        if h[i] < l[i-2] and (l[i-2]-h[i]) >= cfg["fvg_min_atr"]*a_:
            fvgs.append(dict(lo=h[i], hi=l[i-2], bull=False, bar=i))
        fvgs = [z for z in fvgs if not (z["bull"] and l[i] <= z["hi"] and i > z["bar"]) and not (not z["bull"] and h[i] >= z["lo"] and i > z["bar"])]
        fvgs = fvgs[-20:]
        # equal highs/lows
        if len(SH_) >= 2:
            tol = cfg["eq_tol_atr"]*a_
            cand = [p for _, p in SH_[-6:]]
            for x in range(len(cand)):
                for y in range(x+1, len(cand)):
                    if abs(cand[x]-cand[y]) <= tol: eq_h = {"lvl":(cand[x]+cand[y])/2, "bar":i}
        if len(SL_) >= 2:
            tol = cfg["eq_tol_atr"]*a_
            cand = [p for _, p in SL_[-6:]]
            for x in range(len(cand)):
                for y in range(x+1, len(cand)):
                    if abs(cand[x]-cand[y]) <= tol: eq_l = {"lvl":(cand[x]+cand[y])/2, "bar":i}
        # sweeps: wick beyond eq/swing then close back
        if eq_h["lvl"] and i - eq_h["bar"] <= cfg["valid_bars"] and h[i] > eq_h["lvl"] and c[i] < eq_h["lvl"]:
            sweep["up"] = i
        if eq_l["lvl"] and i - eq_l["bar"] <= cfg["valid_bars"] and l[i] < eq_l["lvl"] and c[i] > eq_l["lvl"]:
            sweep["dn"] = i
        if len(SH_) >= 1 and h[i] > SH_[-1][1] and c[i] < SH_[-1][1]: sweep["up"] = i
        if len(SL_) >= 1 and l[i] < SL_[-1][1] and c[i] > SL_[-1][1]: sweep["dn"] = i
        print("") if False else None

        # ---------- gates ----------
        if halted or pos is not None: continue
        if trades_today >= cfg["max_trades_day"]: continue
        sess = session_of(gmt_hours[i])
        if sess in ("ASIA",) and not cfg["trade_asia"]: continue
        if sess in ("LONDON","OVERLAP") and not cfg["trade_london"]: continue
        if sess == "NEWYORK" and not cfg["trade_ny"]: continue
        if sess == "OFFHOURS": continue
        if any(a <= hours[i] <= b for a, b in cfg["news_utc"]): continue
        if dows[i] == 4 and hours[i] >= cfg["friday_cut_utc"]: continue
        if dows[i] >= 5: continue

        # ---------- analytics snapshot ----------
        if not (atr[i] > 0 and P["atr_pctl"][i] == P["atr_pctl"][i]): continue
        p = c[i]
        # profile every bar (vectorized mask, cheap enough)
        if i >= pb:
            lo_w, hi_w = l[i-pb:i].min(), h[i-pb:i].max()
            if hi_w > lo_w:
                edges = np.linspace(lo_w, hi_w, rows+1)
                mask = (edges[:-1][None,:] <= h[i-pb:i][:,None]) & (edges[1:][None,:] >= l[i-pb:i][:,None])
                cov = mask.sum(1); cov = np.where(cov==0,1,cov)
                bins = ((v[i-pb:i]/cov)[:,None]*mask).sum(0)
                poc_i = int(np.argmax(bins)); total=bins.sum()
                li_=hi_=poc_i; acc=bins[poc_i]
                while acc < 0.70*total and (li_>0 or hi_<rows-1):
                    up = bins[hi_+1] if hi_<rows-1 else -1
                    dn = bins[li_-1] if li_>0 else -1
                    if up>=dn: hi_+=1; acc+=max(up,0)
                    else: li_-=1; acc+=max(dn,0)
                ctr=(edges[:-1]+edges[1:])/2
                poc, vah, val = ctr[poc_i], edges[hi_+1], edges[li_]
                mean_bin = total/rows
                lvn_below = bool(((bins[:max(poc_i,1)] <= 0.5*mean_bin) & (ctr[:max(poc_i,1)] < p)).any())
            else:
                poc=vah=val=np.nan; lvn_below=False
        else:
            poc=vah=val=np.nan; lvn_below=False

        # regime
        pctl = P["atr_pctl"][i]
        eri = er[i]
        if pctl < 0.20: regime = "QUIET"
        elif (h[i]-l[i]) > 1.5*atr[i] and abs(eri) > 0.25: regime = "BREAKOUT"
        elif eri > 0.35 and c[i] > ema50[i]: regime = "TREND_UP"
        elif eri < -0.35 and c[i] < ema50[i]: regime = "TREND_DOWN"
        else: regime = "RANGE"
        if regime == "QUIET": continue
        vf = float(np.clip(atr[i]/(pd.Series(atr[max(0,i-5760):i]).mean() if i>100 else atr[i]), 0.5, 1.5))

        # ---------- score 8 groups (mirrors MQL5) ----------
        gL = {k:0.0 for k in W}; gS = {k:0.0 for k in W}
        # vwap
        if p > vw_d[i] and p > vw_w[i]: gL["vwap"] += 0.6*W["vwap"]
        if p < vw_d[i] and p < vw_w[i]: gS["vwap"] += 0.6*W["vwap"]
        if p > vw_q[i]: gL["vwap"] += 0.4*W["vwap"]
        if p < vw_q[i]: gS["vwap"] += 0.4*W["vwap"]
        # delta
        if pressure[i] > 0.2: gL["delta"] += 0.3*W["delta"]
        if pressure[i] < -0.2: gS["delta"] += 0.3*W["delta"]
        if i >= 10:
            cds = cd[i] - cd[i-10]
            if cds > 0: gL["delta"] += 0.25*W["delta"]
            if cds < 0: gS["delta"] += 0.25*W["delta"]
        if len(SL_) >= 2 and SL_[-1][1] < SL_[-2][1] and cd[i] > cd[max(SL_[-2][0],0)]: gL["delta"] += 0.25*W["delta"]
        if len(SH_) >= 2 and SH_[-1][1] > SH_[-2][1] and cd[i] < cd[max(SH_[-2][0],0)]: gS["delta"] += 0.25*W["delta"]
        # money
        if mfi[i] > 50 and pressure[i] > 0: gL["money"] += 0.7*W["money"]
        if mfi[i] < 50 and pressure[i] < 0: gS["money"] += 0.7*W["money"]
        # profile
        if vah == vah:
            if p > vah and lvn_below: gL["profile"] += 0.4*W["profile"]
            if p < val: gS["profile"] += 0.4*W["profile"]
            if abs(p-val) < 0.3*atr[i] and c[i] > o[i]: gL["profile"] += 0.6*W["profile"]
            if abs(p-vah) < 0.3*atr[i] and c[i] < o[i]: gS["profile"] += 0.6*W["profile"]
            if abs(p-poc) < 0.3*atr[i] and c[i] > o[i]: gL["profile"] += 0.3*W["profile"]
            if abs(p-poc) < 0.3*atr[i] and c[i] < o[i]: gS["profile"] += 0.3*W["profile"]
        # structure
        fresh = cfg["valid_bars"]
        if i-last_bos["bar"] <= fresh and last_bos["dir"] == 1: gL["structure"] += 0.30*W["structure"]
        if i-last_bos["bar"] <= fresh and last_bos["dir"] == -1: gS["structure"] += 0.30*W["structure"]
        if i-last_choch["bar"] <= fresh and last_choch["dir"] == 1: gL["structure"] += 0.20*W["structure"]
        if i-last_choch["bar"] <= fresh and last_choch["dir"] == -1: gS["structure"] += 0.20*W["structure"]
        if len(SH_)>=1 and len(SL_)>=1:
            rh, rl = SH_[-1][1], SL_[-1][1]
            eq = (rh+rl)/2
            if p < eq: gL["structure"] += 0.15*W["structure"]
            if p > eq: gS["structure"] += 0.15*W["structure"]
        in_bull_ob = any(z["bull"] and z["lo"] <= p <= z["hi"] for z in obs)
        in_bear_ob = any(not z["bull"] and z["lo"] <= p <= z["hi"] for z in obs)
        in_bull_fvg = any(z["bull"] and z["lo"] <= p <= z["hi"] for z in fvgs)
        in_bear_fvg = any(not z["bull"] and z["lo"] <= p <= z["hi"] for z in fvgs)
        if in_bull_ob or in_bull_fvg: gL["structure"] += 0.25*W["structure"]
        if in_bear_ob or in_bear_fvg: gS["structure"] += 0.25*W["structure"]
        if i-sweep["dn"] <= 5: gL["structure"] += 0.10*W["structure"]
        if i-sweep["up"] <= 5: gS["structure"] += 0.10*W["structure"]
        gL["structure"] = min(gL["structure"], W["structure"]); gS["structure"] = min(gS["structure"], W["structure"])
        # AMD
        if i >= 20:
            p_old = c[i-20:i].mean(); p_new = c[i-10:i].mean(); p_old2 = c[i-20:i-10].mean()
            v_old = v[i-20:i-10].mean(); v_new = v[i-10:i].mean()
            p_up = p_new > p_old2; p_dn = p_new < p_old2; v_up = v_new > v_old
            accum = (p_up and v_up) or ((h[i-20:i].max()-l[i-20:i].min() < 1.2*atr[i]) and (cd[i]-cd[i-20] > 0))
            distrib = p_dn and v_up
            manip_dn = (i-sweep["dn"] <= 5) and (pressure[i-3:i].mean() > 0.2 if i>=3 else False)
            manip_up = (i-sweep["up"] <= 5) and (pressure[i-3:i].mean() < -0.2 if i>=3 else False)
            if manip_dn: gL["amd"] += W["amd"]
            elif accum: gL["amd"] += 0.7*W["amd"]
            if manip_up: gS["amd"] += W["amd"]
            elif distrib: gS["amd"] += 0.7*W["amd"]
        # regime group
        if regime == "TREND_UP": gL["regime"] += 0.6*W["regime"]
        if regime == "TREND_DOWN": gS["regime"] += 0.6*W["regime"]
        if mom[i] > 0.2: gL["regime"] += 0.4*W["regime"]
        if mom[i] < -0.2: gS["regime"] += 0.4*W["regime"]
        if regime == "RANGE":
            gL["regime"] = min(gL["regime"], 0.3*W["regime"]); gS["regime"] = min(gS["regime"], 0.3*W["regime"])
        # book proxy (no ticks in bar data): pressure alignment + volume-rising tape
        vol_rising = i >= 20 and v[i-5:i].mean() > v[i-20:i-5].mean()
        if pressure[i] > 0.15: gL["book"] += 0.5*W["book"]
        if pressure[i] < -0.15: gS["book"] += 0.5*W["book"]
        if vol_rising: gL["book"] += 0.5*W["book"]; gS["book"] += 0.5*W["book"]
        for k in W:
            gL[k] = min(gL[k], W[k]); gS[k] = min(gS[k], W[k])
        longScore = sum(gL.values())*sc; shortScore = sum(gS.values())*sc
        # adaptive threshold
        ms_ = cfg["min_score"] + (10 if regime=="RANGE" else (-5 if regime=="BREAKOUT" else 0)) + (5 if sess=="ASIA" else 0)
        d = 1 if longScore > shortScore else -1
        score = max(longScore, shortScore)
        if abs(longScore-shortScore) < 8 or score < ms_: continue
        if last_sig_bar == i and last_sig_dir == d: continue
        last_sig_bar, last_sig_dir = i, d
        # SL synthesis
        if d == 1:
            cands = [z["lo"] for z in obs if z["bull"]] + ([SL_[-1][1]] if SL_ else [])
            sl = (min(cands) - 0.3*atr[i]) if cands else (p - 1.5*atr[i])
        else:
            cands = [z["hi"] for z in obs if not z["bull"]] + ([SH_[-1][1]] if SH_ else [])
            sl = (max(cands) + 0.3*atr[i]) if cands else (p + 1.5*atr[i])
        dist = abs(p - sl)
        dist = float(np.clip(dist, cfg["sl_min_atr"]*atr[i], cfg["sl_max_atr"]*atr[i]))
        sl = p - d*dist
        # ---------- execute at next bar open ----------
        entry = o[i+1] + d*cfg["spread_pts"]/2
        sl_dist = abs(entry - sl)
        risk_amt = equity * cfg["risk_pct"]/100
        pos = dict(dir=d, entry=entry, sl=entry - d*sl_dist, sl_dist=sl_dist,
                   tp1=entry + d*cfg["rr"][0]*sl_dist*vf, tp2=entry + d*cfg["rr"][1]*sl_dist*vf,
                   atr=atr[i], tp1_done=False, tp2_done=False, wm=entry, risk_amt=risk_amt,
                   regime=regime, score=score)
        trades_today += 1
    # close any open position at end
    if pos is not None:
        frac = 1.0 - (cfg["tp1_close"] if pos["tp1_done"] else 0) - (cfg["tp2_close"] if pos["tp2_done"] else 0)
        pnl = frac * pos["risk_amt"] * ((c[n-1]-pos["entry"])*pos["dir"]/pos["sl_dist"])
        equity += pnl
        trades.append(dict(pnl=pnl, kind="eod", bar=n-1, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
    return dict(equity=equity, trades=pd.DataFrame(trades), eq_curve=np.array(eq_curve))

print("engine ready")

# Patch engine: add variant flags into run_backtest via CFG
# variant flags:
#   range_trigger_mode: in RANGE require range-specific trigger instead of +10 threshold
#   normalize_achievable: score normalized by achievable max under current caps

import types

def run_backtest_v2(df, cfg=CFG, start_equity=100000.0):
    """v2: adds range_trigger_mode + normalize_achievable. Everything else identical."""
    P = precompute(df)
    o,h,l,c,v = P["o"],P["h"],P["l"],P["c"],P["v"]
    atr, er, ema50, mom = P["atr"], P["er"], P["ema50"], P["mom"]
    vw_d, vw_w, vw_q = P["vw_d"], P["vw_w"], P["vw_q"]
    pressure, cd, mfi = P["pressure"], P["cd"], P["mfi"]
    idx = df.index; n = len(df)
    hours = (idx.hour.to_numpy()*60 + idx.minute.to_numpy())
    gmt_hours = idx.hour.to_numpy(); dows = idx.dayofweek.to_numpy()
    W = cfg["W"]; wsum = sum(W.values()); sc = 100.0/wsum
    SL_, SH_ = [], []; iSL_, iSH_ = [], []
    trend = 0; last_bos = {"dir":0,"bar":-999}; last_choch = {"dir":0,"bar":-999}
    fvgs, obs = [], []
    eq_h = {"lvl":None,"bar":-999}; eq_l = {"lvl":None,"bar":-999}
    sweep = {"up":-999,"dn":-999}
    equity = start_equity; baseline = start_equity
    day_start_eq = equity; cur_day = None; trades_today = 0
    halted = False; halt_reason = ""
    pos = None; trades = []; eq_curve = []
    last_sig_bar = -999; last_sig_dir = 0
    pb = cfg["profile_bars"]; rows = cfg["profile_rows"]

    for i in range(60, n-1):
        d = idx[i].date()
        if d != cur_day:
            cur_day = d; day_start_eq = equity; trades_today = 0
            if halted and halt_reason == "DAILY_DD": halted = False; halt_reason = ""
        daily_dd = 100*(day_start_eq-equity)/day_start_eq if day_start_eq>0 else 0
        overall_dd = 100*(baseline-equity)/baseline if baseline>0 else 0
        if not halted:
            if daily_dd >= cfg["halt_frac"]*cfg["max_daily_dd"]: halted=True; halt_reason="DAILY_DD"
            elif overall_dd >= cfg["halt_frac"]*cfg["max_overall_dd"]: halted=True; halt_reason="OVERALL_DD"
        if pos is not None:
            hit = None
            if pos["dir"] == 1:
                if l[i] <= pos["sl"]: hit = ("sl", pos["sl"] - cfg["slip_sl_pts"])
                elif h[i] >= pos["tp2"] and not pos["tp2_done"]: hit = ("tp2", pos["tp2"])
                elif h[i] >= pos["tp1"] and not pos["tp1_done"]: hit = ("tp1", pos["tp1"])
                if pos["tp2_done"] and h[i] > pos["wm"]: pos["wm"] = h[i]
            else:
                if h[i] >= pos["sl"]: hit = ("sl", pos["sl"] + cfg["slip_sl_pts"])
                elif l[i] <= pos["tp2"] and not pos["tp2_done"]: hit = ("tp2", pos["tp2"])
                elif l[i] <= pos["tp1"] and not pos["tp1_done"]: hit = ("tp1", pos["tp1"])
                if pos["tp2_done"] and l[i] < pos["wm"]: pos["wm"] = l[i]
            if pos is not None and pos["tp2_done"] and not (hit and hit[0]=="sl"):
                trail = pos["wm"] - pos["dir"]*cfg["trail_atr"]*pos["atr"]
                if pos["dir"]==1 and trail > pos["sl"]: pos["sl"] = trail
                if pos["dir"]==-1 and trail < pos["sl"]: pos["sl"] = trail
            if hit:
                kind, px = hit
                r_pts = pos["sl_dist"]
                if kind == "tp1":
                    pos["tp1_done"] = True
                    pnl = cfg["tp1_close"] * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    pos["sl"] = pos["entry"] + pos["dir"]*cfg["be_buf_atr"]*pos["atr"]
                    trades.append(dict(pnl=pnl, kind="tp1", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                elif kind == "tp2":
                    pos["tp2_done"] = True
                    pnl = cfg["tp2_close"] * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    trades.append(dict(pnl=pnl, kind="tp2", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                else:
                    frac = 1.0
                    if pos["tp1_done"] or pos["tp2_done"]:
                        frac = 1.0 - (cfg["tp1_close"] if pos["tp1_done"] else 0) - (cfg["tp2_close"] if pos["tp2_done"] else 0)
                    pnl = frac * pos["risk_amt"] * ((px - pos["entry"])*pos["dir"]/r_pts)
                    equity += pnl
                    trades.append(dict(pnl=pnl, kind="sl", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
                    pos = None
        eq_curve.append(equity)

        s = cfg["swing_len"]; j = i - s
        if j >= s:
            w = h[j-s:j+s+1]
            if h[j] == w.max() and (w < h[j]).sum() == 2*s: SH_.append((j, h[j]))
            w2 = l[j-s:j+s+1]
            if l[j] == w2.min() and (w2 > l[j]).sum() == 2*s: SL_.append((j, l[j]))
            SH_, SL_ = SH_[-10:], SL_[-10:]
        if len(SH_) >= 2 and len(SL_) >= 2:
            hh = SH_[-1][1] > SH_[-2][1]; hl = SL_[-1][1] > SL_[-2][1]
            lh = SH_[-1][1] < SH_[-2][1]; ll = SL_[-1][1] < SL_[-2][1]
            trend = 1 if (hh and hl) else (-1 if (lh and ll) else 0)
        if len(SH_) >= 1 and SH_[-1][0] < i and c[i] > SH_[-1][1] and c[i-1] <= SH_[-1][1]:
            if trend == -1: last_choch = {"dir":1,"bar":i}
            else: last_bos = {"dir":1,"bar":i}
            for k in range(i, max(i-10, 0), -1):
                if c[k] < o[k]: obs.append(dict(lo=l[k], hi=h[k], bull=True, bar=k, vol=v[k])); break
        if len(SL_) >= 1 and SL_[-1][0] < i and c[i] < SL_[-1][1] and c[i-1] >= SL_[-1][1]:
            if trend == 1: last_choch = {"dir":-1,"bar":i}
            else: last_bos = {"dir":-1,"bar":i}
            for k in range(i, max(i-10, 0), -1):
                if c[k] > o[k]: obs.append(dict(lo=l[k], hi=h[k], bull=False, bar=k, vol=v[k])); break
        obs = [z for z in obs if not (z["bull"] and c[i] < z["lo"]) and not (not z["bull"] and c[i] > z["hi"])]
        obs = obs[-20:]
        a_ = atr[i] if atr[i] > 0 else 1e-9
        if l[i] > h[i-2] and (l[i]-h[i-2]) >= cfg["fvg_min_atr"]*a_:
            fvgs.append(dict(lo=h[i-2], hi=l[i], bull=True, bar=i))
        if h[i] < l[i-2] and (l[i-2]-h[i]) >= cfg["fvg_min_atr"]*a_:
            fvgs.append(dict(lo=h[i], hi=l[i-2], bull=False, bar=i))
        fvgs = [z for z in fvgs if not (z["bull"] and l[i] <= z["hi"] and i > z["bar"]) and not (not z["bull"] and h[i] >= z["lo"] and i > z["bar"])]
        fvgs = fvgs[-20:]
        if len(SH_) >= 2:
            tol = cfg["eq_tol_atr"]*a_
            cand = [p for _, p in SH_[-6:]]
            for x in range(len(cand)):
                for y in range(x+1, len(cand)):
                    if abs(cand[x]-cand[y]) <= tol: eq_h = {"lvl":(cand[x]+cand[y])/2, "bar":i}
        if len(SL_) >= 2:
            tol = cfg["eq_tol_atr"]*a_
            cand = [p for _, p in SL_[-6:]]
            for x in range(len(cand)):
                for y in range(x+1, len(cand)):
                    if abs(cand[x]-cand[y]) <= tol: eq_l = {"lvl":(cand[x]+cand[y])/2, "bar":i}
        if eq_h["lvl"] and i - eq_h["bar"] <= cfg["valid_bars"] and h[i] > eq_h["lvl"] and c[i] < eq_h["lvl"]: sweep["up"] = i
        if eq_l["lvl"] and i - eq_l["bar"] <= cfg["valid_bars"] and l[i] < eq_l["lvl"] and c[i] > eq_l["lvl"]: sweep["dn"] = i
        if len(SH_) >= 1 and h[i] > SH_[-1][1] and c[i] < SH_[-1][1]: sweep["up"] = i
        if len(SL_) >= 1 and l[i] < SL_[-1][1] and c[i] > SL_[-1][1]: sweep["dn"] = i

        if halted or pos is not None: continue
        if trades_today >= cfg["max_trades_day"]: continue
        sess = session_of(gmt_hours[i])
        if sess in ("ASIA",) and not cfg["trade_asia"]: continue
        if sess in ("LONDON","OVERLAP") and not cfg["trade_london"]: continue
        if sess == "NEWYORK" and not cfg["trade_ny"]: continue
        if sess == "OFFHOURS": continue
        if any(aa <= hours[i] <= bb for aa, bb in cfg["news_utc"]): continue
        if dows[i] == 4 and hours[i] >= cfg["friday_cut_utc"]: continue
        if dows[i] >= 5: continue
        if not (atr[i] > 0 and P["atr_pctl"][i] == P["atr_pctl"][i]): continue
        p = c[i]
        if i >= pb:
            lo_w, hi_w = l[i-pb:i].min(), h[i-pb:i].max()
            if hi_w > lo_w:
                edges = np.linspace(lo_w, hi_w, rows+1)
                mask = (edges[:-1][None,:] <= h[i-pb:i][:,None]) & (edges[1:][None,:] >= l[i-pb:i][:,None])
                cov = mask.sum(1); cov = np.where(cov==0,1,cov)
                bins = ((v[i-pb:i]/cov)[:,None]*mask).sum(0)
                poc_i = int(np.argmax(bins)); total=bins.sum()
                li_=hi_=poc_i; acc=bins[poc_i]
                while acc < 0.70*total and (li_>0 or hi_<rows-1):
                    up = bins[hi_+1] if hi_<rows-1 else -1
                    dn = bins[li_-1] if li_>0 else -1
                    if up>=dn: hi_+=1; acc+=max(up,0)
                    else: li_-=1; acc+=max(dn,0)
                ctr=(edges[:-1]+edges[1:])/2
                poc, vah, val = ctr[poc_i], edges[hi_+1], edges[li_]
                mean_bin = total/rows
                lvn_below = bool(((bins[:max(poc_i,1)] <= 0.5*mean_bin) & (ctr[:max(poc_i,1)] < p)).any())
            else:
                poc=vah=val=np.nan; lvn_below=False
        else:
            poc=vah=val=np.nan; lvn_below=False

        pctl = P["atr_pctl"][i]; eri = er[i]
        if pctl < 0.20: regime = "QUIET"
        elif (h[i]-l[i]) > 1.5*atr[i] and abs(eri) > 0.25: regime = "BREAKOUT"
        elif eri > 0.35 and c[i] > ema50[i]: regime = "TREND_UP"
        elif eri < -0.35 and c[i] < ema50[i]: regime = "TREND_DOWN"
        else: regime = "RANGE"
        if regime == "QUIET": continue
        vf = float(np.clip(atr[i]/(pd.Series(atr[max(0,i-5760):i]).mean() if i>100 else atr[i]), 0.5, 1.5))

        gL = {k:0.0 for k in W}; gS = {k:0.0 for k in W}
        if p > vw_d[i] and p > vw_w[i]: gL["vwap"] += 0.6*W["vwap"]
        if p < vw_d[i] and p < vw_w[i]: gS["vwap"] += 0.6*W["vwap"]
        if p > vw_q[i]: gL["vwap"] += 0.4*W["vwap"]
        if p < vw_q[i]: gS["vwap"] += 0.4*W["vwap"]
        if pressure[i] > 0.2: gL["delta"] += 0.3*W["delta"]
        if pressure[i] < -0.2: gS["delta"] += 0.3*W["delta"]
        if i >= 10:
            cds = cd[i] - cd[i-10]
            if cds > 0: gL["delta"] += 0.25*W["delta"]
            if cds < 0: gS["delta"] += 0.25*W["delta"]
        if len(SL_) >= 2 and SL_[-1][1] < SL_[-2][1] and cd[i] > cd[max(SL_[-2][0],0)]: gL["delta"] += 0.25*W["delta"]
        if len(SH_) >= 2 and SH_[-1][1] > SH_[-2][1] and cd[i] < cd[max(SH_[-2][0],0)]: gS["delta"] += 0.25*W["delta"]
        if mfi[i] > 50 and pressure[i] > 0: gL["money"] += 0.7*W["money"]
        if mfi[i] < 50 and pressure[i] < 0: gS["money"] += 0.7*W["money"]
        if vah == vah:
            if p > vah and lvn_below: gL["profile"] += 0.4*W["profile"]
            if p < val: gS["profile"] += 0.4*W["profile"]
            if abs(p-val) < 0.3*atr[i] and c[i] > o[i]: gL["profile"] += 0.6*W["profile"]
            if abs(p-vah) < 0.3*atr[i] and c[i] < o[i]: gS["profile"] += 0.6*W["profile"]
            if abs(p-poc) < 0.3*atr[i] and c[i] > o[i]: gL["profile"] += 0.3*W["profile"]
            if abs(p-poc) < 0.3*atr[i] and c[i] < o[i]: gS["profile"] += 0.3*W["profile"]
        fresh = cfg["valid_bars"]
        if i-last_bos["bar"] <= fresh and last_bos["dir"] == 1: gL["structure"] += 0.30*W["structure"]
        if i-last_bos["bar"] <= fresh and last_bos["dir"] == -1: gS["structure"] += 0.30*W["structure"]
        if i-last_choch["bar"] <= fresh and last_choch["dir"] == 1: gL["structure"] += 0.20*W["structure"]
        if i-last_choch["bar"] <= fresh and last_choch["dir"] == -1: gS["structure"] += 0.20*W["structure"]
        if len(SH_)>=1 and len(SL_)>=1:
            rh, rl = SH_[-1][1], SL_[-1][1]; eq = (rh+rl)/2
            if p < eq: gL["structure"] += 0.15*W["structure"]
            if p > eq: gS["structure"] += 0.15*W["structure"]
        in_bull_ob = any(z["bull"] and z["lo"] <= p <= z["hi"] for z in obs)
        in_bear_ob = any(not z["bull"] and z["lo"] <= p <= z["hi"] for z in obs)
        in_bull_fvg = any(z["bull"] and z["lo"] <= p <= z["hi"] for z in fvgs)
        in_bear_fvg = any(not z["bull"] and z["lo"] <= p <= z["hi"] for z in fvgs)
        if in_bull_ob or in_bull_fvg: gL["structure"] += 0.25*W["structure"]
        if in_bear_ob or in_bear_fvg: gS["structure"] += 0.25*W["structure"]
        if i-sweep["dn"] <= 5: gL["structure"] += 0.10*W["structure"]
        if i-sweep["up"] <= 5: gS["structure"] += 0.10*W["structure"]
        gL["structure"] = min(gL["structure"], W["structure"]); gS["structure"] = min(gS["structure"], W["structure"])
        if i >= 20:
            p_new = c[i-10:i].mean(); p_old2 = c[i-20:i-10].mean()
            v_old = v[i-20:i-10].mean(); v_new = v[i-10:i].mean()
            p_up = p_new > p_old2; p_dn = p_new < p_old2; v_up = v_new > v_old
            accum = (p_up and v_up) or ((h[i-20:i].max()-l[i-20:i].min() < 1.2*atr[i]) and (cd[i]-cd[i-20] > 0))
            distrib = p_dn and v_up
            manip_dn = (i-sweep["dn"] <= 5) and (pressure[i-3:i].mean() > 0.2 if i>=3 else False)
            manip_up = (i-sweep["up"] <= 5) and (pressure[i-3:i].mean() < -0.2 if i>=3 else False)
            if manip_dn: gL["amd"] += W["amd"]
            elif accum: gL["amd"] += 0.7*W["amd"]
            if manip_up: gS["amd"] += W["amd"]
            elif distrib: gS["amd"] += 0.7*W["amd"]
        if regime == "TREND_UP": gL["regime"] += 0.6*W["regime"]
        if regime == "TREND_DOWN": gS["regime"] += 0.6*W["regime"]
        if mom[i] > 0.2: gL["regime"] += 0.4*W["regime"]
        if mom[i] < -0.2: gS["regime"] += 0.4*W["regime"]
        # achievable-max normalization (structural): cap regime group in RANGE
        cap_reg = 0.3*W["regime"] if regime == "RANGE" else W["regime"]
        gL["regime"] = min(gL["regime"], cap_reg); gS["regime"] = min(gS["regime"], cap_reg)
        vol_rising = i >= 20 and v[i-5:i].mean() > v[i-20:i-5].mean()
        if pressure[i] > 0.15: gL["book"] += 0.5*W["book"]
        if pressure[i] < -0.15: gS["book"] += 0.5*W["book"]
        if vol_rising: gL["book"] += 0.5*W["book"]; gS["book"] += 0.5*W["book"]
        for k in W:
            gL[k] = min(gL[k], W[k]); gS[k] = min(gS[k], W[k])
        rawL, rawS = sum(gL.values()), sum(gS.values())
        if cfg.get("normalize_achievable", False):
            achievable = wsum - (W["regime"] - cap_reg)   # only range caps reduce ceiling
            longScore = rawL/achievable*100; shortScore = rawS/achievable*100
        else:
            longScore = rawL*sc; shortScore = rawS*sc
        # RANGE handling: trigger requirement OR +10 threshold
        range_ok_L = range_ok_S = True
        if regime == "RANGE" and cfg.get("range_trigger_mode", False):
            discount_L = len(SH_)>=1 and len(SL_)>=1 and p < (SH_[-1][1]+SL_[-1][1])/2
            premium_S = len(SH_)>=1 and len(SL_)>=1 and p > (SH_[-1][1]+SL_[-1][1])/2
            trigL = (in_bull_ob or in_bull_fvg or (i-sweep["dn"]<=5) or
                     (vah==vah and (abs(p-val)<0.3*atr[i] or abs(p-poc)<0.3*atr[i]) and c[i]>o[i]))
            trigS = (in_bear_ob or in_bear_fvg or (i-sweep["up"]<=5) or
                     (vah==vah and (abs(p-vah)<0.3*atr[i] or abs(p-poc)<0.3*atr[i]) and c[i]<o[i]))
            range_ok_L = discount_L and trigL
            range_ok_S = premium_S and trigS
            ms_ = cfg["min_score"] + (5 if sess=="ASIA" else 0)
        else:
            ms_ = cfg["min_score"] + (10 if regime=="RANGE" else (-5 if regime=="BREAKOUT" else 0)) + (5 if sess=="ASIA" else 0)
        dd = 1 if longScore > shortScore else -1
        score = max(longScore, shortScore)
        if abs(longScore-shortScore) < 8 or score < ms_: continue
        if regime == "RANGE" and cfg.get("range_trigger_mode", False):
            if dd == 1 and not range_ok_L: continue
            if dd == -1 and not range_ok_S: continue
        if last_sig_bar == i and last_sig_dir == dd: continue
        last_sig_bar, last_sig_dir = i, dd
        if dd == 1:
            cands = [z["lo"] for z in obs if z["bull"]] + ([SL_[-1][1]] if SL_ else [])
            sl = (min(cands) - 0.3*atr[i]) if cands else (p - 1.5*atr[i])
        else:
            cands = [z["hi"] for z in obs if not z["bull"]] + ([SH_[-1][1]] if SH_ else [])
            sl = (max(cands) + 0.3*atr[i]) if cands else (p + 1.5*atr[i])
        dist = abs(p - sl)
        dist = float(np.clip(dist, cfg["sl_min_atr"]*atr[i], cfg["sl_max_atr"]*atr[i]))
        sl = p - dd*dist
        entry = o[i+1] + dd*cfg["spread_pts"]/2
        sl_dist = abs(entry - sl)
        risk_amt = equity * cfg["risk_pct"]/100
        pos = dict(dir=dd, entry=entry, sl=entry - dd*sl_dist, sl_dist=sl_dist,
                   tp1=entry + dd*cfg["rr"][0]*sl_dist*vf, tp2=entry + dd*cfg["rr"][1]*sl_dist*vf,
                   atr=atr[i], tp1_done=False, tp2_done=False, wm=entry, risk_amt=risk_amt,
                   regime=regime, score=score)
        trades_today += 1
    if pos is not None:
        frac = 1.0 - (cfg["tp1_close"] if pos["tp1_done"] else 0) - (cfg["tp2_close"] if pos["tp2_done"] else 0)
        pnl = frac * pos["risk_amt"] * ((c[n-1]-pos["entry"])*pos["dir"]/pos["sl_dist"])
        equity += pnl
        trades.append(dict(pnl=pnl, kind="eod", bar=n-1, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))
    return dict(equity=equity, trades=pd.DataFrame(trades), eq_curve=np.array(eq_curve))

CFG_V2 = dict(CFG, range_trigger_mode=True, normalize_achievable=True)
t0=time.time()
v2_1 = []
for s in range(3):
    d = gen_nas100_5m(seed=100+s, days=260)
    r = run_backtest_v2(d, cfg=CFG_V2)
    eq = r["eq_curve"]; tr = r["trades"]
    peak = np.maximum.accumulate(eq); mdd = ((peak-eq)/peak).max()*100
    entries = int((tr.kind=="sl").sum() + (tr.kind=="eod").sum())
    v2_1.append(dict(seed=100+s, ret_pct=100*(r["equity"]/100000-1), max_dd=mdd, entries=entries, per_day=entries/260))
    del d, r
print("V2 (range trigger + achievable normalization) %.0fs" % (time.time()-t0))
print(pd.DataFrame(v2_1).round(2).to_string(index=False))

# Add two structural risk rules as flags:
#  A) consec-loss daily cooldown: after N consecutive full-SL losses today, stop for the day
#  B) time-stop: exit position at close if no TP/SL after N bars
# Implement via minimal edits: wrap run_backtest_v2 with monkey-patched cfg and a small patch of code.
# (Rather than duplicating the whole engine again, I'll subclass via exec of modified source.)

import inspect, re
src = inspect.getsource(run_backtest_v2)

# A) cooldown: track consecutive SL losses today; gate new entries
srcA = src.replace(
    '        if halted or pos is not None: continue',
    '        if halted or pos is not None: continue\n'
    '        if cfg.get("cooldown_losses", 0) > 0 and consec_sl >= cfg["cooldown_losses"]: continue'
).replace(
    '            cur_day = d; day_start_eq = equity; trades_today = 0',
    '            cur_day = d; day_start_eq = equity; trades_today = 0; consec_sl = 0'
).replace(
    '    pos = None; trades = []; eq_curve = []',
    '    pos = None; trades = []; eq_curve = []; consec_sl = 0'
).replace(
    '                    trades.append(dict(pnl=pnl, kind="sl", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))\n'
    '                    pos = None',
    '                    trades.append(dict(pnl=pnl, kind="sl", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))\n'
    '                    if pnl < 0: consec_sl += 1\n'
    '                    else: consec_sl = 0\n'
    '                    pos = None'
)
srcA = srcA.replace("def run_backtest_v2", "def run_backtest_v3")

# B) time-stop: check holding time before management
srcA = srcA.replace(
    '        if pos is not None:\n            hit = None',
    '        if pos is not None:\n'
    '            if cfg.get("time_stop_bars", 0) > 0 and (i - pos.get("entry_bar", i)) >= cfg["time_stop_bars"]:\n'
    '                frac = 1.0 - (cfg["tp1_close"] if pos["tp1_done"] else 0) - (cfg["tp2_close"] if pos["tp2_done"] else 0)\n'
    '                pnl = frac * pos["risk_amt"] * ((c[i]-pos["entry"])*pos["dir"]/pos["sl_dist"])\n'
    '                equity += pnl\n'
    '                trades.append(dict(pnl=pnl, kind="tstop", bar=i, dir=pos["dir"], regime=pos["regime"], score=pos["score"]))\n'
    '                pos = None\n'
    '        if pos is not None:\n            hit = None'
).replace(
    'regime=regime, score=score)\n        trades_today += 1',
    'regime=regime, score=score, entry_bar=i+1)\n        trades_today += 1'
)
exec(srcA)
print("v3 engine defined:", "run_backtest_v3" in dir())

def mc3(fn, cfg, seeds):
    out = []
    for s in seeds:
        d = gen_nas100_5m(seed=100+s, days=260)
        r = fn(d, cfg=cfg)
        eq = r["eq_curve"]; tr = r["trades"]
        peak = np.maximum.accumulate(eq); mdd = ((peak-eq)/peak).max()*100
        entries = int((tr.kind=="sl").sum() + (tr.kind=="eod").sum() + (tr.kind=="tstop").sum()) if "tstop" in tr.columns else int((tr.kind=="sl").sum())
        out.append(dict(seed=100+s, ret_pct=100*(r["equity"]/100000-1), max_dd=mdd, entries=entries))
        del d, r
    return pd.DataFrame(out)

t0=time.time()
CFG_A = dict(CFG, cooldown_losses=2)          # baseline + loss cooldown
resA = mc3(run_backtest_v3, CFG_A, range(3))
print("A) loss-cooldown(2) %.0fs" % (time.time()-t0))
print(resA.round(2).to_string(index=False), "\nmean ret %.2f%%, DD %.2f%%" % (resA.ret_pct.mean(), resA.max_dd.mean()))

# Export the harness cells to a runnable script
wanted = []
for cell in _ih:
    if ("def gen_nas100_5m" in cell) or ("def precompute" in cell and "import" not in cell) \
       or cell.startswith("CFG = dict") or ("def run_backtest_v2" in cell) or ("def mc3" in cell):
        wanted.append(cell.strip())
script = "# Apex NAS100 — Python research harness (synthetic multi-regime validation)\n" \
         "# Validates mechanics/risk/robustness. NOT real-market edge validation.\n" \
         "import numpy as np, pandas as pd, time\n\n" + "\n\n".join(wanted) + '''

if __name__ == "__main__":
    d = gen_nas100_5m(seed=42, days=260)
    r = run_backtest_v2(d, cfg=CFG)
    eq = r["eq_curve"]
    peak = np.maximum.accumulate(eq)
    print("ret %.1f%% | maxDD %.2f%% | events %d" % (100*(r["equity"]/100000-1), ((peak-eq)/peak).max()*100, len(r["trades"])))
'''
open("/mnt/agents/output/ApexNAS100/research_harness.py","w").write(script)
import subprocess
print(subprocess.run(["python3","-c","import ast; ast.parse(open('/mnt/agents/output/ApexNAS100/research_harness.py').read()); print('syntax OK')"],capture_output=True,text=True).stdout)

if __name__ == "__main__":
    d = gen_nas100_5m(seed=42, days=260)
    r = run_backtest_v2(d, cfg=CFG)
    eq = r["eq_curve"]
    peak = np.maximum.accumulate(eq)
    print("ret %.1f%% | maxDD %.2f%% | events %d" % (100*(r["equity"]/100000-1), ((peak-eq)/peak).max()*100, len(r["trades"])))
