"""regime_router.py — Four-dimension market regime classifier.

Dimensions:
  D1: Trend strength + direction (ADX + +/-DI)
  D2: Trend quality (EMA 9/21/50 alignment)
  D3: Normalised volatility (ATR / price)
  D4: Volume expansion (tick_volume vs 20-bar MA)

Output labels:
  STRONG_TREND   — ADX>28 + EMA aligned + volume expanding
  WEAK_TREND     — ADX>25 + EMA aligned (even without volume confirmation)
  STEALTH_TREND  — Low ADX but EMA aligned + 8+ consecutive directional bars
  FAKE_TREND     — ADX>28 but EMA messy + volume contracting (false breakout)
  RANGING        — ADX<22 + no EMA alignment + normal ATR
  VOLATILITY_SPIKE — ATR surge but ADX hasn't caught up (data/news impulse)
  EXTREME        — Day change >7% or ATR ratio >0.6%
  TRANSITION     — Any other state; no new positions allowed

Design: Read-only analysis module. No side effects, no state mutation.
        Safe for both offline backtesting and online real-time use.
"""
from __future__ import annotations
from typing import Any

from src.strategy_registry import list_compatible_strategies


# ── Internal helpers (pure functions) ───────────────────────────

def _ema(data: list[float], period: int) -> list[float | None]:
    if len(data) < period:
        return [None] * len(data)
    k = 2.0 / (period + 1)
    result: list[float | None] = [None] * (period - 1)
    result.append(sum(data[:period]) / period)
    for v in data[period:]:
        result.append(v * k + result[-1] * (1 - k))  # type: ignore[operator]
    return result


def _mean(values: list[float]) -> float:
    if not values:
        return 0.0
    return sum(values) / len(values)


def _true_range_series(bars: list[dict]) -> list[float]:
    trs: list[float] = []
    for i, bar in enumerate(bars):
        high = float(bar.get("high", 0) or 0)
        low = float(bar.get("low", 0) or 0)
        if i <= 0:
            trs.append(max(0.0, high - low))
            continue
        prev_close = float((bars[i - 1] or {}).get("close", 0) or 0)
        trs.append(max(high - low, abs(high - prev_close), abs(low - prev_close)))
    return trs


def _efficiency_ratio(closes: list[float], window: int) -> float:
    if len(closes) <= window:
        return 0.0
    segment = closes[-(window + 1):]
    net_move = abs(segment[-1] - segment[0])
    total_move = sum(abs(segment[i] - segment[i - 1]) for i in range(1, len(segment)))
    if total_move <= 1e-9:
        return 0.0
    return net_move / total_move


def _with_allowed_strategies(result: dict[str, Any], *, session_mode: str = "ALL") -> dict[str, Any]:
    regime = str(result.get("regime", "UNKNOWN") or "UNKNOWN")
    confidence = float(result.get("confidence", 0.0) or 0.0)
    result["allowed_strategies"] = list_compatible_strategies(
        regime,
        regime_confidence=confidence,
        session_mode=session_mode,
    )
    return result


_HYSTERESIS_STICKY_REGIMES = {
    "STRONG_TREND",
    "WEAK_TREND",
    "STEALTH_TREND",
    "RANGING",
    "TRANSITION",
}


def _apply_regime_hysteresis(
    result: dict[str, Any],
    *,
    prev_regime: str = "UNKNOWN",
    change_count: int = 0,
) -> dict[str, Any]:
    current_regime = str(result.get("regime", "UNKNOWN") or "UNKNOWN").upper()
    previous_regime = str(prev_regime or "UNKNOWN").upper()
    confidence = float(result.get("confidence", 0.0) or 0.0)
    if previous_regime in {"", "UNKNOWN"} or current_regime == previous_regime:
        result["change_count"] = 0
        _record_transition(previous_regime, current_regime, confidence)
        return result
    if current_regime not in _HYSTERESIS_STICKY_REGIMES or previous_regime not in _HYSTERESIS_STICKY_REGIMES:
        result["change_count"] = 0
        return result
    next_change_count = int(change_count or 0) + 1
    flags = list(result.get("flags", [])) if isinstance(result.get("flags", []), list) else []
    # Transition timeout is a deliberate fallback after prolonged ambiguity;
    # do not let hysteresis override it.
    is_timeout = "transition_timeout" in flags
    if not is_timeout and confidence < 0.62 and next_change_count < 5:
        held_result = dict(result)
        held_result["regime"] = previous_regime
        held_result["confidence"] = round(max(0.45, confidence * 0.65), 2)
        held_result["change_count"] = next_change_count
        held_result["pending_regime"] = current_regime
        held_result["pending_confidence"] = round(confidence, 2)
        held_flags = list(flags)
        held_flags.extend(
            [
                "regime_hysteresis_hold",
                f"pending={current_regime}",
                f"hold_count={next_change_count}",
            ]
        )
        held_result["flags"] = held_flags
        held_result["allowed_strategies"] = list_compatible_strategies(
            previous_regime,
            regime_confidence=float(held_result["confidence"]),
            session_mode="ALL",
        )
        return held_result

    result["change_count"] = 0
    _record_transition(previous_regime, current_regime, confidence)
    if next_change_count > 0:
        flags = list(result.get("flags", [])) if isinstance(result.get("flags", []), list) else []
        flags.extend(
            [
                "regime_hysteresis_commit",
                f"prev={previous_regime}",
                f"count={next_change_count}",
            ]
        )
        result["flags"] = flags
    return result


