"""
bar_backtest.py

[2026-09-25] 1-minute-bar portfolio backtest over the 20 opening-window days,
driving the bot's REAL decision code (smart_engine / setup_analyzer / exit /
exit_giveback_room / exit_resistance_stall / volatility / reentry) once per
minute instead of every 5 s from ticks.

Differences from the tick replay (simulate.py), applied identically to every
variant so comparisons between variants stay fair:
  - one check per minute (at the bar close); Stage 2's 18 s hold is waived
    (a minute already spans it)
  - no trade-flow imbalance (needs ticks): the imbalance exit and the
    context rule's imbalance check are skipped
  - no quotes: the spread check is skipped
  - hard stop checked against each bar's LOW (fills at the stop, or the bar
    open if it gapped through)
  - entries fill at the NEXT bar's open (market order after the signal)

Usage: python bar_backtest.py VARIANT [--scanner current|alt]
Writes results/<variant>_<scanner>.json with every trade and its context.
"""
import argparse
import gzip
import json
import math
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[1]))
from config_loader import get_config
import smart_engine
import exit as exit_engine
import exit_giveback_room
import exit_resistance_stall
import reentry

ET = ZoneInfo("America/New_York")
UTC = timezone.utc
EQUITY = 8800.0

VARIANTS = {
    "A": {},                                                  # live config (9/25)
    "B": {"low_ceiling": True},
    "C": {"max_ext": True},
    "D": {"low_ceiling": True, "max_ext": True},
    "E": {"trend_bars": 30},
    "F": {"low_ceiling": True, "dead_money": 90},
    "G": {"five_day": True},
    "H": {"context": False},                                  # plan without context rules (9/24 morning)
    "I": {"real_targets": False},                             # plan without real-level targets
    # [2026-09-25] Combinations suggested by the 20-day pattern review
    "J": {"max_ext": True, "min_rr": 2.0},
    "K": {"max_ext": True, "start_after": "10:00"},
    "L": {"max_ext": True, "min_rr": 2.0, "start_after": "10:00"},
    "M": {"max_ext": True, "min_rr": 2.0, "start_after": "10:00", "setups": ["pullback"]},
}


def apply_variant(v: dict):
    cfg = get_config()
    se = cfg["smart_engine"]
    se["use_setup_plan"] = True
    se["entry_slope_rule"] = {**se.get("entry_slope_rule", {}), "enabled": False}
    se["min_stop"] = {**se.get("min_stop", {}), "enabled": True, "style": "k", "k": 0.25}
    se["max_extension_from_open"] = {**se.get("max_extension_from_open", {}), "enabled": v.get("max_ext", False)}
    se["persistence_seconds"] = 0
    se["max_evaluation_gap_seconds"] = 10 ** 6
    sp = cfg["setup_plan"]
    sp["context_rules"] = {**sp.get("context_rules", {}), "enabled": v.get("context", True)}
    sp["real_level_targets"] = v.get("real_targets", True)
    sp["reclaim_breakouts"] = False
    sp["min_reward_risk"] = v.get("min_rr", 1.5)
    sp["five_day_levels"] = {**sp.get("five_day_levels", {}), "enabled": v.get("five_day", False)}
    rs = cfg["resistance_stall"]
    rs.update({"enabled": True, "stall_minutes": 45.0, "low_ceiling_enabled": v.get("low_ceiling", False),
               "dead_money_minutes": v.get("dead_money", 0)})
    cfg["exit"]["deterioration_trend_bars"] = v.get("trend_bars", 0)
    cfg["giveback_room"].update({"min_peak_gain_cents": 15.0, "giveback_ratio": 0.25})
    t = cfg["trading"]
    t.update({"max_positions": 5, "max_position_notional_pct_of_equity": 19.0, "account_risk_pct_per_trade": 1.0,
              "reentry_after_loss": {"mode": "above_entry", "buffer_cents": 1.0}})
    return cfg


