"""
exit_v2.py -- [2026-09-29] Exits the user asked for on 9/29 ("add the smooth exit and add
the breakeven rule stop trail"), ported from trade1/exit_rules.py modes "step_r" and
"smooth" (tested there on fixed entries: 21 days smooth break 0.6 +$1,213, 0.8 +$970 vs
exit B +$407; step_r + reversal breakeven -$547 vs hold -$1,275 on the first entries).

One managed stop + one trend check, evaluated every poll:
  STOP TRAIL (trail: true)
    start   the entry's stop, at least min_stop_pct below the buy price; R = buy - start
    steps   every time price has risen another R above the buy, the stop rises by R
            (after the first step it sits at the buy price = BREAKEVEN)
    trail   from breakeven on, the stop also trails atr_mult x the 1-min ATR (Wilder,
            atr_period bars) under the high since the buy; it never moves down
    reversal breakeven (reversal_breakeven_atr > 0): at the first 1-min bar after the
            buy that closes >= 1 ATR under the high since the buy, if that high was
            < reversal_breakeven_atr x ATR above the buy, the stop moves to the buy price
    sells on a touch of the stop
  SMOOTH TREND (smooth: true)
    smoothness = R^2 of the last smooth_bars finished 1-min closes, signed by the slope;
    armed once it reaches smooth_arm after the buy; armed -> sell when it drops under
    smooth_break (the steady climb broke)
  CLIMAX (climax_exit: true) [2026-09-30, user-approved X1 from the playbook study]
    sell when a finished 1-min bar after the buy traded >= climax_volx x its 14-day normal
    volume for that minute AND made the day's high (2 years of scanner stocks: after such a
    bar a -1% move came first 46-48% of the time vs +1% first 33-39%, every period)
replace_j: true -> these are the ONLY exits besides the 15:55 sell-all (J's giveback,
stall, imbalance, weakening and hard-stop layers are skipped); false -> they run first,
then J's layers as before.
"""
from dataclasses import dataclass, field
from datetime import datetime

DEFAULTS = {
    "enabled": False,
    "replace_j": True,
    "trail": True,
    "min_stop_pct": 1.0,
    "atr_mult": 2.0,
    "atr_period": 14,
    "reversal_breakeven_atr": 2.0,
    "smooth": True,
    "smooth_bars": 30,
    "smooth_arm": 0.8,
    "smooth_break": 0.6,
    "climax_exit": False,
    "climax_volx": 10.0,
    "smooth_need_below_vwap": False,   # [2026-10-02] a smooth break only sells once price < VWAP (WNC 10/2)
}


@dataclass
class ExitV2Decision:
    should_exit: bool = False
    state: str = "HOLD"
    reason: str = ""
    metrics: dict = field(default_factory=dict)


def _atr_1m(done, period):
    atr = None
    for prev, b in zip(done, done[1:]):
        tr = max(b["h"] - b["l"], abs(b["h"] - prev["c"]), abs(b["l"] - prev["c"]))
        atr = tr if atr is None else atr + (tr - atr) / period
    return atr


def _smoothness(closes):
    n = len(closes)
    if n < 5:
        return None
    xm, ym = (n - 1) / 2, sum(closes) / n
    sxx = sum((k - xm) ** 2 for k in range(n))
    slope = sum((k - xm) * (y - ym) for k, y in enumerate(closes)) / sxx
    sst = sum((y - ym) ** 2 for y in closes)
    if sst == 0:
        return 0.0
    ssr = sum((y - (ym + slope * (k - xm))) ** 2 for k, y in enumerate(closes))
    return (1 - ssr / sst) * (1 if slope > 0 else -1)


def _minute(ts):
    from zoneinfo import ZoneInfo
    t = ts.astimezone(ZoneInfo("America/New_York"))
    return (t.hour - 9) * 60 + t.minute - 30


def _vwap(bars):
    pv = sum((b["h"] + b["l"] + b["c"]) / 3 * b["v"] for b in bars)
    v = sum(b["v"] for b in bars)
    return pv / v if v else bars[-1]["c"]