def _compute_adx_di(bars: list[dict], period: int = 14) -> dict[str, Any]:
    """Compute ADX with +/-DI directional components."""
    n = len(bars)
    if n < period + 1:
        return {"adx": 20.0, "plus_di": 25.0, "minus_di": 25.0, "adx_slope": 0.0, "di_diff": 0.0}

    tr_list: list[float] = []
    plus_dm: list[float] = []
    minus_dm: list[float] = []
    for i in range(1, n):
        b, pb = bars[i], bars[i - 1]
        hl = float(b.get("high", 0) or 0) - float(pb.get("high", 0) or 0)
        lh = float(pb.get("low", 0) or 0) - float(b.get("low", 0) or 0)
        plus_dm.append(max(hl, 0) if hl > lh else 0)
        minus_dm.append(max(lh, 0) if lh > hl else 0)
        tr_list.append(max(
            float(b.get("high", 0) or 0) - float(b.get("low", 0) or 0),
            abs(float(b.get("high", 0) or 0) - float(pb.get("close", 0) or 0)),
            abs(float(b.get("low", 0) or 0) - float(pb.get("close", 0) or 0)),
        ))

    atr_vals = _ema(tr_list, period)
    ema_plus = _ema(plus_dm, period)
    ema_minus = _ema(minus_dm, period)

    di_plus: list[float | None] = []
    di_minus: list[float | None] = []
    dx: list[float | None] = []
    for i in range(len(atr_vals)):
        av = atr_vals[i]
        ep = ema_plus[i] if i < len(ema_plus) else None
        em = ema_minus[i] if i < len(ema_minus) else None
        if av is None or av == 0:
            di_plus.append(None); di_minus.append(None); dx.append(None)
            continue
        dp = (100 * ep / av) if ep is not None else None
        dm = (100 * em / av) if em is not None else None
        di_plus.append(dp); di_minus.append(dm)
        if dp is not None and dm is not None and (dp + dm) > 0:
            dx.append(100 * abs(dp - dm) / (dp + dm))
        else:
            dx.append(None)

    valid_dx = [v for v in dx if v is not None]
    adx_ema = _ema(valid_dx, period)
    adx = float(adx_ema[-1]) if adx_ema and adx_ema[-1] is not None else 20.0
    last_plus = next((v for v in reversed(di_plus) if v is not None), 25.0) or 25.0
    last_minus = next((v for v in reversed(di_minus) if v is not None), 25.0) or 25.0

    adx_slope = float(valid_dx[-1] - valid_dx[-6]) if len(valid_dx) >= 6 else 0.0

    return {
        "adx": round(adx, 1),
        "plus_di": round(last_plus, 1),
        "minus_di": round(last_minus, 1),
        "adx_slope": round(adx_slope, 1),
        "di_diff": round(last_plus - last_minus, 1),
    }


# ── Main classifier ─────────────────────────────────────────────



def _record_transition(from_r: str, to_r: str, confidence: float) -> None:
    try:
        from src.learning.trading_brain import get_brain
        get_brain().record_regime_transition(from_r, to_r, confidence)
    except Exception:
        pass


_SESSION_ADJUSTMENTS = {
    "ASIAN":    {"adx_mult": 0.85, "stealth_consec": 6, "efficiency_floor": 0.20},
    "LONDON":   {"adx_mult": 1.00, "stealth_consec": 4, "efficiency_floor": 0.24},
    "NEW_YORK": {"adx_mult": 1.05, "stealth_consec": 4, "efficiency_floor": 0.26},
    "OVERLAP":  {"adx_mult": 1.10, "stealth_consec": 3, "efficiency_floor": 0.28},
}


