"""
exit_rules.py

EXIT rules for every open position, whichever entry module bought it
(position["setup"] says which one; position["plan"] carries what that
module attached at entry). Called by monitor.py every poll -- see
rules_api.py. The core still sells everything at 15:55.

[2026-09-27] HIGHER-LOW TRAIL -- "hold while the higher lows hold".
The big-winner study (reports/2026-09-27_big_winner_patterns.pdf) found
the day's big winners climb all afternoon in a staircase of higher lows,
while the old exits sold every trade by 11:30. So:

  1. Starting stop:
       initial_stop = "entry"  the entry module's own stop
       initial_stop = "swing"  the last swing low BEFORE the entry (more
                               room), but never more than
                               max_initial_risk_atr x daily ATR below the
                               entry price, and never above the entry stop
  2. Trail: build swing_minutes bars (default 5-min) from the 1-min bars.
     Each time a new swing low forms AFTER the entry (a bar whose low is
     below the bars on both sides) and it is higher than the current
     stop, the stop moves up to it (minus buffer_cents). Never down.
  3. Sell when:
       trigger = "close"  a finished 1-min bar CLOSES below the stop
                          (a quick wick through the stop doesn't count), or
                          price falls disaster_pct below the stop at any time
       trigger = "touch"  price trades at or below the stop

[2026-09-27] mode "step_r" -- STEPPED STOP, then ATR TRAIL (user's design):
  R = entry price - the entry's stop (the risk per share).
  1. Every time price rises another R above the entry, the stop rises by R
     (stop = original stop + k*R after k steps). After the first step the
     stop is at the entry price (breakeven).
  2. From breakeven on, the stop trails step_atr_mult x the 1-minute ATR
     (Wilder, step_atr_period finished 1-min bars) below the highest price
     since the entry -- and it never moves down.
  3. Sell the moment price touches the stop (step_trigger "touch"), or on a
     finished 1-min bar closing below it ("close").
  Option step_min_stop_pct (0 = off), e.g. 1.0: the starting stop is at
     least this % below the buy price (the entry's own stop if lower still);
     R is measured from it, so the steps scale with it too.
  Option step_reversal_breakeven_atr (0 = off), e.g. 2.0: at the FIRST
     REVERSAL (first finished 1-min bar after the buy, once price has been
     above the buy price, that closes >= 1 x 1-min ATR below the high since
     the buy), if that high was less than 2.0 x 1-min ATR above the buy
     price, the stop moves up to the buy price (if price is already below
     it, that sells right away).

[2026-09-28] mode "smooth" -- HOLD WHILE THE TREND STAYS SMOOTH (user request after
AGEN was sold twice at its smoothest point):
  1. Starting stop: the entry's stop, at least smooth_min_stop_pct below the buy
     price (like exit B). Always active; sells on a touch.
  2. Smoothness = R^2 of the last smooth_bars (30) finished 1-min closes, signed
     by the slope (a smooth DOWN move is negative).
  3. Once smoothness reaches smooth_arm (0.8) after the buy, the trade is armed.
  4. Armed: sell the first time smoothness falls below smooth_break (0.8 / 0.6 /
     0.4 tested) -- the steady climb has broken.

Settings: config.json "exit_rules" (defaults below); "mode" picks the rule
("higher_low" = the higher-low trail above, "step_r" = stepped stop,
"smooth" = hold while smooth).
"""
from datetime import datetime, timedelta

from rules_api import ExitDecision, MarketView

NAME = "exit"

DEFAULTS = {
    "mode": "higher_low",
    "smooth_bars": 30,
    "smooth_arm": 0.8,
    "smooth_break": 0.6,
    "smooth_min_stop_pct": 1.0,
    "step_atr_mult": 2.0,
    "step_atr_period": 14,
    "step_trigger": "touch",
    "step_reversal_breakeven_atr": 0.0,
    "step_min_stop_pct": 0.0,
    "swing_minutes": 5,
    "initial_stop": "entry",
    "max_initial_risk_atr": 0.5,
    "trigger": "close",
    "buffer_cents": 1.0,
    "disaster_pct": 1.0,
}