def qty_for(entry, stop, t):
    risk = entry - stop
    if risk <= 0:
        return 0
    q = math.floor(EQUITY * t["account_risk_pct_per_trade"] / 100.0 / risk)
    q = min(q, math.floor(EQUITY * t["max_position_notional_pct_of_equity"] / 100.0 / entry))
    return q if q >= 1 else 0


def run_day(day: dict, watch: list, cfg: dict, v: dict = None) -> list:
    v = v or {}
    t_cfg = cfg["trading"]
    syms = day["symbols"]
    bars = {s: [{"t": datetime.fromtimestamp(b[0], UTC), "o": b[1], "h": b[2], "l": b[3], "c": b[4], "v": b[5]}
                for b in d["bars"]] for s, d in syms.items()}
    idx = {s: {b["t"]: i for i, b in enumerate(bl)} for s, bl in bars.items()}
    d0 = datetime.fromisoformat(day["date"])
    start = datetime(d0.year, d0.month, d0.day, 9, 30, tzinfo=ET).astimezone(UTC)
    minutes = [start + timedelta(minutes=i) for i in range(0, 386)]   # 9:30 .. 15:55
    upto = {s: -1 for s in bars}
    positions, trades, persist = {}, [], {}
    gb_state, stall_state, conf_state, healthy = {}, {}, {}, {}

    def close(sym, px, reason, ts):
        p = positions.pop(sym)
        peak = max(p["peak"], px)
        trades.append({**p, "exit_price": round(px, 4), "exit_time": ts.isoformat(), "exit_reason": reason,
                       "pl": round((px - p["entry_price"]) * p["qty"], 2),
                       "pl_dollars": round((px - p["entry_price"]) * p["qty"], 2),
                       "mfe_c": round((peak - p["entry_price"]) * 100, 1),
                       "mae_c": round((p["entry_price"] - p["low"]) * 100, 1)})
        for st in (gb_state, stall_state, conf_state, healthy):
            st.pop(sym, None)

    for m in minutes:
        now = m + timedelta(seconds=59)
        for s in bars:
            if m in idx[s]:
                upto[s] = idx[s][m]
        # ---- exits
        for s in list(positions):
            if upto[s] < 0:
                continue
            bl = bars[s][:upto[s] + 1]
            b = bl[-1]
            p = positions[s]
            if b["t"] != m:            # no trade this minute
                continue
            p["peak"] = max(p["peak"], b["h"]); p["low"] = min(p["low"], b["l"])
            if m.astimezone(ET).strftime("%H:%M") >= "15:55":
                close(s, b["o"], "END_OF_DAY", m); continue
            if b["l"] <= p["stop_price"]:
                close(s, min(p["stop_price"], b["o"]), "hard stop (intrabar)", m); continue
            g = exit_giveback_room.evaluate(s, bl, p["entry_price"], gb_state.get(s, {}))
            gb_state[s] = g.state_out
            if g.should_exit:
                close(s, b["c"], g.reason, m); continue
            r = exit_resistance_stall.evaluate(s, bl, p["entry_time"], now, stall_state.get(s, {}),
                                               slots_free=len(positions) < t_cfg["max_positions"],
                                               healthy=healthy.get(s, True))
            stall_state[s] = r.state_out
            if r.should_exit:
                close(s, b["c"], r.reason, m); continue
            e = exit_engine.evaluate(s, bl, p["entry_price"], conf_state.get(s, {}), trade_imbalance=None,
                                     stop_price=p["stop_price"])
            conf_state[s] = e.confirmation
            healthy[s] = e.reason.startswith("healthy")
            if e.should_exit:
                close(s, b["c"], e.reason, m)
        for s in list(positions):          # symbols with no bar at 15:55
            if m.astimezone(ET).strftime("%H:%M") >= "15:55":
                close(s, bars[s][upto[s]]["c"], "END_OF_DAY", m)
        # ---- entries
        if m.astimezone(ET).strftime("%H:%M") >= "15:15":
            continue
        if v.get("start_after") and m.astimezone(ET).strftime("%H:%M") < v["start_after"]:
            continue
        for s in watch:
            if len(positions) >= t_cfg["max_positions"]:
                break
            if s in positions or upto[s] < 2 or bars[s][upto[s]]["t"] != m:
                continue
            bl = bars[s][:upto[s] + 1]
            prior = [t for t in trades if t["symbol"] == s]
            ok, _ = reentry.check(s, bl[-1]["c"], prior[-1] if prior else None, t_cfg["reentry_after_loss"])
            if not ok:
                continue
            lv = {**syms[s]["levels"], "session_high": max(b["h"] for b in bl)}
            dcs = smart_engine.evaluate(s, bl, sum(b["v"] for b in bl), syms[s]["baseline"], lv,
                                        persist.get(s, {}), quote=None, trade_imbalance=None, as_of=now)
            persist[s] = dcs.persistence
            if not dcs.should_enter:
                continue
            if v.get("setups") and (dcs.metrics.get("plan") or {}).get("setup") not in v["setups"]:
                continue
            nxt = upto[s] + 1
            if nxt >= len(bars[s]):
                continue
            entry = bars[s][nxt]["o"]
            stop = dcs.metrics.get("stage3", {}).get("stop")
            if stop is None or stop >= entry:
                continue
            q = qty_for(entry, stop, t_cfg)
            if q <= 0:
                continue
            plan = dcs.metrics.get("plan") or {}
            positions[s] = {
                "symbol": s, "qty": q, "entry_price": entry, "stop_price": stop,
                "entry_time": bars[s][nxt]["t"].isoformat(), "peak": entry, "low": entry,
                "setup": plan.get("setup"), "rr": plan.get("reward_risk"), "target": plan.get("target"),
                "target_label": plan.get("target_label"),
                "ext_from_open_pct": dcs.metrics.get("ext_from_open_pct"),
                "pos_in_5d": plan.get("pos_in_5d_range_pct"), "room_5d_atr": plan.get("room_to_5d_high_atr"),
                "vol_class": (dcs.metrics.get("volatility") or {}).get("class"),
                "daily_atr_pct": (dcs.metrics.get("volatility") or {}).get("daily_atr_pct"),
                "pace": dcs.metrics.get("volume_pace_ratio"), "vwap_dist_pct":
                    round((entry / dcs.metrics["vwap"] - 1) * 100, 2) if dcs.metrics.get("vwap") else None,
                "watch_rank": watch.index(s) + 1,
            }
    return trades


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("variant", choices=sorted(VARIANTS))
    ap.add_argument("--scanner", choices=["current", "alt"], default="current")
    a = ap.parse_args()
    cfg = apply_variant(VARIANTS[a.variant])
    out = {"variant": a.variant, "scanner": a.scanner, "days": {}}
    for f in sorted((HERE / "bars").glob("bars_*.json.gz")):
        day = json.load(gzip.open(f, "rt"))
        watch = day["top30_current" if a.scanner == "current" else "top30_alt"]
        tr = run_day(day, watch, cfg, VARIANTS[a.variant])
        out["days"][day["date"]] = tr
        print(day["date"], len(tr), "trades", round(sum(t["pl"] for t in tr), 2), flush=True)
    res = HERE / "results"
    res.mkdir(exist_ok=True)
    (res / f"{a.variant}_{a.scanner}.json").write_text(json.dumps(out))
    allt = [t for tr in out["days"].values() for t in tr]
    print(f"TOTAL {a.variant}/{a.scanner}: {len(allt)} trades, {sum(t['pl'] > 0 for t in allt)} wins, "
          f"${sum(t['pl'] for t in allt):+.2f}")


if __name__ == "__main__":
    main()