def _second_opinion(bars: list[dict], current_regime: str) -> str:
    closes = [float(b.get("close", 0) or 0) for b in bars if b.get("close")]
    if len(closes) < 41:
        return "AGREE"
    adx_info = _compute_adx_di(bars, period=10)
    adx = float(adx_info["adx"])
    di_diff = float(adx_info["di_diff"])
    e8 = _ema(closes, 8)[-1]
    e20 = _ema(closes, 20)[-1]
    e40 = _ema(closes, 40)[-1]
    bullish = e8 and e20 and e40 and e8 > e20 > e40
    bearish = e8 and e20 and e40 and e8 < e20 < e40
    long_eff = _efficiency_ratio(closes, 16)
    is_trend = (adx >= 22 and (bullish or bearish) and abs(di_diff) > 4) or (adx >= 25 and long_eff > 0.20)
    is_range = adx < 19 and not bullish and not bearish
    cr = str(current_regime or "").upper()
    if is_trend and cr in ("STRONG_TREND", "WEAK_TREND", "STEALTH_TREND"): return "AGREE"
    if is_range and cr in ("RANGING",): return "AGREE"
    if not is_trend and not is_range and cr in ("TRANSITION", "FAKE_TREND", "VOLATILITY_SPIKE"): return "AGREE"
    return "CONFLICT"


def _detect_trend_exhaustion(bars: list[dict], adx_slope: float, closes: list[float], volumes: list[float]) -> dict:
    c1 = float(adx_slope or 0) < -1.0
    c2 = False
    if len(volumes) >= 10:
        rv = sum(volumes[-5:]) / 5
        pv = sum(volumes[-10:-5]) / 5
        c2 = pv > 0 and rv < pv * 0.8
    c3 = False
    if len(bars) >= 10:
        valid = [b for b in bars if b.get("high") and b.get("low")]
        if len(valid) >= 10:
            rr = [float(b["high"]) - float(b["low"]) for b in valid[-5:]]
            pr = [float(b["high"]) - float(b["low"]) for b in valid[-10:-5]]
            c3 = sum(pr) > 0 and sum(rr) / 5 < sum(pr) / 5 * 0.75
    cnt = sum([c1, c2, c3])
    return {"trend_exhaustion": cnt >= 2, "exhaustion_level": "confirmed" if cnt == 3 else "early" if cnt >= 2 else "none"}