def _buckets(bars, minutes):
    """Completed 1-min bars -> completed N-min bars [(start, low)]."""
    done = bars[:-1]            # the last 1-min bar is still forming
    if not done:
        return []
    last_end = done[-1]["t"] + timedelta(minutes=1)
    out = {}
    for b in done:
        t = b["t"]
        start = t.replace(minute=t.minute - t.minute % minutes, second=0, microsecond=0)
        lo = out.get(start)
        out[start] = b["l"] if lo is None else min(lo, b["l"])
    return [(s, lo) for s, lo in sorted(out.items()) if s + timedelta(minutes=minutes) <= last_end]


def _swing_lows(buckets):
    """(start, low) of each confirmed swing low: lower than the bar before,
    no higher than the bar after."""
    return [buckets[i] for i in range(1, len(buckets) - 1)
            if buckets[i][1] < buckets[i - 1][1] and buckets[i][1] <= buckets[i + 1][1]]


def _atr_1m(bars, period):
    """Wilder ATR over finished 1-min bars (the last bar is still forming)."""
    done = bars[:-1]
    if len(done) < 2:
        return None
    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 _step_r(view: MarketView, position: dict, state: dict, c: dict) -> ExitDecision:
    entry, stop0 = position["entry_price"], position["stop_price"]
    if c.get("step_min_stop_pct"):
        stop0 = min(stop0, entry * (1 - c["step_min_stop_pct"] / 100.0))
    R = entry - stop0
    if "stop" not in state:
        state.update(stop=stop0, steps=0, high=entry, how="entry stop")
    state["high"] = max(state["high"], view.price)
    # 1. stepped stop: +R for every full R the price has risen
    if R > 0:
        steps = int((state["high"] - entry) // R)
        if steps > state["steps"]:
            state["steps"] = steps
            new = stop0 + steps * R
            if new > state["stop"]:
                state["stop"], state["how"] = new, f"step {steps} ({'breakeven' if steps == 1 else f'+{steps - 1}R'})"
    atr = _atr_1m(view.bars, int(c["step_atr_period"]))
    # 1b. first reversal -> breakeven, if the run before it was small
    k_rev = c.get("step_reversal_breakeven_atr") or 0
    if k_rev and atr and not state.get("reversal_seen") and len(view.bars) >= 2:
        last_done = view.bars[-2]
        t_buy = datetime.fromisoformat(position["entry_time"]).replace(second=0, microsecond=0)
        if last_done["t"] > t_buy:
            state["bar_high"] = max(state.get("bar_high", entry), last_done["h"])
            if state["bar_high"] > entry and last_done["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"
    # 2. after breakeven: trail k x 1-min ATR below the high since entry
    if state["steps"] >= 1 and atr:
        trail = state["high"] - c["step_atr_mult"] * atr
        if trail > state["stop"]:
            state["stop"], state["how"] = trail, f"trail {c['step_atr_mult']}x1m-ATR ({atr:.3f})"
    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"]}
    if c["step_trigger"] == "close":
        last_done = view.bars[-2] if len(view.bars) >= 2 else None
        hit = last_done is not None and last_done["t"] >= datetime.fromisoformat(position["entry_time"]).replace(
            second=0, microsecond=0) and last_done["c"] < stop
    else:
        hit = view.price <= stop
    if hit:
        return ExitDecision(True, state="STOP", reason=f"price {view.price:.2f} hit stop {stop:.2f} ({state['how']})",
                            metrics=m)
    return ExitDecision(state="HOLD", reason=f"holding above {stop:.2f} ({state['how']})", metrics=m)


def _smoothness(closes):
    """R^2 of closes vs time, signed by the slope (+ = smooth rise, - = smooth fall)."""
    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 _smooth(view: MarketView, position: dict, state: dict, c: dict) -> ExitDecision:
    entry = position["entry_price"]
    if "stop" not in state:
        state.update(stop=min(position["stop_price"], entry * (1 - c["smooth_min_stop_pct"] / 100.0)),
                     armed=False, peak_r2=None)
    stop = state["stop"]
    t_buy = datetime.fromisoformat(position["entry_time"]).replace(second=0, microsecond=0)
    done = view.bars[:-1]                                   # finished 1-min bars
    n = int(c["smooth_bars"])
    r2 = _smoothness([b["c"] for b in done[-n:]]) if len(done) >= n else None
    since_buy = [b for b in done if b["t"] >= t_buy]
    if r2 is not None and since_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)
    m = {"stop": round(stop, 4), "smoothness": round(r2, 3) if r2 is not None else None,
         "armed": state["armed"], "peak_smoothness": state["peak_r2"]}
    if view.price <= stop:
        return ExitDecision(True, state="STOP", reason=f"price {view.price:.2f} hit stop {stop:.2f}", metrics=m)
    if state["armed"] and r2 is not None and r2 < c["smooth_break"]:
        return ExitDecision(True, state="SMOOTH_BREAK",
                            reason=f"smoothness {r2:.2f} fell below {c['smooth_break']} (trend broke)", metrics=m)
    return ExitDecision(state="HOLD", reason=("riding smooth trend" if state["armed"] else "waiting for a smooth trend")
                        + f" (smoothness {r2 if r2 is not None else float('nan'):.2f}), stop {stop:.2f}", metrics=m)


def evaluate(view: MarketView, position: dict, state: dict) -> ExitDecision:
    c = {**DEFAULTS, **(view.cfg or {})}
    if c["mode"] == "step_r":
        return _step_r(view, position, state, c)
    if c["mode"] == "smooth":
        return _smooth(view, position, state, c)
    entry = position["entry_price"]
    t_entry = datetime.fromisoformat(position["entry_time"])
    buf = c["buffer_cents"] / 100.0
    swings = _swing_lows(_buckets(view.bars, int(c["swing_minutes"])))

    if "stop" not in state:
        stop = position["stop_price"]
        how = "entry stop"
        if c["initial_stop"] == "swing":
            atr = view.levels.get("daily_atr")
            floor = entry - c["max_initial_risk_atr"] * atr if atr else stop
            before = [lo for s, lo in swings if s < t_entry and lo < entry]
            if before:
                cand = max(before[-1] - buf, floor)
                if cand < stop:
                    stop, how = cand, "swing low before entry"
        state.update(stop=stop, how=how, raised=0)

    # trail up to each new higher swing low that formed after the entry
    for s, lo in swings:
        if s >= t_entry and lo - buf > state["stop"] and lo < view.price:
            state["stop"], state["how"] = lo - buf, f"higher low {lo:.2f} ({s.strftime('%H:%M')}Z)"
            state["raised"] += 1

    stop = state["stop"]
    m = {"stop": round(stop, 4), "stop_from": state["how"], "times_raised": state["raised"]}
    if c["trigger"] == "touch":
        if view.price <= stop:
            return ExitDecision(True, state="STOP", reason=f"price {view.price:.2f} <= stop {stop:.2f} ({state['how']})",
                                metrics=m)
    else:
        last_done = view.bars[-2] if len(view.bars) >= 2 else None
        if last_done and last_done["t"] >= t_entry and last_done["c"] < stop:
            return ExitDecision(True, state="STOP",
                                reason=f"1-min close {last_done['c']:.2f} < stop {stop:.2f} ({state['how']})", metrics=m)
        if view.price <= stop * (1 - c["disaster_pct"] / 100):
            return ExitDecision(True, state="STOP", reason=f"price {view.price:.2f} far below stop {stop:.2f}", metrics=m)
    return ExitDecision(state="HOLD", reason=f"holding above {stop:.2f} ({state['how']})", metrics=m)
