"""
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

Settings: config.json "exit_rules" (defaults below).
"""
from datetime import datetime, timedelta

from rules_api import ExitDecision, MarketView

NAME = "exit"

DEFAULTS = {
    "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 evaluate(view: MarketView, position: dict, state: dict) -> ExitDecision:
    c = {**DEFAULTS, **(view.cfg or {})}
    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)