def classify_regime(
    bars: list[dict],
    session: str = "",
    prev_regime: str = "UNKNOWN",
    transition_count: int = 0,
    change_count: int = 0,
) -> dict[str, Any]:
    """Classify the current market regime from OHLCV bars.

    Args:
        bars: List of OHLCV dicts with keys {open, high, low, close, tick_volume, time}.
              Minimum 50 bars recommended. Uses M5 bars for all calculations.
        prev_regime: Previous regime label (for transition timeout tracking).
        transition_count: Number of consecutive TRANSITION bars seen so far.
        change_count: Number of consecutive cycles trying to flip away from prev_regime.

    Returns:
        dict with keys:
          regime             — label string (see module docstring)
          confidence         — 0.0–1.0, how confident the classification is
          transition_count   — next transition counter
          change_count       — next regime-change persistence counter
          weight_profile     — optional informational weights
          flags              — diagnostic flags for logging
    """
    def _read_interpreter_subtype() -> str:
        try:
            from src.swarm.blackboard import global_blackboard
            if global_blackboard:
                mi = global_blackboard.get("Market_Interpreter", {})
                if isinstance(mi, dict):
                    subtype = mi.get("regime_subtype", "")
                    if subtype and str(subtype).strip():
                        return str(subtype).strip()
        except Exception:
            pass
        return ""

    def _finish(payload: dict[str, Any]) -> dict[str, Any]:
        result = _apply_regime_hysteresis(
            _with_allowed_strategies(payload),
            prev_regime=prev_regime,
            change_count=change_count,
        )
        # llm_subtype from Market Interpreter
        subtype = _read_interpreter_subtype()
        if subtype:
            result["llm_subtype"] = subtype
        return result

    n = len(bars)
    if n < 50:
        return _finish({
            "regime": "UNKNOWN", "confidence": 0.30,
            "transition_count": int(transition_count or 0), "weight_profile": {},
            "flags": ["insufficient_data", f"bars={n}"],
        })

    # Filter out bars with invalid close prices (None/0/negative) before processing
    valid_bars = [b for b in bars if b.get("close") and float(b.get("close", 0) or 0) > 0]
    if len(valid_bars) < 20:
        return _finish({"regime": "TRANSITION", "confidence": 0.30,
                "transition_count": int(transition_count or 0) + 1, "weight_profile": {},
                "flags": ["invalid_bar_data", f"valid_bars={len(valid_bars)}"]})
    bars = valid_bars
    closes = [float(b.get("close", 0) or 0) for b in bars]
    volumes = [float(b.get("tick_volume", 0) or 0) for b in bars]
    current_price = closes[-1]

    # ── D1: ADX + Direction ──
    adx_info = _compute_adx_di(bars)
    adx = float(adx_info["adx"])
    adj = _SESSION_ADJUSTMENTS.get(session, {})
    adx = round(adx * adj.get("adx_mult", 1.0), 1)
    adx_slope = float(adx_info["adx_slope"])
    di_diff = float(adx_info["di_diff"])

    # ── D2: EMA Alignment ──
    e9_list = _ema(closes, 9)
    e21_list = _ema(closes, 21)
    e50_list = _ema(closes, 50)
    e9 = e9_list[-1]
    e21 = e21_list[-1]
    e50 = e50_list[-1]

    bullish_aligned = (e9 is not None and e21 is not None and e50 is not None
                       and e9 > e21 > e50)
    bearish_aligned = (e9 is not None and e21 is not None and e50 is not None
                       and e9 < e21 < e50)
    ema_clean = bullish_aligned or bearish_aligned

    # ── D3: Normalised Volatility ──
    true_ranges = _true_range_series(bars)
    atr14 = _mean(true_ranges[-14:])
    atr50 = _mean(true_ranges[-50:])
    atr_ratio = atr14 / max(0.01, current_price)
    atr_expansion = atr14 / max(0.01, atr50)

    # ── D4: Volume Expansion ──
    avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 1.0
    recent_vol = sum(volumes[-5:]) / 5 if len(volumes) >= 5 else 1.0
    vol_expanding = recent_vol > avg_vol_20 * 1.3
    vol_contracting = recent_vol < avg_vol_20 * 0.7

    # ── Consecutive directional bars (stealth trend detection) ──
    same_dir_count = 0
    if len(closes) >= 13:
        target_dir = 1 if closes[-1] > closes[-2] else -1
        for i in range(len(closes) - 2, max(0, len(closes) - 13), -1):
            if (closes[i + 1] - closes[i]) * target_dir > 0:
                same_dir_count += 1
            else:
                break

    displacement_ratio = abs(closes[-1] - closes[-9]) / atr14 if atr14 > 0 and len(closes) >= 9 else 0.0
    displacement_long = abs(closes[-1] - closes[-21]) / atr14 if atr14 > 0 and len(closes) >= 21 else displacement_ratio
    directional_efficiency = _efficiency_ratio(closes, 12)
    short_efficiency = _efficiency_ratio(closes, 6)
    medium_efficiency = _efficiency_ratio(closes, 24)
    long_efficiency = _efficiency_ratio(closes, 50)
    ema_gap_fast = abs((e9 or current_price) - (e21 or current_price)) / max(atr14, 0.01)
    ema_gap_slow = abs((e21 or current_price) - (e50 or current_price)) / max(atr14, 0.01)
    ema_alignment_strength = ema_gap_fast + ema_gap_slow

    # ── Extreme market check ──
    day_bars = bars[-288:] if len(bars) >= 288 else bars
    if len(day_bars) >= 2:
        day_first = float(day_bars[0].get("close", 0) or 0)
        day_last = float(day_bars[-1].get("close", 0) or 0)
        day_change_pct = abs(day_last - day_first) / max(0.01, day_first) * 100
    else:
        day_change_pct = 0.0

    # VOLATILITY_SPIKE: instrument-agnostic volatility shock without clean structure.
    if atr_expansion >= 1.9 and not ema_clean and directional_efficiency < 0.26:
        return _finish({
            "regime": "VOLATILITY_SPIKE", "confidence": 0.55,
            "transition_count": 0, "weight_profile": {},
            "atr_ratio": round(atr_ratio, 6),
            "flags": ["atr_spike", f"atr_expansion={atr_expansion:.2f}",
                      f"atr_ratio={atr_ratio:.4f}", f"adx={adx:.0f}"],
        })

    # ── Classification ──
    di_direction = "BULLISH" if di_diff > 2 else "BEARISH" if di_diff < -2 else "NEUTRAL"

    # FAKE_TREND: low commitment breakout / fakeout with collapsing participation.
    if adx >= 24 and vol_contracting and directional_efficiency < 0.23 and short_efficiency < 0.25 and displacement_long < 3.2:
        return _finish({
            "regime": "FAKE_TREND", "confidence": 0.65,
            "transition_count": 0, "weight_profile": {},
            "flags": ["adx_high", "vol_contracting",
                      "ema_messy" if not ema_clean else "ema_suspect",
                      f"adx={adx:.0f}", f"eff={directional_efficiency:.2f}",
                      f"disp21={displacement_long:.2f}"],
        })
    if (
        atr_expansion >= 1.05
        and directional_efficiency < 0.20
        and medium_efficiency < 0.10
        and long_efficiency < 0.08
        and 0.8 <= displacement_ratio <= 1.4
        and displacement_long < 1.6
    ):
        return _finish({
            "regime": "FAKE_TREND", "confidence": 0.60,
            "transition_count": 0, "weight_profile": {},
            "flags": ["low_commitment_breakout", f"atr_expansion={atr_expansion:.2f}",
                      f"eff={directional_efficiency:.2f}", f"eff24={medium_efficiency:.2f}",
                      f"eff50={long_efficiency:.2f}", f"disp={displacement_ratio:.2f}"],
        })

    # EMA-anchored low-vol trend: preserve bearish/bullish aligned structures that do not
    # travel far enough in the short window to beat the broad ranging rule.
    if (
        ema_clean
        and adx >= 23
        and abs(di_diff) >= 8
        and 1.5 <= displacement_long < 3.0
        and long_efficiency < 0.30
    ):
        return _finish({
            "regime": "WEAK_TREND", "confidence": 0.58,
            "transition_count": 0,
            "weight_profile": {"trend": 0.55, "mean_reversion": 0.20, "hybrid": 0.25},
            "flags": ["ema_anchored_trend", f"adx={adx:.0f}", f"di={di_diff:.1f}",
                      f"disp21={displacement_long:.2f}", f"eff50={long_efficiency:.2f}"],
        })

    # Range efficiency guard: catch wide/narrow ranges where price is bounded
    # and inefficient over the long window, even if short-term EMAs have
    # incidental alignment due to lag or ADX reads high from whipsaw. This
    # runs before trend detection so ranging markets are not misclassified
    # as weak/strong trends.
    if (
        long_efficiency < 0.32
        and displacement_ratio < 1.2
        and not vol_expanding
        and (
            displacement_long < 3.0
            or (adx >= 25 and medium_efficiency < 0.65)
        )
    ):
        return _finish({
            "regime": "RANGING", "confidence": 0.70,
            "transition_count": 0,
            "weight_profile": {"mean_reversion": 0.68, "grid": 0.20, "hybrid": 0.12},
            "flags": ["range_efficiency_guard",
                      f"adx={adx:.0f}", f"eff50={long_efficiency:.2f}",
                      f"disp={displacement_ratio:.2f}", f"disp21={displacement_long:.2f}"],
        })

    # ── TREND DETECTION FIRST (specific → general) ──
    # Trend conditions are checked before ranging because a market with
    # directional bias (even with pullbacks) is a trend, not a range.
    # RANGING is the fallback when no trend pattern matches.

    # STRONG_TREND: allow strong-trend recognition after brief pullbacks by using a longer window.
    if ema_clean and ema_alignment_strength >= 0.45 and (
        long_efficiency >= 0.45 or (
            long_efficiency >= 0.30
            and ((displacement_ratio >= 1.6 and directional_efficiency >= 0.32) or (displacement_long >= 5.0 and medium_efficiency >= 0.55))
        ) or (
            displacement_long >= 3.0
            and adx >= 27
            and abs(di_diff) >= 10
            and recent_vol >= avg_vol_20
        )
    ) and (adx >= 25 or adx_slope > 4) and (
        vol_expanding
        or atr_expansion >= 1.05
        or medium_efficiency >= 0.60
        or (long_efficiency >= 0.45 and abs(di_diff) >= 10 and recent_vol >= avg_vol_20)
    ):
        direction = "BULLISH" if bullish_aligned else "BEARISH"
        return _finish({
            "regime": "STRONG_TREND", "confidence": 0.85,
            "transition_count": 0,
            "weight_profile": {"trend": 0.82, "pullback": 0.18},
            "flags": ["strong_trend", f"direction={direction}",
                      "vol_expanding" if vol_expanding else "atr_expanding",
                      f"adx={adx:.0f}", f"eff={directional_efficiency:.2f}",
                      f"disp={displacement_ratio:.2f}", f"disp21={displacement_long:.2f}",
                      f"eff24={medium_efficiency:.2f}", f"eff50={long_efficiency:.2f}"],
        })

    # WEAK_TREND: EMA clean + directional evidence (primary path).
    if (
        ema_clean and displacement_ratio >= 0.60 and directional_efficiency >= 0.24 and (
            adx >= 20 or short_efficiency >= 0.40 or ema_alignment_strength >= 0.55 or displacement_long >= 3.0
        )
    ) or (
        not ema_clean and adx >= 24 and displacement_long >= 3.0 and directional_efficiency >= 0.28 and short_efficiency >= 0.28
    ):
        return _finish({
            "regime": "WEAK_TREND", "confidence": 0.70,
            "transition_count": 0,
            "weight_profile": {"trend": 0.60, "mean_reversion": 0.25, "hybrid": 0.15},
            "flags": ["weak_trend", f"adx={adx:.0f}",
                      f"adx_slope={adx_slope:+.1f}", f"eff={directional_efficiency:.2f}",
                      f"disp={displacement_ratio:.2f}", f"disp21={displacement_long:.2f}",
                      f"eff50={long_efficiency:.2f}"],
        })

    # STEALTH_TREND: Low ADX but EMA aligned + persistent same-direction closes.
    if adx <= 22 and ema_clean and same_dir_count >= 5 and displacement_ratio >= 1.35 and directional_efficiency >= 0.30:
        direction = "BULLISH" if closes[-1] > closes[-12] else "BEARISH"
        return _finish({
            "regime": "STEALTH_TREND", "confidence": 0.60,
            "transition_count": 0,
            "weight_profile": {"trend": 0.60, "pullback": 0.40},
            "flags": ["stealth_trend", f"direction={direction}",
                      f"consec={same_dir_count}", f"adx={adx:.0f}",
                      f"eff={directional_efficiency:.2f}"],
        })

    # Range containment trap: price is oscillating inside a tight channel
    # even though short-term EMAs may be aligned due to lag. Treat as RANGING
    # before the EMA-clean catch-all can misclassify it as a weak trend.
    if len(closes) >= 20 and atr14 > 0:
        _mid = _mean(closes[-20:])
        _max_dev = max(abs(c - _mid) for c in closes[-20:]) / atr14
        if _max_dev <= 1.5 and adx < 20 and long_efficiency < 0.25:
            return _finish({
                "regime": "RANGING", "confidence": 0.72,
                "transition_count": 0,
                "weight_profile": {"mean_reversion": 0.68, "grid": 0.20, "hybrid": 0.12},
                "flags": ["range_containment_trap", f"max_dev={_max_dev:.2f}",
                          f"adx={adx:.0f}", f"eff50={long_efficiency:.2f}"],
            })

    # EMA-clean catch-all: EMA alignment is the single strongest signal that a
    # market is trending.  Even low-displacement EMA-aligned markets are weak
    # trends, not ranges — classifying them as TRANSITION/RANGING creates a
    # death spiral where the system can never re-detect the trend.
    if ema_clean and displacement_ratio >= 0.55 and directional_efficiency >= 0.20:
        direction = "BULLISH" if bullish_aligned else "BEARISH"

        # Require at least one confirming factor beyond marginal EMA alignment.
        # EMA alone can align in tight ranges (lag effects); a genuine weak trend
        # should show other signs: displacement, short-term efficiency, or volume.
        _has_confirming = (
            adx >= 18                          # trend strength confirms alignment
            or short_efficiency >= 0.30        # 6-bar efficiency shows clear bias
            or displacement_long >= 1.5        # price moved >1.5 ATR in 21 bars
        )
        if not _has_confirming:
            # Marginal EMA alignment alone = likely noise in a range.
            # Classify as TRANSITION rather than WEAK_TREND to avoid
            # triggering trend strategies on non-existent trends.
            return _finish({
                "regime": "TRANSITION", "confidence": 0.45,
                "transition_count": int(transition_count or 0) + 1,
                "weight_profile": {"hybrid": 0.30, "cash": 0.70},
                "flags": ["ema_aligned_no_confirm", f"direction={direction}",
                          f"eff={directional_efficiency:.2f}",
                          f"disp={displacement_ratio:.2f}", f"adx={adx:.0f}"],
            })

        conf = 0.55
        if ema_alignment_strength >= 0.25 or short_efficiency >= 0.28:
            conf = 0.58
        if displacement_long >= 2.5:
            conf = 0.62
        return _finish({
            "regime": "WEAK_TREND", "confidence": conf,
            "transition_count": 0,
            "weight_profile": {"trend": 0.42, "mean_reversion": 0.28, "hybrid": 0.30},
            "flags": ["ema_clean_early_trend", f"direction={direction}",
                      f"adx={adx:.0f}", f"eff={directional_efficiency:.2f}",
                      f"align={ema_alignment_strength:.2f}", f"disp={displacement_ratio:.2f}",
                      f"disp21={displacement_long:.2f}", f"eff50={long_efficiency:.2f}"],
        })

    # ── RANGING (fallback: no trend pattern matched) ──
    # Only classified as ranging when ALL trend checks above failed AND EMA is
    # not clean.  EMA-aligned markets are categorically trending — low
    # displacement in an EMA-aligned market is a grinding trend, not a range.
    if not ema_clean and adx < 22 and (
        (
            displacement_ratio < 0.75
            and long_efficiency < 0.35
            and same_dir_count <= 1
            and not vol_expanding
            and atr_expansion < 1.55
        ) or (
            displacement_ratio < 0.75
            and long_efficiency < 0.30
            and short_efficiency < 0.35
            and atr_expansion < 1.55
        )
    ):
        return _finish({
            "regime": "RANGING", "confidence": 0.78,
            "transition_count": 0,
            "weight_profile": {"mean_reversion": 0.68, "grid": 0.20, "hybrid": 0.12},
            "atr_ratio": round(atr_ratio, 6),
            "flags": ["ranging", f"adx={adx:.0f}", f"atr_expansion={atr_expansion:.2f}",
                      f"disp={displacement_ratio:.2f}", f"disp21={displacement_long:.2f}",
                      f"eff={directional_efficiency:.2f}", f"eff50={long_efficiency:.2f}"],
        })

    # ── Remaining edge cases ──

    # EMA-clean but no trend pattern matched above: the market has directional
    # structure but lacks enough displacement or efficiency to be called a trend.
    # Classify as TRANSITION and let the next cycle re-evaluate naturally.
    # The TRANSITION timeout below will NOT force RANGING when EMA is clean,
    # so there is no death spiral — the market will re-enter a trend label as
    # soon as displacement / efficiency improves.
    if ema_clean:
        direction = "BULLISH" if bullish_aligned else "BEARISH"
        return _finish({
            "regime": "TRANSITION", "confidence": 0.48,
            "transition_count": 0,
            "weight_profile": {"trend": 0.35, "mean_reversion": 0.35, "hybrid": 0.30},
            "flags": ["ema_clean_transition", f"direction={direction}",
                      f"adx={adx:.0f}", f"consec={same_dir_count}",
                      f"disp={displacement_ratio:.2f}", f"eff={directional_efficiency:.2f}"],
        })

    if day_change_pct > 7.0 or (
        (day_change_pct > 5.0 or (atr_expansion >= 2.6 and atr_ratio > 0.0035))
        and (not ema_clean or directional_efficiency < 0.25)
    ):
        return _finish({
            "regime": "EXTREME", "confidence": 0.90,
            "transition_count": 0, "weight_profile": {},
            "flags": ["extreme_market",
                      f"day_change={day_change_pct:.1f}%",
                      f"atr_ratio={atr_ratio:.4f}",
                      f"atr_expansion={atr_expansion:.2f}"],
        })

    next_transition_count = int(transition_count or 0) + 1 if prev_regime in ("UNKNOWN", "TRANSITION") else 1
    if next_transition_count >= 8:  # 8 × M5 = 40 minutes
        # If EMA is aligned, the market has directional structure — force a
        # low-confidence trend instead of parking it in RANGING permanently.
        if ema_clean:
            direction = "BULLISH" if bullish_aligned else "BEARISH"
            return _finish({
                "regime": "WEAK_TREND", "confidence": 0.50,
                "transition_count": 0,
                "weight_profile": {"trend": 0.35, "mean_reversion": 0.25, "hybrid": 0.40},
                "flags": ["transition_timeout_ema_trend", f"direction={direction}",
                          f"count={transition_count}", f"adx={adx:.0f}",
                          f"disp21={displacement_long:.2f}"],
            })
        return _finish({
            "regime": "RANGING", "confidence": 0.40,
            "transition_count": 0,
            "weight_profile": {"mean_reversion": 0.55, "hybrid": 0.15, "cash": 0.30},
            "flags": ["transition_timeout", f"count={transition_count}"],
        })

    return _finish({
        "regime": "TRANSITION", "confidence": 0.35,
        "transition_count": next_transition_count,
        "weight_profile": {"hybrid": 0.40, "cash": 0.60},
        "atr_ratio": round(atr_ratio, 6),
        "flags": ["transition", f"adx={adx:.0f}",
                  f"adx_slope={adx_slope:+.1f}", f"count={transition_count}"],
    })


