"""
exit_velocity_experimental.py

[2026-09-19] NOT wired into monitor.py's live loop -- backtest-only.
Built from the cross-symbol peak-pattern analysis run against the full
2026-09-18 session (all 30 candidates, not just the 9 the bot actually
traded): neither imbalance level at a stalled peak nor imbalance flipping
negative afterward showed any real discriminating power between the 161
failed-breakout events and the 275 that recovered (52-73% recovery,
non-monotonic, across every imbalance bucket tested) -- directly
undermining the assumption exit_prf_experimental.py and
exit_resistance_timer_experimental.py were both built on. The one signal
that DID show a clean, monotonic, statistically significant gradient
(ratio>=1.0 vs <1.0: 73.0% recovery n=204 vs 54.3% n=232, p~0.00005) was
the ratio of trade participation (buy+sell volume) in the 90s after a
stall vs the 90s before it -- rising participation predicts recovery,
fading participation predicts failure.

This does NOT explain GLOO itself (participation ratio 0.92, imbalance
+73% at its own real top -- looked completely healthy right up to the
reversal), so it is not being sold as "the fix." It's a different SHAPE
of signal than everything tried so far, per the explicit design goal
this was built to: PRF and the resistance-timer both wait a FIXED time
window (poll-count confirm reads, or a flat 60-120s clock) regardless of
how fast or slow price is actually giving back. This module instead
reacts to the RATE of the post-peak decline (a short trailing-window
price velocity, independent of how long the stall has been running) --
matching the one clear practitioner rule the accompanying web research
turned up for this exact discrimination problem: a fast snapback is a
failure, a move that holds/builds is a genuine pause -- gated by the
participation-ratio finding above so a merely-noisy fast wiggle with
volume already picking back up doesn't fire.

Single-read trigger once both conditions are true (no confirm-read
hysteresis layered on top, deliberately -- the whole point is reacting
faster than PRF/resistance-timer's wait-N-reads designs), using
stream.py's 30s sub-bars (get_bars_sub()) for both the velocity
reference price and the participation-ratio volumes, same buffer
fast_slope_exit.py already reads.
"""

from dataclasses import dataclass, field

STATE_HOLD = "HOLD"
STATE_EXIT = "EXIT"

DEFAULT_CONFIG = {
    "min_mfe_pct": 3.0,                  # same "after +3% MFE" arm gate PRF/resistance-timer use
    "velocity_lookback_buckets": 1,      # 30s sub-bars back for the reference price (~30s trailing velocity)
    "min_decline_velocity_pct_per_min": 6.0,   # UNCALIBRATED -- see simulate.py sweep results before trusting
    "participation_window_buckets": 3,   # ~90s each side at 30s buckets, matches the analysis's own window
    "max_participation_ratio_to_exit": 1.0,    # only fire while participation is flat/fading, not picking back up
}


@dataclass
class VelocityDecision:
    symbol: str
    state: str = STATE_HOLD
    should_exit: bool = False
    reason: str = ""
    metrics: dict = field(default_factory=dict)
    state_out: dict = field(default_factory=dict)


def _merge_cfg(cfg):
    return {**DEFAULT_CONFIG, **(cfg or {})}