def evaluate(bars, position, state, cfg=None, ref=None) -> ExitV2Decision:
    """bars: today's 1-min bars incl. the forming one; position: {"entry_price",
    "stop_price", "entry_time" (iso)}; state: per-position dict kept by the caller."""
    c = {**DEFAULTS, **(cfg or {})}
    price = bars[-1]["c"]
    entry = position["entry_price"]
    done = bars[:-1]
    t_buy = datetime.fromisoformat(position["entry_time"]).replace(second=0, microsecond=0)
    if "stop" not in state:
        stop0 = min(position["stop_price"], entry * (1 - c["min_stop_pct"] / 100.0))
        state.update(stop0=stop0, stop=stop0, steps=0, high=entry, how="entry stop", armed=False,
                     peak_r2=None, reversal_seen=False, bar_high=entry)
    state["high"] = max(state["high"], price)
    R = entry - state["stop0"]
    atr = _atr_1m(done[-(int(c["atr_period"]) * 3):], int(c["atr_period"])) if len(done) >= 2 else None

    if c["trail"]:
        if R > 0:
            steps = int((state["high"] - entry) // R)
            if steps > state["steps"]:
                state["steps"] = steps
                new = state["stop0"] + steps * R
                if new > state["stop"]:
                    state["stop"], state["how"] = new, ("breakeven (+1R)" if steps == 1 else f"step +{steps - 1}R")
        k_rev = c.get("reversal_breakeven_atr") or 0
        if k_rev and atr and not state["reversal_seen"] and done and done[-1]["t"] >= t_buy:
            last = done[-1]
            state["bar_high"] = max(state["bar_high"], last["h"])
            if state["bar_high"] > entry and last["c"] <= state["bar_high"] - atr:
                state["reversal_seen"] = True
                run = state["bar_high"] - entry
                if run < k_rev * atr and entry > state["stop"]:
                    state["stop"], state["how"] = entry, f"first reversal after only {run / atr:.1f} ATR -> buy price"
        if state["steps"] >= 1 and atr:
            trail = state["high"] - c["atr_mult"] * atr
            if trail > state["stop"]:
                state["stop"], state["how"] = trail, f"trail {c['atr_mult']}x1m-ATR ({atr:.3f})"

    r2 = None
    if c["smooth"]:
        n = int(c["smooth_bars"])
        if len(done) >= n:
            r2 = _smoothness([b["c"] for b in done[-n:]])
        if r2 is not None and done and done[-1]["t"] >= t_buy and r2 >= c["smooth_arm"]:
            state["armed"] = True
        if r2 is not None:
            state["peak_r2"] = r2 if state["peak_r2"] is None else max(state["peak_r2"], r2)

    climax = None
    if c.get("climax_exit") and ref and len(done) >= 2 and done[-1]["t"] >= t_buy:
        b = done[-1]
        mi = _minute(b["t"])
        # [2026-10-02] re-checked on EVERY poll (was once per bar): the first look at a just-finished
        # bar is the live-trade version; the official bar (full volume/high) replaces it ~1-2 s
        # later and must be seen too (UNIT 10/2 9:46: 23x volume at a new high, X1 did not fire)
        if 0 <= mi < 390:
            volx = b["v"] / max(ref["vol_per_min"][mi], 1)
            if volx >= c["climax_volx"] and b["h"] >= max(x["h"] for x in done[:-1]):
                climax = (volx, b["h"])

    stop = state["stop"]
    m = {"stop": round(stop, 4), "stop_from": state["how"], "R": round(R, 4), "steps": state["steps"],
         "atr_1m": round(atr, 4) if atr else None, "high_since_entry": state["high"],
         "smoothness": round(r2, 3) if r2 is not None else None, "armed": state["armed"],
         "peak_smoothness": round(state["peak_r2"], 3) if state["peak_r2"] is not None else None}
    if climax:
        m["climax_volx"] = round(climax[0], 1)
        return ExitV2Decision(True, "CLIMAX", f"exit_v2 climax: {climax[0]:.0f}x normal volume on a new day high "
                                              f"{climax[1]:.2f} (X1)", m)
    if price <= stop:
        return ExitV2Decision(True, "STOP", f"exit_v2 stop: price {price:.2f} <= {stop:.2f} ({state['how']})", m)
    if c["smooth"] and state["armed"] and r2 is not None and r2 < c["smooth_break"] \
            and not (c.get("smooth_need_below_vwap") and price >= _vwap(bars)):
        return ExitV2Decision(True, "SMOOTH_BREAK",
                              f"exit_v2 smooth break: smoothness {r2:.2f} < {c['smooth_break']} (trend broke)", m)
    return ExitV2Decision(False, "HOLD", f"exit_v2 hold: stop {stop:.2f} ({state['how']})"
                          + (f", smoothness {r2:.2f}{' armed' if state['armed'] else ''}" if r2 is not None else ""), m)