# ── Range Loosening Detector (M1 fast-path) ─────────────────────

def detect_range_loosening(m1_bars: list[dict]) -> dict[str, Any]:
    """Detect whether a ranging market is being broken by short-term momentum.

    Uses M1 bars (≥15) to check three conditions that must ALL be met:
      C1: Directional consistency — bull/bear candle ratio ≥67% or ≤33%
      C2: Net displacement — |close[-1] - close[-15]| ≥ 1.0 × ATR(M1,14)
      C3: Candle body ratio — avg(|close-open|/|high-low|) ≥ 45%

    Returns a dict with the same shape as classify_regime() for downstream
    compatibility: {loosening, direction, confidence, flags}.
    """
    n = len(m1_bars)
    if n < 15:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["insufficient_m1_bars", f"bars={n}"]}

    valid = [b for b in m1_bars if b.get("close") and float(b.get("close", 0) or 0) > 0]
    if len(valid) < 15:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["invalid_m1_bar_data", f"valid={len(valid)}"]}

    recent = valid[-15:]
    closes = [float(b.get("close", 0) or 0) for b in recent]

    # ── C1: Directional consistency ──
    bull_count = 0
    bear_count = 0
    body_ratios: list[float] = []
    for b in recent:
        o = float(b.get("open", 0) or 0)
        c = float(b.get("close", 0) or 0)
        h = float(b.get("high", 0) or 0)
        l = float(b.get("low", 0) or 0)
        if c > o:
            bull_count += 1
        elif c < o:
            bear_count += 1
        hl_range = h - l
        if hl_range > 0:
            body_ratios.append(abs(c - o) / hl_range)

    total_directional = bull_count + bear_count
    if total_directional == 0:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c1_fail", "no_directional_candles"]}

    bull_pct = bull_count / total_directional
    if bull_pct >= 0.67:
        c1_direction = "BULLISH"
        c1_pass = True
    elif bull_pct <= 0.33:
        c1_direction = "BEARISH"
        c1_pass = True
    else:
        c1_direction = "NEUTRAL"
        c1_pass = False

    if not c1_pass:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c1_fail", f"bull_pct={bull_pct:.2f}"]}

    # ── C2: Net displacement ≥ 1.0 × ATR(M1,14) ──
    trs = _true_range_series(recent)
    atr_m1 = _mean(trs[-14:]) if len(trs) >= 14 else _mean(trs)
    if atr_m1 <= 0:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c2_fail", "atr_zero"]}

    net_displacement = abs(closes[-1] - closes[0])
    displacement_atr = net_displacement / atr_m1

    if displacement_atr < 1.0:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c2_fail", f"disp_atr={displacement_atr:.2f}"]}

    # ── C3: Average candle body ratio ≥ 45% ──
    if not body_ratios:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c3_fail", "no_body_ratios"]}
    avg_body_ratio = _mean(body_ratios)
    if avg_body_ratio < 0.45:
        return {"loosening": False, "direction": "NEUTRAL",
                "confidence": 0.0, "flags": ["c3_fail", f"avg_body={avg_body_ratio:.2f}"]}

    # ── All conditions met ──
    c123_confidence = round(min(0.75, 0.45 + displacement_atr * 0.12 + avg_body_ratio * 0.18), 2)
    return {
        "loosening": True,
        "direction": c1_direction,
        "confidence": c123_confidence,
        "flags": ["range_loosening", f"dir={c1_direction}",
                  f"bull_pct={bull_pct:.2f}", f"disp_atr={displacement_atr:.2f}",
                  f"avg_body={avg_body_ratio:.2f}"],
    }