def evaluate(symbol: str, bars: list, sub_bars: list, entry_price: float,
             state_in: dict, cfg: dict = None) -> VelocityDecision:
    """
    bars: same short rolling 1-min-bar window exit.py/PRF take.
    sub_bars: stream.SymbolBuffer.get_bars_sub() -- 30s-bucket bars,
        oldest first, each {"t","o","h","l","c","v",...}.
    state_in: caller-held state fed back every poll -- {"peak_price"}.
        Fresh {} at entry. No confirm-count/stage tracking needed --
        this is a memoryless single-read trigger past the peak itself.
    """
    cfg = _merge_cfg(cfg)
    state_in = dict(state_in or {})

    need_buckets = max(cfg["velocity_lookback_buckets"], cfg["participation_window_buckets"] * 2) + 1
    if len(bars) < 3 or len(sub_bars) < need_buckets:
        return VelocityDecision(symbol=symbol, reason="insufficient sub-bar history", state_out=state_in)

    price = bars[-1]["c"]
    prior_peak = state_in.get("peak_price", price)
    peak_price = max(prior_peak, price)
    made_new_high = price > prior_peak

    mfe_pct = (peak_price - entry_price) / entry_price * 100.0 if entry_price else 0.0
    armed = mfe_pct >= cfg["min_mfe_pct"]

    base_state = {"peak_price": peak_price}
    metrics = {"price": price, "peak_price": round(peak_price, 4), "mfe_pct": round(mfe_pct, 3),
               "armed": armed, "made_new_high": made_new_high}

    if not armed:
        return VelocityDecision(symbol=symbol, state=STATE_HOLD,
                                 reason=f"not armed yet (MFE {mfe_pct:.2f}% < {cfg['min_mfe_pct']}%)",
                                 metrics=metrics, state_out=base_state)

    if made_new_high:
        return VelocityDecision(symbol=symbol, state=STATE_HOLD,
                                 reason=f"making new highs (${price:.4f})", metrics=metrics,
                                 state_out=base_state)

    # -- decline velocity: price now vs a short trailing reference, independent
    # of how long price has been stalled -- reacts to a sudden sharp drop
    # immediately rather than waiting out a fixed clock like the resistance-timer.
    ref_bar = sub_bars[-1 - cfg["velocity_lookback_buckets"]]
    ref_price = ref_bar["c"]
    lookback_seconds = cfg["velocity_lookback_buckets"] * 30.0
    velocity_pct = (ref_price - price) / ref_price * 100.0 if ref_price else 0.0
    velocity_pct_per_min = velocity_pct / (lookback_seconds / 60.0) if lookback_seconds else 0.0

    # -- participation ratio: total buy+sell volume in the trailing window vs
    # the window before that (both via 30s sub-bars, no reliance on
    # SymbolBuffer's own 90s-capped raw trade log so this works regardless of
    # how long ago the peak itself was).
    w = cfg["participation_window_buckets"]
    recent_vol = sum(b["v"] for b in sub_bars[-w:])
    prior_vol = sum(b["v"] for b in sub_bars[-2 * w:-w]) if len(sub_bars) >= 2 * w else 0.0
    participation_ratio = (recent_vol / prior_vol) if prior_vol > 0 else None

    metrics.update({
        "velocity_pct_per_min": round(velocity_pct_per_min, 2),
        "recent_vol": round(recent_vol, 1), "prior_vol": round(prior_vol, 1),
        "participation_ratio": round(participation_ratio, 3) if participation_ratio is not None else None,
    })

    decline_fast_enough = velocity_pct_per_min >= cfg["min_decline_velocity_pct_per_min"]
    # Fails open (doesn't exit) if there isn't enough history to compute the
    # ratio yet -- same fail-open convention as PRF's cold-imbalance-read case.
    participation_not_elevated = (participation_ratio is not None
                                   and participation_ratio < cfg["max_participation_ratio_to_exit"])

    if decline_fast_enough and participation_not_elevated:
        return VelocityDecision(
            symbol=symbol, state=STATE_EXIT, should_exit=True,
            reason=(f"fast decline off peak ${peak_price:.4f}: {velocity_pct_per_min:.1f}%/min over "
                    f"{lookback_seconds:.0f}s, participation ratio {participation_ratio:.2f} "
                    f"(not picking back up)"),
            metrics=metrics, state_out=base_state)

    return VelocityDecision(
        symbol=symbol, state=STATE_HOLD,
        reason=(f"off peak ${peak_price:.4f}: velocity {velocity_pct_per_min:.1f}%/min "
                f"(need >={cfg['min_decline_velocity_pct_per_min']}), participation ratio "
                f"{participation_ratio if participation_ratio is not None else 'n/a'} "
                f"(need <{cfg['max_participation_ratio_to_exit']})"),
        metrics=metrics, state_out=base_state)
