"""
APEX V16 — Brain Mean Reversion.

Migrated from V15's Brainmr.py. The TRADING RULES are unchanged:
  - Entry only on RSI extremes (BUY <32, SELL >68)
  - ATR ratio cap (atr_ratio > 1.8 -> reject; volatility eccessiva)
  - Indices (MES/MNQ/MYM) PRO-TREND only (V15 patch #20):
      BUY requires BULLISH H1 structure
      SELL requires BEARISH H1 structure
      RANGING regime -> reject (indices don't MR in range)
  - Candle pattern signal SOFT: when reversal pattern absent,
    AI confidence threshold raises 60% -> 75%
  - Post-validation watchdog (RSI re-check, conf threshold by
    pattern presence, RR floor, C>=90% + H4 weak guardia)
  - Time-stop matrix per (asset, session) with TUPLE
    (deep_loss_min, breakeven_min)
  - Grace period for indices (+15min if break-even & RSI toward mean)
  - Trailing emergency (progress > 30% & TP large enough)
  - Auto partial 50% + SL→BE at progress >= 50% (V18: era 65%, allineato a TF)
  - RSI50 cross + P&L > 0 + progress > 15% -> PARTIAL_50 + set_be
  - AI exit per-candle (idempotency via runtime.last_exit_eval_time)

The user reading this file should recognize V15's MR rules; only the
data flow changed (typed dataclasses, no state mutation, async).
"""

from __future__ import annotations

import datetime as _dt
import json
import logging
from typing import Optional

from analysis.price_action import (
    PASignals,
    PAScore,
    extract_pa_signals,
    score_pa,
)
from analysis.tech_snapshot import tech_log_fields
from brain.ai_client import AIClient, extract_json_from_response
from brain.brain_base import BrainBase
from core import config_futures as cfg_fut
from core.contracts import (
    BrainContext,
    BrainDecision,
    BrainName,
    Direction,
    EntryDecision,
    EntryEvalResult,
    TradeAction,
)
from trading.tp_resolver import TP_SUGGESTED_CONSERVATIVE_MARGIN


_log = logging.getLogger("brain.mr")


# ============================================================
# MR_ASSET_PROFILES — sl_range / sl_min_points
#
# V16-29-apr: rivalidato su pattern di clamp osservati in produzione
# (parità con TF). MES/MNQ/MYM avevano sl_range CFD V15 fuori scala
# futures → CLAMP_UP @ MIN. 6C aveva sl_range troppo largo → CLAMP_DOWN.
# Post BUG C: tp_range rimosso (TP è scelta AI come prezzo assoluto).
#
# CALIBRATO SU CFD V15 (EURUSD.r / US100 / XAUUSD.r etc.).
# DA RIVALIDARE IN CALIBRAZIONE V16 (5-9 mag).
#
# Mapping V15 -> V16 futures (identical to TF mapping):
#   US500   -> MES        EURUSD  -> 6E       USDJPY  -> 6J
#   US100   -> MNQ        GBPUSD  -> 6B       USDCAD  -> 6C
#   US30    -> MYM        AUDUSD  -> 6A       XAUUSD  -> MGC
#
# MR profiles differ from TF: tighter SL (MR enters on extremes,
# small reversal expected), lower RR floor (1.5 vs TF 1.8-2.0).
# MCL and 6A use GENERIC_PROFILE + one-time warning.
# ============================================================

MR_ASSET_PROFILES: dict = {
    # ── EQUITY INDEX MICROS ────────────────────────────────────────
    "MES": {  # ≡ V15 US500
        "sl_range":      (0.30, 0.90),  # V16-29-apr: era (0.045, 0.080), CLAMP_UP
        "sl_min_points": 25,
        "v15_origin":    "US500",
        "note":          "S&P500 micro MR: V16 calibrato ATR_M5 ~6, sl_ticks 8-25",
    },
    "MNQ": {  # ≡ V15 US100
        "sl_range":      (0.060, 0.250),  # V16-29-apr: era (0.045, 0.075), CLAMP_UP
        "sl_min_points": 60,
        "v15_origin":    "US100",
        "note":          "Nasdaq micro MR: V16 calibrato ATR_M5 ~30, sl_ticks 8-30",
    },
    "MYM": {  # ≡ V15 US30
        "sl_range":      (0.13, 0.45),  # V16-29-apr: era (0.045, 0.080), CLAMP_UP
        "sl_min_points": 100,
        "v15_origin":    "US30",
        "note":          "Dow micro MR: V16 calibrato ATR_M5 ~80, sl_ticks 11-36",
    },
    # ── METAL MICRO ────────────────────────────────────────────────
    "MGC": {  # ≡ V15 XAUUSD
        "sl_range":      (0.7, 1.2),
        "sl_min_points": 1.5,
        "max_risk_mult": 0.8,    # V15: gold is volatile, cap risk
        "v15_origin":    "XAUUSD",
        "note":          "Gold micro: deep SL, V15 calibration",
    },
    # ── FX FUTURES ─────────────────────────────────────────────────
    "6E": {   # ≡ V15 EURUSD
        "sl_range":      (3.0, 5.0),
        "v15_origin":    "EURUSD",
        "note":          "EUR/USD: SL 9-15p (V15 calibration)",
    },
    "6B": {   # ≡ V15 GBPUSD
        "sl_range":      (2.8, 4.5),
        "v15_origin":    "GBPUSD",
        "note":          "GBP/USD: SL 15-24p (V15 calibration)",
    },
    "6J": {   # ≡ V15 USDJPY (inverted_quote at broker)
        "sl_range":      (0.40, 0.65),
        "sl_min_points": 0.25,    # V15 had 25 pip floor
        "v15_origin":    "USDJPY",
        "note":          "JPY: SL 24-39p (V15 calibration)",
    },
    "6C": {   # ≡ V15 USDCAD (inverted_quote at broker)
        "sl_range":      (1.5, 2.5),  # V16-29-apr: era (3.0, 4.8), CLAMP_DOWN @ MAX=50
        "v15_origin":    "USDCAD",
        "note":          "CAD MR: V16 calibrato (ATR_TYPICAL 0.0008 alto vs altri FX)",
    },
    "6A": {   # AUD/USD - new in V16
        "sl_range":      (3.0, 4.8),
        "v15_origin":    "AUDUSD (new in V16)",
        "note":          "AUD/USD MR: derived from 6E analog",
    },
    "MCL": {  # WTI Crude Oil micro - new in V16
        "sl_range":      (1.0, 1.8),
        "v15_origin":    "WTI (new in V16)",
        "note":          "Crude Oil micro MR: tight ranges, refine in CALIBRAZIONE",
    },
}

GENERIC_PROFILE: dict = {
    "sl_range":   (3.0, 5.0),
    "v15_origin": "GENERIC",
    "note":       "no V15 equivalent — generic conservative MR profile, recalibrate in V16",
}


# ============================================================
# MR_TIME_STOP — (asset, session) -> (deep_loss_min, breakeven_min)
# Migrated from V15. Default for unknown asset/session: (35, 60).
# ============================================================

MR_TIME_STOP: dict = {
    # FX futures (mapped from V15 .r symbols)
    "6E": {"LONDON": (70, 115), "LONDON_NY": (65, 110), "NY_CLOSE": (70, 115),
           "NY_LATE": (75, 120), "ASIA_EARLY": (80, 125), "ASIA_LONDON_GAP": (75, 120)},
    "6B": {"LONDON": (70, 115), "LONDON_NY": (65, 110), "NY_CLOSE": (70, 115),
           "NY_LATE": (75, 120), "ASIA_EARLY": (80, 125), "ASIA_LONDON_GAP": (75, 120)},
    "6A": {"LONDON": (70, 115), "LONDON_NY": (70, 115), "NY_CLOSE": (70, 115),
           "NY_LATE": (75, 120), "ASIA_EARLY": (65, 110), "ASIA_LONDON_GAP": (70, 115)},
    "6J": {"LONDON": (70, 115), "LONDON_NY": (65, 110), "NY_CLOSE": (70, 115),
           "NY_LATE": (75, 120), "ASIA_EARLY": (65, 110), "ASIA_LONDON_GAP": (70, 115)},
    "6C": {"LONDON": (70, 115), "LONDON_NY": (60, 105), "NY_CLOSE": (65, 110),
           "NY_LATE": (70, 115), "ASIA_EARLY": (80, 125), "ASIA_LONDON_GAP": (75, 120)},
    # Equity index micros
    "MES": {"LONDON": (75, 120), "LONDON_NY": (65, 110), "NY_CLOSE": (65, 110),
            "NY_LATE": (75, 120), "ASIA_EARLY": (95, 145), "ASIA_LONDON_GAP": (95, 145)},
    "MNQ": {"LONDON": (75, 120), "LONDON_NY": (65, 110), "NY_CLOSE": (65, 110),
            "NY_LATE": (75, 120), "ASIA_EARLY": (95, 145), "ASIA_LONDON_GAP": (95, 145)},
    "MYM": {"LONDON": (75, 120), "LONDON_NY": (65, 110), "NY_CLOSE": (65, 110),
            "NY_LATE": (75, 120), "ASIA_EARLY": (95, 145), "ASIA_LONDON_GAP": (95, 145)},
    # Metals/Energy
    "MGC": {"LONDON": (75, 120), "LONDON_NY": (70, 115), "NY_CLOSE": (75, 120),
            "NY_LATE": (80, 125), "ASIA_EARLY": (85, 130), "ASIA_LONDON_GAP": (85, 130)},
    "MCL": {"LONDON": (70, 115), "LONDON_NY": (60, 105), "NY_CLOSE": (65, 110),
            "NY_LATE": (75, 120), "ASIA_EARLY": (85, 130), "ASIA_LONDON_GAP": (80, 125)},
}
MR_TIME_STOP_DEFAULT = (35, 60)


# ============================================================
# MR-SPECIFIC THRESHOLDS (from V15)
# ============================================================

RSI_MR_OVERSOLD = 32.0
RSI_MR_OVERBOUGHT = 68.0
ATR_RATIO_CAP = 1.8                  # V15: atr_ratio > 1.8 -> reject

# Confidence thresholds depending on candle pattern presence
CONF_MIN_WITH_PATTERN = 60
CONF_MIN_WITHOUT_PATTERN = 75

# Grace period for indices in consolidation
GRACE_INDICES_EXTRA_MIN = 15
INDICES_FUTURES = frozenset(cfg_fut.INDICI_FUTURES)  # MES, MNQ, MYM, YM

# Trailing emergency
TRAILING_PROGRESS_THRESHOLD_PCT = 30.0
TRAILING_ATR_MULT = 1.5
TRAILING_TP_DISTANCE_MULT = 2.5     # TP must be > 2.5x trailing distance

# Auto partials
# V18: soglia abbassata 65→50 per parità con Brain TF
# (TF_AUTO_PARTIAL_PROGRESS_PCT). Secure half + BE a metà strada verso il
# TP, prima che un ritracciamento bruci il progress consolidato.
AUTO_PARTIAL_PROGRESS_PCT = 50.0
RSI50_PARTIAL_MIN_PROGRESS_PCT = 15.0

# RSI50 mean reach thresholds (MR exit signals)
RSI_BUY_REACHED_MEAN = 48.0          # BUY -> RSI back above 48
RSI_SELL_REACHED_MEAN = 52.0         # SELL -> RSI back below 52


# ============================================================
# SESSION CLASSIFIER (duplicated from brain_tf — rule of three)
# ============================================================

def _classify_session(utc_hour: int) -> str:
    if 7 <= utc_hour < 12:   return "LONDON"
    if 12 <= utc_hour < 17:  return "LONDON_NY"
    if 17 <= utc_hour < 20:  return "NY_CLOSE"
    if 20 <= utc_hour <= 23: return "NY_LATE"
    if 0 <= utc_hour < 3:    return "ASIA_EARLY"
    return "ASIA_LONDON_GAP"


# ============================================================
# BRAIN MR
# ============================================================

class BrainMR(BrainBase):
    """
    Mean Reversion Brain. Async (uses AIClient).

    Tech keys consumed (orchestrator pre-condition):
        symbol, price, open, rsi, rsi_prev, rsi_h1, rsi_h4,
        atr_ratio, atr_m5_points, vwap, vwap_deviation_pct,
        market_structure, regime, vol_regime, trend_maturity,
        candle_strength, candle_time, divergence, macd_decelerating,
        deviation_pct (EMA50 deviation),
        bias, allowed_direction, h1_compatibility, h1_reason,
        swing_data (optional dict),
        consecutive_sl_count (optional int).

    Plus PA pattern keys (consumed via analysis.price_action):
        hammer, bull_engulfing, morning_star, piercing, doji + doji_type,
        shooting_star, bear_engulfing, evening_star, dark_cloud,
        volume_weak.
    """

    name = BrainName.MR.value

    def __init__(self, config, ai_client: AIClient, logger=None) -> None:
        super().__init__(config)
        self.ai = ai_client
        self.logger = logger
        self._warned_generic_profile: set[str] = set()

    # ============================================================
    # PROFILE LOOKUP
    # ============================================================

    def _profile_for(self, symbol: str) -> dict:
        prof = MR_ASSET_PROFILES.get(symbol)
        if prof is not None:
            return prof
        if symbol not in self._warned_generic_profile:
            self._warned_generic_profile.add(symbol)
            msg = (f"[BrainMR] {symbol}: no V15-calibrated profile — "
                   f"using GENERIC. Recalibrate in V16 (5-9 may).")
            _log.warning(msg)
            if self.logger is not None:
                self.logger.system.warning(msg)
        return GENERIC_PROFILE

    # ============================================================
    # ENTRY
    # ============================================================

    async def evaluate_entry(
        self,
        symbol: str,
        tech: dict,
        *,
        bias_data,
        last_entry_eval_time: float = 0.0,
    ) -> EntryEvalResult:
        """
        Evaluate an MR entry. See BrainTF.evaluate_entry docstring for
        the EntryEvalResult contract and same-candle dedup semantics.

        Direction derivation (V15): BUY if allowed_direction in {BUY, BOTH}
        AND rsi < 50; SELL otherwise. So MR direction comes from RSI side
        of 50 within bias-allowed set, not just from bias alone.
        """
        # Source A (BiasResolver: algo + AI override). See brain_base
        # docstring — tech.* bias fields are Source B (algo-only).
        allowed = bias_data.allowed_direction
        rsi = float(tech.rsi)
        rsi_h4 = float(tech.rsi_h4)
        atr_ratio = float(tech.atr_ratio)
        candle_time = float(getattr(tech, "candle_time", 0) or 0)

        # ── CANDLE-STATE GATE (before pre-val) ────────────────────
        # Skip if candle isn't a settled, fresh M5 close. Logged as
        # scan_skip by the orchestrator (not entry_rejected: this is
        # state-of-data, not a trading decision).
        if not bool(getattr(tech, "is_candle_closed", False)):
            return EntryEvalResult(
                decision=None, reject_reason="CANDLE_NOT_CLOSED",
            )
        age = float(getattr(tech, "candle_age_seconds", 0.0) or 0.0)
        if age < float(self.config.candle_close_delay_seconds):
            return EntryEvalResult(
                decision=None, reject_reason="CANDLE_STABILIZING",
            )
        if age > float(self.config.candle_max_age_seconds):
            return EntryEvalResult(
                decision=None, reject_reason="CANDLE_TOO_OLD",
            )

        # Direction derivation (V15: RSI side of 50, gated by bias)
        if allowed in ("BUY", "BOTH") and rsi < 50.0:
            direction = "BUY"
        else:
            direction = "SELL"

        # ── PRE-VAL 1: RSI must be at extreme ─────────────────────
        if direction == "BUY" and rsi >= RSI_MR_OVERSOLD:
            return EntryEvalResult(decision=self._reject(
                symbol, direction, rule="RSI_NOT_EXTREME",
                detail=(f"MR BUY requires RSI < {RSI_MR_OVERSOLD} (oversold). "
                        f"RSI M5={rsi:.1f} -> use TF for shallow pullbacks."),
                tech=tech, rsi_threshold=RSI_MR_OVERSOLD, side="oversold",
            ))
        if direction == "SELL" and rsi <= RSI_MR_OVERBOUGHT:
            return EntryEvalResult(decision=self._reject(
                symbol, direction, rule="RSI_NOT_EXTREME",
                detail=(f"MR SELL requires RSI > {RSI_MR_OVERBOUGHT} (overbought). "
                        f"RSI M5={rsi:.1f} -> use TF for shallow pullbacks."),
                tech=tech, rsi_threshold=RSI_MR_OVERBOUGHT, side="overbought",
            ))

        # ── PRE-VAL 2: indices restriction (V15 patch #20) ───────
        is_index = symbol in INDICES_FUTURES
        if is_index:
            regime = str(tech.regime).upper()
            struct = str(tech.market_structure).upper()
            if regime == "RANGING":
                return EntryEvalResult(decision=self._reject(
                    symbol, direction, rule="INDICES_RANGING",
                    detail=(f"index {symbol} in RANGING regime -> "
                            f"MR not allowed (V15 patch #20)."),
                    tech=tech, regime=regime, market_structure=struct,
                ))
            trend_is_bull = "BULLISH" in struct
            trend_is_bear = "BEARISH" in struct
            if direction == "BUY" and not trend_is_bull:
                return EntryEvalResult(decision=self._reject(
                    symbol, direction, rule="INDICES_NOT_PRO_TREND",
                    detail=(f"index {symbol} BUY requires BULLISH structure "
                            f"(V15 patch #20). Got {struct}."),
                    tech=tech, market_structure=struct, regime=regime,
                ))
            if direction == "SELL" and not trend_is_bear:
                return EntryEvalResult(decision=self._reject(
                    symbol, direction, rule="INDICES_NOT_PRO_TREND",
                    detail=(f"index {symbol} SELL requires BEARISH structure "
                            f"(V15 patch #20). Got {struct}."),
                    tech=tech, market_structure=struct, regime=regime,
                ))

        # ── PRE-VAL 3: ATR cap (volatility too high for MR) ──────
        if atr_ratio > ATR_RATIO_CAP:
            return EntryEvalResult(decision=self._reject(
                symbol, direction, rule="ATR_TOO_HIGH",
                detail=(f"ATR ratio {atr_ratio:.2f}x > cap {ATR_RATIO_CAP} — "
                        f"volatility excessive for MR (spike risk)."),
                tech=tech, atr_cap=ATR_RATIO_CAP,
            ))

        # ── PRE-VAL 4: H1 compatibility floor (V18 09-mag) ──
        # h1_compat < 0.70 = setup against-trend / mismatched H1.
        # Gate deterministico pre-AI, no token cost.
        h1_compat = float(tech.h1_compatibility)
        if h1_compat < 0.70:
            return EntryEvalResult(decision=self._reject(
                symbol, direction, rule="H1_COMPAT_TOO_LOW",
                detail=f"H1 compat {h1_compat:.2f} < 0.70 — {direction} rejected",
                tech=tech,
            ))

        # ── PRE-VAL 5: MACD accelerating against (V18 12-mag) ──
        # Incidente 12-mag: 6B BUY approvato con MACD che accelerava
        # bearish contro → SL -$375. Gate deterministico pre-AI: se il
        # momentum (segno dell'istogramma MACD) è opposto alla direzione
        # E non sta decelerando, blocca l'entry — MR vuole momentum che
        # rallenta o gira a favore, non che accelera contro.
        macd_decel = bool(tech.macd_decelerating)
        macd_hist = float(tech.macd_hist_last)
        macd_against = (
            (direction == "BUY"  and not macd_decel and macd_hist < 0) or
            (direction == "SELL" and not macd_decel and macd_hist > 0)
        )
        if macd_against:
            return EntryEvalResult(decision=self._reject(
                symbol, direction, rule="MACD_ACCELERATING_AGAINST",
                detail=(f"MACD accelerando contro {direction} "
                        f"(hist={macd_hist:.6f}) — MR rejected"),
                tech=tech,
                macd_decelerating=macd_decel,
                macd_hist_last=macd_hist,
            ))

        # ── SAME-CANDLE DEDUP (gate just before AI call) ──────────
        if candle_time and candle_time <= last_entry_eval_time:
            return EntryEvalResult(decision=None, dedup_skipped=True)

        # ── BUILD PROMPT + AI ────────────────────────────────────
        profile = self._profile_for(symbol)
        signals = extract_pa_signals(tech)
        score = score_pa(signals, direction)
        pattern_confirmed = self._pattern_confirms_reversal(signals, direction)

        prompt = self._build_entry_prompt(
            symbol=symbol, direction=direction, allowed=allowed,
            rsi=rsi, rsi_h4=rsi_h4, atr_ratio=atr_ratio,
            tech=tech, bias_data=bias_data,
            signals=signals, score=score,
            pattern_confirmed=pattern_confirmed, profile=profile,
        )

        resp = await self.ai.ask_for_decision(prompt)

        # Cache-update policy per error_kind (parity with brain_tf):
        TRANSIENT_KINDS = {"unknown", "overload"}
        if resp.text is not None:
            ai_eval_ts: Optional[float] = candle_time if candle_time else None
        elif resp.error_kind in TRANSIENT_KINDS:
            ai_eval_ts = None
        else:
            ai_eval_ts = candle_time if candle_time else None

        if resp.text is None:
            return EntryEvalResult(
                decision=self._reject(
                    symbol, direction, rule="API_ERROR",
                    detail=f"AI unavailable: {resp.error_kind}",
                    tech=tech,
                    error_kind=resp.error_kind, attempts=resp.attempts,
                ),
                evaluated_candle_time=ai_eval_ts,
            )
        result = self._parse_ai_json(resp.text)
        if not result or "approved" not in result:
            return EntryEvalResult(
                decision=self._reject(
                    symbol, direction, rule="API_PARSE_ERROR",
                    detail="AI returned malformed JSON",
                    tech=tech,
                    response_preview=(resp.text or "")[:200],
                ),
                evaluated_candle_time=ai_eval_ts,
            )

        # ── POST-VALIDATION WATCHDOG ─────────────────────────────
        rejection = self._post_validate(
            result=result,
            symbol=symbol,
            direction=direction,
            rsi=rsi,
            rsi_h4=rsi_h4,
            profile=profile,
            pattern_confirmed=pattern_confirmed,
            entry_price=float(tech.price),
        )
        if rejection is not None:
            extra_fields: dict = {}
            if rejection[0] == "TP_BELOW_MIN_PROFIT":
                extra_fields["tp_source"] = "rejected_tp_too_small"
                extra_fields["tp_price_suggested_ai"] = float(
                    result.get("tp_price_suggested", 0.0) or 0.0
                )
            return EntryEvalResult(
                decision=self._reject(
                    symbol, direction,
                    rule=rejection[0], detail=rejection[1],
                    tech=tech,
                    ai_confidence=int(result.get("confidence", 0)),
                    ai_sl_atr=float(result.get("sl_atr_multiplier", 0.0)),
                    ai_rr_multiplier=float(result.get("rr_multiplier", 0.0)),
                    pattern_confirmed=pattern_confirmed,
                    **extra_fields,
                ),
                evaluated_candle_time=ai_eval_ts,
            )

        if not result.get("approved"):
            rd = result.get("rejection_details") or {}
            ai_rule = str(rd.get("rule_failed", "UNSPECIFIED"))[:40] if isinstance(rd, dict) else "UNSPECIFIED"
            ai_detail = str(rd.get("detail", ""))[:200] if isinstance(rd, dict) else ""
            return EntryEvalResult(
                decision=self._reject(
                    symbol, direction, rule="AI_REJECTED",
                    detail=str(result.get("reason", "AI did not approve"))[:200],
                    tech=tech,
                    ai_confidence=int(result.get("confidence", 0)),
                    pattern_confirmed=pattern_confirmed,
                    ai_rule_failed=ai_rule,
                    ai_rejection_detail=ai_detail,
                ),
                evaluated_candle_time=ai_eval_ts,
            )

        # ── SL PRICE COMPUTATION (TP variante γ: resolved post-sizing) ─
        # Brain only emits SL price + rr_multiplier. Orchestrator calls
        # tp_resolver.resolve_tp_price(...) post-sizing to finalize tp_price.
        try:
            pc = self._compute_sl_only(
                symbol=symbol,
                direction=direction,
                tech=tech,
                sl_atr=float(result["sl_atr_multiplier"]),
            )
        except (KeyError, ValueError) as e:
            return EntryEvalResult(
                decision=self._reject(
                    symbol, direction, rule="PRICE_COMPUTATION_ERROR",
                    detail=f"cannot compute prices: {e}",
                    tech=tech,
                    atr_m5_points=getattr(tech, "atr_m5_points", None),
                ),
                evaluated_candle_time=ai_eval_ts,
            )

        # ── BUILD DECISION ───────────────────────────────────────
        confidence = int(result.get("confidence", 0))
        rationale = str(result.get("reason", "MR setup approved"))[:120]
        rr_multiplier_ai = float(result.get("rr_multiplier", 0.0))

        # V15 risk_multiplier cap for some assets (e.g. MGC: max 0.8)
        risk_mult = float(result.get("risk_multiplier", 1.0))
        max_risk_mult = float(profile.get("max_risk_mult", 999.0))
        if risk_mult > max_risk_mult:
            risk_mult = max_risk_mult

        return EntryEvalResult(
            decision=EntryDecision(
                direction=direction,
                entry_price=pc["entry_price"],
                sl_price=pc["sl_price"],
                tp_price=0.0,                    # sentinel: orchestrator finalizes post-sizing
                rr_multiplier=rr_multiplier_ai,
                confidence=confidence,
                rationale=rationale,
                metadata={
                    "brain":             "MR",
                    "sl_atr_multiplier": pc["sl_atr"],
                    "rr_multiplier_ai":  rr_multiplier_ai,
                    "tp_price_suggested_ai": float(result.get("tp_price_suggested", 0.0) or 0.0),
                    "tp_rationale_ai":   str(result.get("tp_rationale", ""))[:200],
                    "risk_multiplier":   risk_mult,
                    "key_risk":          str(result.get("key_risk", ""))[:120],
                    "sl_ticks_used":     pc["sl_ticks_post_clamp"],
                    "clamp_active":      pc["clamp_active"],
                    "sl_floored":        pc["sl_floored"],
                    "sl_min_ticks":      pc["sl_min_ticks"],
                    # A/B test passivo SL (v18-dev): consumato dall'orchestrator
                    # per popolare TradeRuntime.sl_price_ai_raw.
                    "sl_price_ai_raw":      pc["sl_price_ai_raw"],
                    "sl_ticks_pre":         pc["sl_ticks_pre"],
                    "sl_floored_or_capped": pc["sl_floored_or_capped"],
                    "pattern_confirmed": pattern_confirmed,
                    "profile_origin":    profile.get("v15_origin", "?"),
                },
            ),
            evaluated_candle_time=ai_eval_ts,
        )

    # ============================================================
    # PATTERN HELPER (V15: dragonfly/gravestone doji map to bull/bear)
    # ============================================================

    @staticmethod
    def _pattern_confirms_reversal(signals: PASignals, direction: str) -> bool:
        """
        Returns True if a reversal candle pattern confirms the MR setup.
        For BUY: any bullish reversal pattern (hammer/bull_engulfing/
        morning_star/piercing/doji_bull). For SELL: bearish counterparts.

        V15 had hardcoded dragonfly_doji and gravestone_doji checks;
        analysis.price_action already maps doji+type into doji_bull/bear.
        """
        if direction == "BUY":
            return (signals.hammer or signals.bull_engulfing or
                    signals.morning_star or signals.piercing or
                    signals.doji_bull)
        return (signals.shooting_star or signals.bear_engulfing or
                signals.evening_star or signals.dark_cloud or
                signals.doji_bear)

    # ============================================================
    # POST-VALIDATION (watchdog on AI output)
    # ============================================================

    @staticmethod
    def _post_validate(
        result: dict,
        symbol: str,
        direction: str,
        rsi: float,
        rsi_h4: float,
        profile: dict,
        pattern_confirmed: bool,
        entry_price: float,
    ) -> Optional[tuple[str, str]]:
        """
        Returns (rule, detail) if AI output should be rejected; None if OK.
        """
        if not result.get("approved"):
            return None  # AI already rejected

        # 1. RSI extreme paranoid recheck
        if direction == "BUY" and rsi >= RSI_MR_OVERSOLD:
            return ("POST_VAL_RSI",
                    f"AI approved with RSI {rsi:.1f} >= {RSI_MR_OVERSOLD} for BUY")
        if direction == "SELL" and rsi <= RSI_MR_OVERBOUGHT:
            return ("POST_VAL_RSI",
                    f"AI approved with RSI {rsi:.1f} <= {RSI_MR_OVERBOUGHT} for SELL")

        # 2. Confidence floor depends on pattern presence (V15 soft-gate)
        confidence = int(result.get("confidence", 0))
        if pattern_confirmed:
            if confidence < CONF_MIN_WITH_PATTERN:
                return ("POST_VAL_CONF",
                        f"confidence {confidence}% < {CONF_MIN_WITH_PATTERN}% "
                        f"floor (pattern confirmed)")
        else:
            if confidence < CONF_MIN_WITHOUT_PATTERN:
                return ("POST_VAL_CONF_NO_PATTERN",
                        f"confidence {confidence}% < {CONF_MIN_WITHOUT_PATTERN}% "
                        f"floor (no reversal pattern)")

        # 3. (rimosso V16-29-apr) RR floor pre-clamp. Filosofia APEX:
        # SL=risk, TP=rr_multiplier × $rischio reale (variante γ).
        sl_mult = float(result.get("sl_atr_multiplier", 0.0))
        rr_mult = float(result.get("rr_multiplier", 0.0))

        # 4. Guardia C>=90% + H4 weak (same as TF)
        if confidence >= 90:
            dir_ok = (
                (direction == "BUY"  and rsi_h4 >= 45) or
                (direction == "SELL" and rsi_h4 <= 55)
            )
            if not dir_ok:
                return ("POST_VAL_C90_H4",
                        f"C{confidence}% but H4 RSI {rsi_h4:.0f} against {direction}")

        # 5. SL multiplier range (parity with brain_tf).
        sl_low, sl_high = profile.get("sl_range", (0.0, float("inf")))
        if not (sl_low <= sl_mult <= sl_high):
            return ("POST_VAL_SL_RANGE",
                    f"sl_atr_multiplier {sl_mult:.3f} out of profile sl_range "
                    f"[{sl_low:.3f},{sl_high:.3f}] for {symbol}")

        # 6. rr_multiplier in advisory prompt-range [0.17, 0.67].
        # Hard clamp [0.10, 0.80] is applied later by tp_resolver as
        # defense-in-depth; this advisory rejection catches AI drift
        # outside the documented confidence→rr mapping (calibration signal).
        if rr_mult <= 0:
            return ("POST_VAL_RR_MISSING",
                    f"rr_multiplier {rr_mult} not positive")
        if not (0.17 <= rr_mult <= 0.67):
            return ("POST_VAL_RR_RANGE",
                    f"rr_multiplier {rr_mult:.3f} outside advisory "
                    f"prompt-range [0.17, 0.67]")

        # 7. TP_BELOW_MIN_PROFIT (V17). Applies only to AI-suggested TP
        # path: if tp_price_suggested is provided and direction-coherent,
        # compute the per-contract net after the 15% conservative margin
        # and reject when below the configured threshold. HARD rejection
        # (no floor lift) — a tight AI-suggested TP is itself signal that
        # the setup is marginal, prefer to skip. The rr-fallback path
        # (handled in tp_resolver via MIN_TP_TICKS) is NOT subject here.
        tp_suggested = float(result.get("tp_price_suggested", 0.0) or 0.0)
        if tp_suggested > 0:
            coherent = (
                (direction == "BUY"  and tp_suggested > entry_price) or
                (direction == "SELL" and tp_suggested < entry_price)
            )
            if coherent:
                spec = cfg_fut.ASSETS_MAP.get(symbol, {})
                tick_size = float(spec.get("tick_size", 0.0))
                tick_value = float(spec.get("tick_value", 0.0))
                if tick_size > 0 and tick_value > 0:
                    scale = 1.0 - TP_SUGGESTED_CONSERVATIVE_MARGIN
                    if direction == "BUY":
                        tp_dist_post = (tp_suggested - entry_price) * scale
                    else:
                        tp_dist_post = (entry_price - tp_suggested) * scale
                    tp_ticks = max(0, int(round(tp_dist_post / tick_size)))
                    tp_gross = tp_ticks * tick_value
                    tp_net = tp_gross - cfg_fut.COMMISSION_PER_CONTRACT_USD
                    if tp_net < cfg_fut.TP_MIN_NET_PROFIT_USD:
                        return (
                            "TP_BELOW_MIN_PROFIT",
                            f"AI tp_suggested={tp_suggested} → "
                            f"{tp_ticks} ticks post-15%-margin → "
                            f"gross/ct ${tp_gross:.2f} − "
                            f"commission ${cfg_fut.COMMISSION_PER_CONTRACT_USD:.2f} = "
                            f"net/ct ${tp_net:.2f} < "
                            f"${cfg_fut.TP_MIN_NET_PROFIT_USD:.2f} threshold",
                        )

        return None

    # ============================================================
    # SL PRICE COMPUTATION (TP variante γ: TP resolved post-sizing)
    # ============================================================

    @staticmethod
    def _compute_sl_only(
        symbol: str,
        direction: str,
        tech: dict,
        sl_atr: float,
    ) -> dict:
        """
        Compute SL price + tick diagnostics from sl_atr_multiplier.

        Same shape as brain_tf._compute_sl_only (parity). TP variante γ:
        TP is finalized post-sizing by orchestrator via tp_resolver.

        Raises:
            KeyError if symbol unknown.
            ValueError if atr_m5_points missing or non-positive.
        """
        spec = cfg_fut.ASSETS_MAP[symbol]
        tick_size = float(spec["tick_size"])
        digits = int(spec["digits"])

        entry_price = float(tech.price)
        atr_points = float(getattr(tech, "atr_m5_points", 0.0) or 0.0)
        if atr_points <= 0:
            raise ValueError("atr_m5_points missing or non-positive in tech")

        sl_distance_pre = atr_points * sl_atr * cfg_fut.SL_SAFETY_MULT
        sl_ticks_pre = round(sl_distance_pre / tick_size)

        sl_min = cfg_fut.MIN_SL_TICKS.get(symbol, 1)
        sl_max = cfg_fut.MAX_SL_TICKS.get(symbol, 10_000)
        sl_ticks_post = max(sl_min, min(sl_ticks_pre, sl_max))
        sl_distance_post = sl_ticks_post * tick_size

        if direction == "BUY":
            sl_price = entry_price - sl_distance_post
            sl_price_ai_raw = entry_price - (sl_ticks_pre * tick_size)
        else:
            sl_price = entry_price + sl_distance_post
            sl_price_ai_raw = entry_price + (sl_ticks_pre * tick_size)

        # V18 12-mag — sl_floored distingue il bump al MIN_SL_TICKS dal
        # clamp generico (parity con brain_tf._compute_sl_only).
        sl_floored = sl_min > 0 and sl_ticks_pre < sl_min

        return {
            "entry_price":            round(entry_price, digits),
            "sl_price":                round(sl_price, digits),
            "atr_m5_points":           atr_points,
            "tick_size":               tick_size,
            "sl_atr":                  sl_atr,
            "sl_distance_pre_clamp":   round(sl_distance_pre, digits + 2),
            "sl_distance_post_clamp":  round(sl_distance_post, digits + 2),
            "sl_ticks_pre_clamp":      sl_ticks_pre,
            "sl_ticks_post_clamp":     sl_ticks_post,
            "sl_min_ticks":            sl_min,
            "sl_max_ticks":            sl_max,
            "clamp_active":            sl_ticks_pre != sl_ticks_post,
            "sl_floored":              sl_floored,
            # A/B test passivo SL (v18-dev): SL grezzo proposto dall'AI prima
            # del clamp MIN/MAX. Loggato solo, mai inviato al broker.
            "sl_ticks_pre":            sl_ticks_pre,
            "sl_price_ai_raw":         round(sl_price_ai_raw, digits),
            "sl_floored_or_capped":    sl_ticks_post != sl_ticks_pre,
        }

    # ============================================================
    # ENTRY PROMPT BUILDER
    # ============================================================

    @staticmethod
    def _build_entry_prompt(
        *,
        symbol: str,
        direction: str,
        allowed: str,
        rsi: float,
        rsi_h4: float,
        atr_ratio: float,
        tech: dict,
        bias_data,
        signals: PASignals,
        score: PAScore,
        pattern_confirmed: bool,
        profile: dict,
    ) -> str:
        """
        V17 narrative entry prompt (Mean Reversion).

        MR è il complemento di TF: non cerca trend, cerca INVERSIONI dalla
        media quando il prezzo è in zona di estremo statistico (RSI < 32
        per BUY o > 68 per SELL) con segnali di esaurimento del momentum
        contrario.

        Espone TUTTI i campi del TechSnapshot, organizzati in sei sezioni:
          1. Contesto mercato (sessione/liquidità, regime, bias, news)
          2. Storia del prezzo (narrativa naturale del distacco dalla media)
          3. Segnali di inversione (RSI estremo, pattern reversal, PA, vol)
          4. Contesto strutturale (swing, EMA50 H1, anti-revenge MR)
          5. Profilo rischio (range SL, candle metadata, max_risk_mult cap)
          6. Domanda esplicita + criteri di approvazione

        Invarianti rispetto a V16:
          - Threshold confidence pattern-aware: {CONF_MIN_WITH_PATTERN}%
            con pattern reversal, {CONF_MIN_WITHOUT_PATTERN}% senza
          - SL profile range mapping (sl_atr_multiplier ∈ profile.sl_range)
          - max_risk_mult cap per asset (es. MGC=0.8)
          - atr_ratio cap MR (ATR_RATIO_CAP=1.8)
          - Re-entry post-SL: divergence OBBLIGATORIA + pattern + volume_weak

        V17 transitional bridge (parità con brain_tf):
          - JSON output mantiene il contratto V16 (`risk_multiplier`,
            `rr_multiplier` ∈ [0.17, 0.67]) — pipeline downstream
            (evaluate_entry / _post_validate / tp_resolver) NON cambia.
          - Aggiunto `tp_price_suggested` (livello TP assoluto basato su
            VWAP/EMA50/swing) accanto a `rr_multiplier`. Per ora viene
            loggato; downstream lo consumerà nei commit V17 successivi
            (margine conservativo 15% applicato in pipeline).
        """
        # ── SL profile ─────────────────────────────────────────────
        sl_low, sl_high = profile["sl_range"]
        sl_mid = round((sl_low + sl_high) / 2, 3)
        sl_floor = profile.get("sl_min_points", 0)
        max_risk_mult = profile.get("max_risk_mult", None)

        # ── Tech extracts ──────────────────────────────────────────
        rsi_h1 = float(tech.rsi_h1)
        deviation_pct = float(tech.deviation_pct)
        struct = tech.market_structure
        regime = tech.regime
        regime_reason = tech.regime_reason
        h1_compat = float(tech.h1_compatibility)
        h1_reason = tech.h1_reason
        vwap = tech.vwap
        vwap_dev = float(tech.vwap_deviation_pct)
        divergence = tech.divergence
        macd_decel = bool(tech.macd_decelerating)
        vol_regime = tech.vol_regime
        vol_spike = bool(tech.vol_spike)
        tech_allowed = tech.allowed_direction
        consec_sl = int(tech.consecutive_sl_count)
        candle_strength = float(signals.candle_strength)

        # Source A bias (from resolver), aligned with evaluate_entry gate.
        bias = bias_data.bias

        # ── RSI extreme detection (MR core signal) ─────────────────
        rsi_extreme = (
            (direction == "BUY"  and rsi < RSI_MR_OVERSOLD) or
            (direction == "SELL" and rsi > RSI_MR_OVERBOUGHT)
        )
        if rsi < RSI_MR_OVERSOLD:
            rsi_zone = "oversold"
        elif rsi > RSI_MR_OVERBOUGHT:
            rsi_zone = "overbought"
        else:
            rsi_zone = "neutra"

        # ── Session + liquidity (UTC buckets) ──────────────────────
        ct = float(getattr(tech, "candle_time", 0) or 0)
        if ct > 0:
            now_dt = _dt.datetime.fromtimestamp(ct, tz=_dt.timezone.utc)
        else:
            now_dt = _dt.datetime.now(_dt.timezone.utc)
        now_utc_str = now_dt.strftime("%H:%M UTC")
        utc_hr = now_dt.hour
        if 0 <= utc_hr < 7:
            session_name, liquidity = "ASIA", "bassa"
        elif 7 <= utc_hr < 12:
            session_name, liquidity = "EUROPA", "media"
        elif 12 <= utc_hr < 16:
            session_name, liquidity = "OVERLAP EU-USA", "alta"
        elif 16 <= utc_hr < 21:
            session_name, liquidity = "USA", "alta"
        else:
            session_name, liquidity = "POST-USA", "bassa"

        # Brain runs only when news_filter doesn't block (gate is in
        # orchestrator BEFORE evaluate_entry).
        news_context = (
            "nessun evento HIGH a calendario imminente "
            "(filtro news in orchestrator già passato)"
        )

        # ── Narrative qualifiers ───────────────────────────────────
        regime_narrative = "ranging" if regime == "RANGING" else "trending"

        if vwap_dev > 0:
            vwap_side = "sopra"
        elif vwap_dev < 0:
            vwap_side = "sotto"
        else:
            vwap_side = "su"

        if atr_ratio >= 1.2:
            atr_quality = "pulito"
        elif atr_ratio >= 0.8:
            atr_quality = "normale"
        else:
            atr_quality = "debole"

        macd_state = "decelerando" if macd_decel else "accelerando"

        # ── Active candle patterns ─────────────────────────────────
        pattern_flags: list[str] = []
        if tech.hammer:         pattern_flags.append("hammer")
        if tech.shooting_star:  pattern_flags.append("shooting_star")
        if tech.bull_engulfing: pattern_flags.append("bull_engulfing")
        if tech.bear_engulfing: pattern_flags.append("bear_engulfing")
        if tech.piercing:       pattern_flags.append("piercing")
        if tech.dark_cloud:     pattern_flags.append("dark_cloud")
        if tech.morning_star:   pattern_flags.append("morning_star")
        if tech.evening_star:   pattern_flags.append("evening_star")
        if tech.doji:
            pattern_flags.append(f"doji({tech.doji_type or 'std'})")
        pattern_str = ", ".join(pattern_flags) if pattern_flags else "NESSUNO"
        doji_type_str = tech.doji_type or "NESSUNO"
        pattern_status = "CONFIRMED ✅" if pattern_confirmed else "ABSENT ❌"

        # ── PA / absorption alignment ──────────────────────────────
        pa_pro_dir = (
            (direction == "BUY"  and score.dominant == "BULLISH") or
            (direction == "SELL" and score.dominant == "BEARISH")
        )
        if pa_pro_dir:
            pa_align = "favor"
        elif score.dominant in ("BULLISH", "BEARISH"):
            pa_align = "adverse"
        else:
            pa_align = "neutro"
        abs_pro_dir = (
            (direction == "BUY"  and tech.buy_absorption) or
            (direction == "SELL" and tech.sell_absorption)
        )

        # ── Swing structural (lazy) ────────────────────────────────
        swing = tech.swing_data or {}
        sw_found = swing.get("swing_found", False)
        sw_type = swing.get("swing_type", "N/A") if sw_found else "NON TROVATO"
        sw_price_str = (
            f"{swing.get('swing_price', 0):.5f}" if sw_found else "N/A"
        )
        sw_bars = swing.get("swing_index", 0)

        # ── H1 compat label ────────────────────────────────────────
        if h1_compat >= 0.8:
            compat_label = "pro-trend"
        elif h1_compat >= 0.5:
            compat_label = "neutro"
        else:
            compat_label = "contro-trend"

        # ── Entry digits per asset ─────────────────────────────────
        entry_for_tp = float(tech.price)
        spec = cfg_fut.ASSETS_MAP.get(symbol, {})
        digits_for_tp = int(spec.get("digits", 5))

        # ── TP time-budget (V17 calibrazione: vincolo raggiungibilità) ──
        # MR usa il breakeven_min come deadline (la più lasca delle due
        # soglie MR_TIME_STOP). L'AI userà max_reachable_ticks per evitare
        # TP oltre il movimento statisticamente fattibile in quel tempo.
        ts_session = _classify_session(utc_hr)
        _, breakeven_min = MR_TIME_STOP.get(symbol, {}).get(
            ts_session, MR_TIME_STOP_DEFAULT,
        )
        minutes_to_timestop = breakeven_min
        tick_size = float(spec.get("tick_size", 0.0) or 0.0)
        atr_m5_points = float(getattr(tech, "atr_m5_points", 0.0) or 0.0)
        atr_m5_ticks = (atr_m5_points / tick_size) if tick_size > 0 else 0.0
        n_candles_to_ts = minutes_to_timestop / 5.0
        max_reachable_ticks = int(round(atr_m5_ticks * n_candles_to_ts * 0.6))

        # ── 6J inverted-quote note (carried over from V16) ─────────
        if symbol == "6J":
            usdjpy_entry = (1.0 / entry_for_tp) if entry_for_tp > 0 else 0.0
            inverted_note = (
                f"\n⚠️ 6J INVERTED QUOTE: 6J quote {entry_for_tp:.7f} ≡ "
                f"USDJPY {usdjpy_entry:.3f}. Movimento opposto: 6J↑ = yen "
                f"forte = USDJPY↓. BUY 6J ≡ SELL USDJPY (yen rafforza)."
            )
        else:
            inverted_note = ""

        # ── Asset character ────────────────────────────────────────
        asset_character = {
            "MES": "S&P 500 micro: momentum forte, breakout, target swing high.",
            "MNQ": "Nasdaq micro: volatile, candele ampie, target livello tondo.",
            "MYM": "Dow micro: meno volatile di MNQ, swing più ordinato.",
            "MGC": "Gold micro: spike-prone, range ampio. Target conservativo.",
            "MCL": "Crude Oil micro: direzionale con inversioni rapide. Target su VWAP/swing.",
            "6E":  "EUR/USD futures: mean-reverting, range giornaliero definito.",
            "6B":  "GBP/USD futures: spike e poi range. Target su swing recenti.",
            "6A":  "AUD/USD futures: risk-on proxy, scalper-friendly.",
            "6J":  "JPY futures: INVERTED quote (6J↑ = USDJPY↓), tick scale separata.",
            "6C":  "CAD futures: INVERTED quote, oil-correlated.",
        }.get(symbol, f"{symbol}: profile non dedicato, target conservativo.")

        favor_str = ", ".join(score.favor_direction) or "nessuno"
        adverse_str = ", ".join(score.adverse_direction) or "nessuno"

        candle_age = float(getattr(tech, "candle_age_seconds", 0.0) or 0.0)

        # ── Threshold confidence (pattern-aware) ───────────────────
        conf_threshold = (
            CONF_MIN_WITH_PATTERN if pattern_confirmed
            else CONF_MIN_WITHOUT_PATTERN
        )

        return f"""\
Sei un sistema di Mean Reversion quantitativo. Non cerchi trend: cerchi
INVERSIONI dalla media quando il prezzo è in zona di estremo statistico
(RSI < {RSI_MR_OVERSOLD} per BUY o > {RSI_MR_OVERBOUGHT} per SELL) con
segnali di esaurimento del momentum contrario. Bias H4: {bias}, direzione
permessa: {allowed}. Restituisci SOLO JSON valido — zero testo fuori dal
JSON.

═══ 1. CONTESTO MERCATO OGGI ═══
Sessione      : {session_name} ({now_utc_str}), liquidità {liquidity}
Regime        : {regime} — {regime_reason}
Bias H4       : {bias}, direzione consentita: {tech_allowed}
RSI H4        : {rsi_h4:.1f}
Vol regime    : {vol_regime}, Vol spike: {"SI" if vol_spike else "NO"}
News          : {news_context}

═══ 2. STORIA DEL PREZZO ═══
{symbol} è in regime {regime_narrative} (struttura H1: {struct}). Il prezzo
quota {entry_for_tp:.{digits_for_tp}f} e si è allontanato dalla media —
deviazione da EMA50 H1: {deviation_pct:+.2f}%. RSI M5 ha raggiunto
{rsi:.1f} (zona {rsi_zone}). RSI H1: {rsi_h1:.1f}. RSI H4: {rsi_h4:.1f}.
VWAP intra-day: {vwap:.{digits_for_tp}f} — il prezzo è {vwap_side} del VWAP
di {vwap_dev:+.2f}%. Struttura H1: {struct} — {h1_reason}. H1 compatibility
con {direction}: {h1_compat:.2f} ({compat_label}). Divergenza RSI/prezzo:
{divergence}. MACD: {macd_state}.{inverted_note}

═══ 3. SEGNALI DI INVERSIONE ═══
Setup MR richiede ESTREMO + ESAURIMENTO MOMENTUM CONTRARIO:
  • RSI estremo ({direction}): {"SI" if rsi_extreme else "NO"}
                          (BUY oversold se rsi<{RSI_MR_OVERSOLD};
                           SELL overbought se rsi>{RSI_MR_OVERBOUGHT})
  • Pattern candela attivi : {pattern_str}
  • Doji type              : {doji_type_str}
  • Pattern reversal MR    : {pattern_status}
                          (≥1 tra hammer/bull_engulfing/morning_star/
                           piercing/doji_bull per BUY; bearish per SELL)
  • PA dominante           : {score.dominant} (forza {score.strength:.1f}x) — {pa_align} a {direction}
                          FAVOR {direction}: {favor_str}
                          ADVERSE {direction}: {adverse_str}
  • Volume                 : {"DEBOLE" if signals.volume_weak else "NORMALE"} (volume_weak={signals.volume_weak})
                          (volumi calanti su candela contraria
                           = momentum in esaurimento)
  • Absorption pro {direction} : {"SI" if abs_pro_dir else "NO"}
                          (BUY_ABS={tech.buy_absorption},
                           SELL_ABS={tech.sell_absorption})
  • ATR ratio              : {atr_ratio:.2f}x → movimento {atr_quality} (cap MR {ATR_RATIO_CAP})
  • Candle strength        : {candle_strength:.2f}x (1.0 = media)
  • Vol spike              : {"SI" if vol_spike else "NO"}

REGOLA HARD pattern-aware:
  - Pattern CONFIRMED → threshold approval normale: {CONF_MIN_WITH_PATTERN}%
  - Pattern ABSENT    → threshold approval ALZATA : {CONF_MIN_WITHOUT_PATTERN}%
                        (servono 2+ fattori robusti — es. RSI MOLTO
                         estremo + divergence forte; setup ambigui senza
                         conferma candela = "falling/rising knife" → REJECT)

═══ 4. CONTESTO STRUTTURALE ═══
Swing recente (lazy)   : {sw_type} @ {sw_price_str} ({sw_bars} candele M5 fa)
EMA50 H1 deviazione    : {deviation_pct:+.2f}% (>0.3% = vera distanza dalla media)
H1 compat              : {h1_compat:.2f} — {compat_label}
H1 struttura           : bull={tech.h1_struct_bull}, bear={tech.h1_struct_bear}
SL consecutivi sessione: {consec_sl} (re-entry post-SL: {"SÌ" if consec_sl > 0 else "NO"})
{f"⚠️ {consec_sl} SL recenti: re-entry MR consentito SOLO con divergence OBBLIGATORIA + pattern reversal attivo + volume_weak. Mancanti → conf < {CONF_MIN_WITH_PATTERN}% (REJECT)." if consec_sl > 0 else ""}

⚠️ Se h1_compat < 0.5 (against-trend MR):
   - RSI MOLTO estremo (<25 BUY o >75 SELL) → conf può essere alta (75%+)
   - Solo divergence + MACD decel → conf 65-70%
   - Altrimenti → conf < 60% (REJECT)
   # TODO(V18 09-mag): hard gate H1_COMPAT_TOO_LOW a 0.70 in
   # evaluate_entry rende unreachable tutte le linee guida h1_compat<0.5
   # nel prompt (qui + criterio approvazione 4 + criterio rifiuto su
   # h1_compat<0.5). Ripulire il prompt se il gate resta stabile.

═══ 5. PROFILO RISCHIO ═══
Profilo SL {symbol} (V15 origin: {profile.get('v15_origin','?')}): {profile.get('note','')}
  Range sl_atr_multiplier : {sl_low:.3f} – {sl_high:.3f}  (mid {sl_mid:.3f}, ATR-meccanico)
  {f"⚠️ SL FLOOR ASSOLUTO: mai sotto {sl_floor} punti." if sl_floor else ""}
  {f"⚠️ MAX risk_multiplier per {symbol}: {max_risk_mult}" if max_risk_mult is not None else ""}
  Mappatura confidence → sl_atr_multiplier:
    60-69%:  sl_atr={sl_high:.3f}   risk_mult=0.5
    70-79%:  sl_atr={sl_mid:.3f}    risk_mult=1.0
    80-84%:  sl_atr={sl_mid:.3f}    risk_mult=1.1
    85%+:    sl_atr={sl_low:.3f}    risk_mult=1.2

Candle metadata (M5 corrente):
  Open           : {tech.open}
  Close          : {entry_for_tp:.{digits_for_tp}f}
  Age post-close : {candle_age:.0f}s
  Asset character: {asset_character}

═══ 5b. VINCOLO TEMPORALE TP ═══
Time-stop MR {symbol} ({ts_session}, breakeven): {minutes_to_timestop} minuti.
ATR M5 corrente: {atr_m5_points:.{digits_for_tp}f} punti = {atr_m5_ticks:.0f} ticks/candela.
Candele M5 disponibili prima del time-stop: ~{n_candles_to_ts:.0f}.
Movimento statisticamente raggiungibile (fattore conservativo 0.6):
    max_reachable_ticks = {max_reachable_ticks}

⚠️ Scegli un `tp_price_suggested` raggiungibile entro {max_reachable_ticks} ticks
   dal prezzo {entry_for_tp:.{digits_for_tp}f}. MR mira al rientro verso la
   media: se VWAP/EMA50/swing-opposto sono oltre questa soglia, NON li scegliere
   — il time-stop chiude prima che il prezzo torni a quel livello.

═══ 6. DOMANDA + TP TARGET ═══
Considerando il contesto di mercato (sez. 1), la storia del prezzo (sez. 2),
i segnali di inversione (sez. 3), il contesto strutturale (sez. 4) e il
profilo rischio (sez. 5):

  → Il prezzo si trova in una zona di estremo statistico con segnali di
    inversione confermati? Questo rappresenta un'opportunità di entrata
    {direction} verso la media, oppure il momentum attuale suggerisce
    di aspettare?

Se approvi, indica DUE campi TP (entrambi richiesti, transitional bridge V17):

  • `tp_price_suggested` (NEW V17): livello TP assoluto verso la media —
    target naturale MR è il VWAP {vwap:.{digits_for_tp}f}, l'EMA50 H1
    (deviazione attuale {deviation_pct:+.2f}%), o lo swing opposto
    {sw_type} {sw_price_str}. Scegli il più vicino e ragionevole nelle
    condizioni attuali. Il sistema applicherà un margine conservativo
    del 15% prima di piazzare l'ordine. Coerente con la direzione
    (BUY: > entry; SELL: < entry).

  • `rr_multiplier` (LEGACY V16, ancora consumato da tp_resolver):
    frazione del RISCHIO REALE in dollari, range [0.17, 0.67].
        tp_usd_target = rr_multiplier × sl_usd_actual
    Mappatura confidence → rr_multiplier (MR — più conservativo di TF):
        60-69%:  0.17 – 0.25   (TP molto vicino, alta probabilità hit)
        70-74%:  0.25 – 0.40   (TP medio-basso)
        75-79%:  0.40 – 0.55   (TP medio)
        80-84%:  0.55 – 0.67   (TP ambizioso)
        85%+ :   0.67          (max)
    Filosofia APEX "WR > RR": frazione conservativa, target raggiungibile
    intraday su Topstep no-overnight. Hard clamp [0.10, 0.80] applicato
    dall'orchestrator come defense-in-depth.

Velocità di hit TP per la sessione corrente ({session_name}, liquidità
{liquidity}): adatta sia tp_price_suggested sia rr_multiplier alla
probabilità che il prezzo raggiunga il target intraday.

═══ CRITERI APPROVAZIONE ═══
APPROVA se TUTTI:
  1) RSI M5 estremo (< {RSI_MR_OVERSOLD} BUY o > {RSI_MR_OVERBOUGHT} SELL)
  2) Almeno 2 fattori tra: [divergenza, MACD decel, candle<0.8x, |dev|>0.3%]
  3) confidence >= {conf_threshold}%
     (= {CONF_MIN_WITH_PATTERN}% con pattern, {CONF_MIN_WITHOUT_PATTERN}% senza — pattern attualmente: {pattern_status})
  4) Se h1_compat < 0.5 → conf >= 65% strict (against-trend rule)
  5) sl_atr_multiplier in [{sl_low:.3f}, {sl_high:.3f}]
  6) rr_multiplier in [0.17, 0.67] (mapping confidence sopra)
  7) tp_price_suggested coerente con la direzione
     (BUY: > entry {entry_for_tp:.{digits_for_tp}f};
      SELL: < entry {entry_for_tp:.{digits_for_tp}f})
  8) tp_price_suggested entro max_reachable_ticks={max_reachable_ticks} ticks
     dal prezzo (BUY: ≤ entry + {max_reachable_ticks}×tick_size;
                  SELL: ≥ entry − {max_reachable_ticks}×tick_size)

RIFIUTA se UNO:
  - candle_strength > 1.8 (momentum troppo forte — rising/falling knife)
  - atr_ratio > {ATR_RATIO_CAP} (gestito dal codice ma re-checka)
  - h1_compat < 0.5 E (no divergence O no MACD decel) → conf < 60%
  - consec_sl > 0 senza divergence + pattern + volume_weak

═══ REJECTION CODES (rule_failed quando approved=false) ═══
  - "RSI_NOT_EXTREME"     : RSI in zona neutra (no oversold/overbought)
  - "NO_PATTERN_LOW_CONF" : pattern absent E meno di 2 fattori robusti
  - "AGAINST_TREND_WEAK"  : h1_compat<0.5 senza divergence + MACD decel
  - "MOMENTUM_TOO_STRONG" : candle_strength > 1.8
  - "ATR_OUT_OF_RANGE"    : atr_ratio > cap
  - "RE_ENTRY_INCOMPLETE" : consec_sl > 0 senza divergence + pattern + volume_weak
  - "ABSORPTION_AGAINST"  : absorption avversa alla direction
  - "CONFIDENCE_FLOOR"    : conf sotto threshold (con/senza pattern)
  - "TP_BEYOND_MAX_REACHABLE": tp_price_suggested oltre max_reachable_ticks (time-budget insufficiente)
  - "OTHER"               : altri motivi (specifica in detail)
Quando approved=true → rule_failed="NONE", detail="ok".

═══ OUTPUT JSON ═══
{{
  "approved": true,
  "confidence": 75,
  "direction": "{direction}",
  "risk_multiplier": 1.0,
  "sl_atr_multiplier": {sl_mid:.3f},
  "rr_multiplier": 0.40,
  "tp_price_suggested": 0.0,
  "tp_rationale": "max 25 parole — livello target tecnico + frazione $rischio",
  "key_risk": "rischio principale",
  "reason": "max 12 parole",
  "rejection_details": {{"rule_failed": "NONE", "detail": "ok"}}
}}
"""

    # ============================================================
    # EXIT
    # ============================================================

    async def manage_exit(self, ctx: BrainContext) -> BrainDecision:
        decision = await self._manage_exit_logic(ctx)
        self._emit_manage_decision_log(ctx, decision)
        return decision

    async def _manage_exit_logic(self, ctx: BrainContext) -> BrainDecision:
        """
        MR exit management. V15 flow:

          1. Time stop (deep loss / breakeven failed) -> EXIT
          2. Grace period for indices (+15min if break-even & toward mean)
          3. Trailing emergency (progress > 30%, TP large) -> MOVE_SL trailing
          4. RSI50 cross + P&L>0 + progress>15% -> PARTIAL_50 + set_be
          5. Auto partial 50% + SL→BE at progress >= 50% -> PARTIAL_50
          6. Same-candle dedup -> HOLD without AI call
          7. AI exit per-candle (default HOLD)
          8. Default reason "stessa candela — AI già valutata"

        Brain does NOT mutate runtime. last_exit_eval_time is signalled
        via BrainDecision.metadata["evaluated_candle_time"].
        rsi50_partial_done is signalled via metadata when emitted, so
        the orchestrator can flip it on TradeRuntime.
        """
        entry = ctx.entry
        rt = ctx.runtime
        tech = ctx.tech_now
        symbol = entry.symbol
        direction = entry.direction
        is_long = direction == Direction.BUY.value
        net_profit = rt.net_profit_usd
        minutes_open = rt.minutes_open

        utc_hour = _dt.datetime.now(_dt.UTC).hour
        session = _classify_session(utc_hour)
        deep_threshold, breakeven_threshold = MR_TIME_STOP.get(
            symbol, {}
        ).get(session, MR_TIME_STOP_DEFAULT)

        # ── 1. TIME STOP DEEP LOSS ────────────────────────────────
        if net_profit < -50 and minutes_open >= deep_threshold:
            return self._exit(
                reason=(f"Time Stop MR — {minutes_open:.0f}m/{deep_threshold}m, "
                        f"loss ${net_profit:.0f} ({session})"),
                metadata={"trigger": "time_stop_deep", "session": session,
                          "deep_threshold": deep_threshold},
            )

        # ── 2. GRACE PERIOD INDICES (consolidation explosion) ────
        symbol_in_indices = symbol in INDICES_FUTURES
        grace_limit = deep_threshold + GRACE_INDICES_EXTRA_MIN
        if (symbol_in_indices and minutes_open >= deep_threshold
                and net_profit < 0):
            rsi_now = float(tech.rsi)
            rsi_entry = entry.rsi_m5_at_entry
            toward_mean = (
                (rsi_entry < 40 and rsi_now > rsi_entry) or
                (rsi_entry > 60 and rsi_now < rsi_entry)
            )
            is_break_even = -10 <= net_profit <= 10
            if is_break_even and toward_mean and minutes_open < grace_limit:
                # Grace active: HOLD unless deep loss
                if net_profit < -30:
                    return self._exit(
                        reason=f"Grace Period aborted — deep loss ${net_profit:.0f}",
                        metadata={"trigger": "grace_aborted_deep_loss"},
                    )
                # else hold and continue (don't return)
            elif minutes_open >= grace_limit:
                return self._exit(
                    reason=(f"Grace Period expired — "
                            f"{minutes_open:.0f}m/{grace_limit}m ({session})"),
                    metadata={"trigger": "grace_expired", "session": session,
                              "grace_limit": grace_limit},
                )

        # ── 3. TIME STOP BREAKEVEN FAILED ────────────────────────
        # (only if not in grace period above)
        if net_profit < 0 and minutes_open >= breakeven_threshold:
            # Indices in grace already handled; for non-indices, fire here
            in_grace = (
                symbol_in_indices
                and minutes_open >= deep_threshold
                and minutes_open < grace_limit
                and -10 <= net_profit <= 10
            )
            if not in_grace:
                return self._exit(
                    reason=(f"Time Stop MR — {minutes_open:.0f}m/{breakeven_threshold}m "
                            f"BE failed ({session})"),
                    metadata={"trigger": "time_stop_be_failed",
                              "session": session,
                              "breakeven_threshold": breakeven_threshold},
                )

        # ── 4. PROGRESS COMPUTATION ──────────────────────────────
        progress = self._compute_progress_pct(entry, tech)

        # ── 5. TRAILING EMERGENCY ────────────────────────────────
        if progress > TRAILING_PROGRESS_THRESHOLD_PCT:
            current_price = float(tech.price)
            atr = float(getattr(tech, "atr_m5_points", 0.0) or 0.0)
            tp_distance = abs(entry.entry_price - entry.tp_price)
            trailing_dist = TRAILING_ATR_MULT * atr
            if (atr > 0 and tp_distance > trailing_dist * TRAILING_TP_DISTANCE_MULT):
                trailing_sl_raw = (current_price - trailing_dist if is_long
                                   else current_price + trailing_dist)
                trailing_sl = self._round_to_tick(
                    trailing_sl_raw, float(tech.tick_size),
                )
                return self._move_sl(
                    sl_price=trailing_sl,
                    reason=(f"Trailing MR — progress {progress:.0f}% "
                            f"(TP largo {tp_distance/atr:.1f}× ATR)"),
                    target="trailing",
                    extra_metadata={
                        "trailing_atr_mult": TRAILING_ATR_MULT,
                        "atr_used": atr,
                        "progress_pct": progress,
                        "tp_distance": tp_distance,
                        "raw_sl_price": trailing_sl_raw,
                        "trigger": "trailing",
                    },
                )
            # TP too tight relative to trailing -> skip trailing, wait TP

        # ── 6. RSI50 CROSS PARTIAL + SET_BE ──────────────────────
        rsi_now = float(tech.rsi)
        reached_mean = (
            (is_long and rsi_now >= RSI_BUY_REACHED_MEAN) or
            ((not is_long) and rsi_now <= RSI_SELL_REACHED_MEAN)
        )
        if (reached_mean and net_profit > 0
                and progress > RSI50_PARTIAL_MIN_PROGRESS_PCT
                and not rt.rsi50_partial_done):
            # Tick-align BE price at emit site (brain_base._move_sl
            # contract: prices in MOVE_SL/BE metadata MUST be tick-
            # aligned). entry.entry_price comes from broker fill so is
            # usually on-grid, but DRY mid-spread and any future
            # slippage-correction path can produce off-grid values that
            # broker silently rejects (V16 incident 29 apr).
            be_price = self._round_to_tick(
                entry.entry_price, float(tech.tick_size),
            )
            return BrainDecision(
                action=TradeAction.PARTIAL_50.value,
                reason=(f"RSI50 crossing ({rsi_now:.1f}) — partial 50% + BE"),
                metadata={
                    "trigger": "rsi50_cross",
                    "set_be_after_partial": True,
                    "be_price": be_price,
                    "rsi50_partial_done": True,   # orchestrator flips runtime
                    "progress_pct": progress,
                },
            )

        # ── 7. AUTO PARTIAL 50% + SL→BE ──────────────────────────
        # V18: soglia 65→50 + set_be_after_partial per parità con Brain TF.
        # Be_price tick-aligned all'emit site (stessa disciplina di RSI50
        # PARTIAL sopra — l'entry_price può non essere on-grid in DRY/slippage).
        if progress >= AUTO_PARTIAL_PROGRESS_PCT and not rt.partial_done:
            be_price = self._round_to_tick(
                entry.entry_price, float(tech.tick_size),
            )
            return BrainDecision(
                action=TradeAction.PARTIAL_50.value,
                reason=f"TP {progress:.0f}% — partial auto MR + SL→BE",
                metadata={
                    "trigger": "auto_partial_50",
                    "set_be_after_partial": True,
                    "be_price": be_price,
                    "progress_pct": progress,
                },
            )

        # ── 8. SAME-CANDLE DEDUP ─────────────────────────────────
        candle_time = float(getattr(tech, "candle_time", 0) or 0)
        if candle_time and candle_time <= rt.last_exit_eval_time:
            return self._hold(
                reason="stessa candela — AI già valutata",
                metadata={"trigger": "dedup"},
            )

        # ── 9. AI EXIT PROMPT ────────────────────────────────────
        signals = extract_pa_signals(tech)
        score = score_pa(signals, direction)
        regime_alert = self._regime_alert(entry, tech, is_long, minutes_open)
        toward_mean = (
            (is_long and rsi_now > entry.rsi_m5_at_entry and entry.rsi_m5_at_entry < 40) or
            ((not is_long) and rsi_now < entry.rsi_m5_at_entry and entry.rsi_m5_at_entry > 60)
        )

        prompt = self._build_exit_prompt(
            ctx=ctx, signals=signals, score=score,
            progress=progress, toward_mean=toward_mean,
            regime_alert=regime_alert,
            grace_limit=grace_limit, session=session,
        )

        resp = await self.ai.ask_for_decision(
            prompt, max_tokens=400, where="manage_exit",
        )
        if resp.text is None:
            return self._hold(
                reason=f"AI error ({resp.error_kind}) — hold default",
                metadata={"trigger": "ai_error", "error_kind": resp.error_kind},
            )
        result = self._parse_ai_json(resp.text)
        if not result or "exit_now" not in result:
            return self._hold(
                reason="AI malformed JSON — hold default",
                metadata={"trigger": "ai_malformed"},
            )

        meta = {
            "brain": "MR",
            "trigger": "ai_exit_eval",
            "session": session,
            "evaluated_candle_time": candle_time if candle_time else None,
            "progress_pct": progress,
            "toward_mean": toward_mean,
        }
        if bool(result.get("exit_now")):
            return self._exit(
                reason=str(result.get("reason", "AI exit"))[:120],
                metadata=meta,
            )
        if bool(result.get("partial_close")):
            return BrainDecision(
                action=TradeAction.PARTIAL_50.value,
                reason=str(result.get("reason", "AI partial"))[:120],
                metadata={**meta, "trigger": "ai_partial"},
            )
        return BrainDecision(
            action=TradeAction.HOLD.value,
            reason=str(result.get("reason", "AI hold"))[:120],
            metadata=meta,
        )

    # ============================================================
    # EXIT HELPERS
    # ============================================================

    @staticmethod
    def _compute_progress_pct(entry, tech: dict) -> float:
        """Progress toward TP, in %."""
        ep = entry.entry_price
        tp = entry.tp_price
        price = float(tech.price)
        if not tp or tp == ep:
            return 0.0
        if entry.direction == Direction.BUY.value:
            return (price - ep) / (tp - ep) * 100.0
        return (ep - price) / (ep - tp) * 100.0

    @staticmethod
    def _regime_alert(entry, tech: dict, is_long: bool, minutes_open: float) -> str:
        """V15 regime-flip detection (only RANGING -> TRENDING after grace)."""
        regime_entry = entry.regime_at_entry
        regime_now = tech.regime
        struct_entry = entry.market_structure_at_entry
        struct_now = tech.market_structure

        regime_flipped = False
        if minutes_open >= 1:
            if is_long:
                if (regime_now == "TRENDING" and regime_entry == "RANGING"
                        and "BEARISH" in str(struct_now)):
                    regime_flipped = True
            else:
                if (regime_now == "TRENDING" and regime_entry == "RANGING"
                        and "BULLISH" in str(struct_now)):
                    regime_flipped = True

        if regime_flipped:
            return (f"🚨 REGIME CAMBIATO CONTRO POSIZIONE: "
                    f"{struct_entry}/{regime_entry} -> {struct_now}/{regime_now}. "
                    f"CHIUDI — presupposto MR invalidato.")
        if struct_now != struct_entry:
            return (f"⚠️ Struttura H1 cambiata ({struct_entry} -> {struct_now}) "
                    f"— valuta se inversione reale.")
        return "✅ Struttura invariata — condizioni MR originali valide."

    # ============================================================
    # EXIT PROMPT BUILDER
    # ============================================================

    @staticmethod
    def _build_exit_prompt(
        *,
        ctx: BrainContext,
        signals: PASignals,
        score: PAScore,
        progress: float,
        toward_mean: bool,
        regime_alert: str,
        grace_limit: float,
        session: str,
    ) -> str:
        """
        V17 narrative anti-hallucination exit prompt (MR).

        Default conservativo = HOLD (V15 philosophy preservata). Quattro
        sezioni narrative:
          1. Contesto entry originale (snapshot al fill, zona estrema RSI)
          2. Situazione attuale (P&L, progress vs TP, RSI multi-TF entry→ora,
             toward_mean, struttura/regime now vs entry, ATR, MACD,
             divergenza, sessione + grace_limit)
          3. Price action attuale (PA dominant, favor/adverse, volume,
             absorption, candle strength, pattern attivi, VWAP + distanza
             target)
          4. Domanda rigida + regole anti-allucinazione (3-action output)

        Anti-hallucination guardrails (V17):
          - Risposta SOLO con i numeri/flag forniti — niente livelli/swing
            inventati, nessuna speculazione su candele non elencate
          - Se incerto → HOLD (default)
          - EXIT difensivo richiede ALMENO 1 cambiamento NUOVO (regime
            cambiato O struttura invertita); single-signal exits rifiutati
          - PARTIAL_50 richiede progress > 50% E segnali di esaurimento

        Specifici V16 preservati:
          - regime_alert (V15 RANGING→TRENDING-against-position detection)
          - deep_discount H4 paradox → h4_rule dedicato in sez. 2
          - VWAP target_reached flag → labellato in sez. 3
          - Grace period for indices (grace_limit visibile in sez. 2)
          - toward_mean signal preserved (anti-allucinazione: domanda rigida)

        Output JSON invariato:
          {"exit_now": bool, "partial_close": bool, "reason": str}.
        """
        entry = ctx.entry
        rt = ctx.runtime
        tech = ctx.tech_now
        is_long = entry.direction == Direction.BUY.value
        trade_type = "LONG" if is_long else "SHORT"

        # ── RSI multi-TF: entry vs ora ────────────────────────────
        rsi_m5_now = float(tech.rsi)
        rsi_h1_now = float(tech.rsi_h1)
        rsi_h4_now = float(tech.rsi_h4)
        rsi_m5_entry = entry.rsi_m5_at_entry
        rsi_h1_entry = entry.rsi_h1_at_entry
        rsi_h4_entry = entry.rsi_h4_at_entry

        # ── RSI zone at entry (MR-specific narrative) ─────────────
        # BUY MR: entry typically oversold (rsi < 32 RSI_MR_OVERSOLD).
        # SELL MR: typically overbought (rsi > 68 RSI_MR_OVERBOUGHT).
        rsi_zone_at_entry = "oversold" if is_long else "overbought"

        # ── H4 paradox / delta rule (V16 calibrated) ──────────────
        deep_discount = (
            (is_long       and rsi_h4_entry < 25) or
            ((not is_long) and rsi_h4_entry > 75)
        )
        if deep_discount:
            h4_rule = (
                f"⚠️ DEEP DISCOUNT (H4 entry {rsi_h4_entry:.1f}): NON usare "
                f"livello assoluto come motivo exit. Solo se H4 era migliorato "
                f"e ora ricaduto, o sceso sotto {rsi_h4_entry-5:.0f} (LONG) / "
                f"salito sopra {rsi_h4_entry+5:.0f} (SHORT)."
            )
        else:
            delta = rsi_h4_now - rsi_h4_entry
            warn = ""
            if is_long and delta < -10:
                warn = " ⚠️ Peggioramento H4 — valuta inversione strutturale."
            elif (not is_long) and delta > 10:
                warn = " ⚠️ Peggioramento H4 — valuta inversione strutturale."
            h4_rule = (
                f"RSI H4: entry {rsi_h4_entry:.1f} → ora {rsi_h4_now:.1f} "
                f"({delta:+.1f}).{warn}"
            )

        # ── VWAP context (target for MR) ──────────────────────────
        vwap_dev = float(tech.vwap_deviation_pct)
        vwap_target_reached = (
            (is_long       and vwap_dev > 0.1) or
            ((not is_long) and vwap_dev < -0.1)
        )
        vwap_label = (
            "VWAP target raggiunto/superato" if vwap_target_reached
            else "non ancora a target"
        )

        # ── PA / absorption alignment with trade direction ────────
        pa_pro_dir = (
            (is_long       and score.dominant == "BULLISH") or
            ((not is_long) and score.dominant == "BEARISH")
        )
        if pa_pro_dir:
            pa_align = "favor"
        elif score.dominant in ("BULLISH", "BEARISH"):
            pa_align = "adverse"
        else:
            pa_align = "neutro"
        abs_pro_dir = (
            (is_long       and tech.buy_absorption) or
            ((not is_long) and tech.sell_absorption)
        )

        favor_str = ", ".join(score.favor_direction) or "nessuno"
        adverse_str = ", ".join(score.adverse_direction) or "nessuno"

        # ── Active candle patterns ────────────────────────────────
        pattern_flags: list[str] = []
        if tech.hammer:         pattern_flags.append("hammer")
        if tech.shooting_star:  pattern_flags.append("shooting_star")
        if tech.bull_engulfing: pattern_flags.append("bull_engulfing")
        if tech.bear_engulfing: pattern_flags.append("bear_engulfing")
        if tech.piercing:       pattern_flags.append("piercing")
        if tech.dark_cloud:     pattern_flags.append("dark_cloud")
        if tech.morning_star:   pattern_flags.append("morning_star")
        if tech.evening_star:   pattern_flags.append("evening_star")
        if tech.doji:
            pattern_flags.append(f"doji({tech.doji_type or 'std'})")
        pattern_str = ", ".join(pattern_flags) if pattern_flags else "NESSUNO"

        # ── SL distance % from current price ──────────────────────
        sl_dist_pct = 0.0
        if entry.entry_price:
            price = float(tech.price)
            if is_long:
                sl_dist_pct = (price - entry.sl_price) / entry.entry_price * 100
            else:
                sl_dist_pct = (entry.sl_price - price) / entry.entry_price * 100

        # ── Asset digits per consistent number formatting ─────────
        spec = cfg_fut.ASSETS_MAP.get(entry.symbol, {})
        digits = int(spec.get("digits", 5))

        return f"""\
Sei un gestore di posizioni Mean Reversion. Decidi FULL EXIT, PARTIAL_50
o HOLD. Default conservativo = HOLD. Rispondi SOLO con JSON valido —
zero testo fuori dal JSON.

═══ 1. CONTESTO ENTRY ORIGINALE ═══
{entry.symbol} trade {trade_type} aperto {rt.minutes_open:.0f} minuti fa.
Entry {entry.entry_price:.{digits}f}, SL {entry.sl_price:.{digits}f}
(distanza dal prezzo attuale {sl_dist_pct:+.2f}%), TP {entry.tp_price:.{digits}f}.
Confidence: {entry.confidence_at_entry}%. RSI M5 entry: {rsi_m5_entry:.1f}
(zona estrema MR). Setup: mean reversion da zona {rsi_zone_at_entry}.
RSI H4 entry: {rsi_h4_entry:.1f}. Struttura H1 al fill:
{entry.market_structure_at_entry}. Regime al fill: {entry.regime_at_entry}.
Deep Discount: {"SÌ" if deep_discount else "NO"}.

═══ 2. SITUAZIONE ATTUALE ═══
P&L netto      : ${rt.net_profit_usd:.2f}
Progress vs TP : {progress:.1f}%
Tempo aperto   : {rt.minutes_open:.0f}m  (sessione {session}, grace_limit {grace_limit:.0f}m)
Prezzo attuale : {tech.price:.{digits}f}

RSI multi-TF (entry → ora):
  M5: {rsi_m5_entry:.1f} → {rsi_m5_now:.1f} ({rsi_m5_now - rsi_m5_entry:+.1f})
  H1: {rsi_h1_entry:.1f} → {rsi_h1_now:.1f} ({rsi_h1_now - rsi_h1_entry:+.1f})
  H4: {rsi_h4_entry:.1f} → {rsi_h4_now:.1f} ({rsi_h4_now - rsi_h4_entry:+.1f})
{h4_rule}
toward_mean (prezzo si sta avvicinando alla media?): {"SI — reversion in corso" if toward_mean else "NO — allontanamento o stallo"}

Struttura H1 ora    : {tech.market_structure}
Struttura H1 entry  : {entry.market_structure_at_entry}
Regime ora          : {tech.regime}
Regime entry        : {entry.regime_at_entry}
{regime_alert}

ATR Ratio  : {tech.atr_ratio:.2f}x
Vol Regime : {tech.vol_regime}
Vol Spike  : {"SI" if tech.vol_spike else "NO"}
MACD       : {"decelerando" if tech.macd_decelerating else "accelerando"}
Divergenza : {tech.divergence}

═══ 3. PRICE ACTION ATTUALE ═══
PA dominante      : {score.dominant} (forza {score.strength:.1f}x) — {pa_align} a {trade_type}
  Bullish/Bearish : {score.bullish_score:.1f} / {score.bearish_score:.1f}
  FAVOR {trade_type:5}  : {favor_str}
  ADVERSE {trade_type:5}: {adverse_str}
Volume            : {"DEBOLE" if signals.volume_weak else "NORMALE"} (volume_weak={signals.volume_weak})
Absorption pro {trade_type}: {"SI" if abs_pro_dir else "NO"}
                    (BUY_ABS={tech.buy_absorption}, SELL_ABS={tech.sell_absorption})
Candle Strength   : {signals.candle_strength:.2f}x (1.0 = media)
Pattern attivi    : {pattern_str}

VWAP intra-day    : {tech.vwap:.{digits}f}
VWAP deviation    : {vwap_dev:+.2f}% — {vwap_label}

═══ 4. DOMANDA RIGIDA — ANTI-ALLUCINAZIONE MR ═══
Il prezzo sta tornando verso la media come previsto? Basandoti SOLO sui
dati: toward_mean={toward_mean}, progress={progress:.1f}%,
P&L=${rt.net_profit_usd:.2f}.

EXIT difensivo solo se il regime è cambiato O la struttura si è invertita
contro il trade. PARTIAL_50 se progress > 50% e segnali di esaurimento.
HOLD se il trade è ancora nella direzione attesa. Non ipotizzare — usa
SOLO i dati forniti.

Esempi di trigger oggettivi citabili dal contesto sopra:
  • EXIT difensivo (richiede ALMENO 1 cambiamento NUOVO):
    - regime_alert con 🚨 REGIME CAMBIATO CONTRO POSIZIONE (sez. 2)
    - struttura H1 invertita CONTRO la direzione (sez. 2)
    - candela gigante contro (candle_strength > 2.5x) + vol_spike (sez. 3)
    - divergenza contro + MACD accelerando contro + struct H1 invertita (tutti)
    - toward_mean=NO E tempo aperto > grace_limit ({grace_limit:.0f}m) (sez. 2)
  • EXIT profitto (mean-reversion completata):
    - RSI M5 ha superato 50 E P&L > 0 E progress > 40% (sez. 2)
    - VWAP target raggiunto E P&L > 0 E progress > 40% (sez. 2 + 3)
    - progress > 75% (sez. 2)
    - P&L > 0 E toward_mean INVERTITO da SI a NO (sez. 2)
  • PARTIAL_50 (progress consolidato + esaurimento):
    - progress > 50% E almeno 1 segnale esaurimento (volume_weak,
      candle_strength < 0.8x, divergenza contro entry direction)

REGOLE OPERATIVE:
  • Rispondi SOLO con i dati forniti — non inventare livelli, swing,
    pattern non elencati, né target ipotetici
  • Se incerto → HOLD (default conservativo)
  • EXIT difensivo richiede ALMENO 1 cambiamento NUOVO rispetto all'entry
  • Single-signal exits sono RIFIUTATI a priori (es. "RSI estremo" da solo,
    "VWAP contro" da solo, "struct invertita se era già così" → HOLD)
  • Setup era valido all'entry (conf {entry.confidence_at_entry}%) — chiudi
    SOLO per qualcosa di NUOVO
  {"• Deep discount: NON usare livello H4 assoluto, applica solo h4_rule sez. 2" if deep_discount else ""}
  • exit_now e partial_close NON possono essere entrambi true

═══ OUTPUT (SOLO JSON) ═══
{{
  "exit_now": false,
  "partial_close": false,
  "reason": "max 12 parole — cita dati specifici (es. 'regime flip + struct invertita + adverse PA 1.8x')"
}}
"""

    # ============================================================
    # HELPERS (duplicated from brain_tf — rule of three)
    # ============================================================

    @staticmethod
    def _parse_ai_json(text: str) -> Optional[dict]:
        try:
            return json.loads(extract_json_from_response(text))
        except (json.JSONDecodeError, TypeError):
            return None

    def _reject(
        self,
        symbol: str,
        direction: str,
        rule: str,
        detail: str,
        *,
        tech=None,
        **fields,
    ) -> None:
        """V18 12-mag — `tech` opzionale: arricchisce l'evento con i campi
        RADAR (rsi_m5/rsi_h4/h1_compat/macd_*/pattern/atr_ratio/bias/...).
        Eventuali `fields` espliciti hanno precedenza sui campi auto-derivati.
        """
        merged_fields = {**tech_log_fields(tech), **fields}
        if self.logger is not None:
            try:
                self.logger.brain_log.write(
                    "entry_rejected",
                    symbol=symbol,
                    brain="MR",
                    direction=direction,
                    rule=rule,
                    reason=detail,
                    **merged_fields,
                )
            except AttributeError:
                self.logger.system.info(
                    f"[MR reject] {symbol} {direction} {rule}: {detail} "
                    f"fields={merged_fields}"
                )
        else:
            _log.debug(
                "[MR reject] %s %s %s: %s fields=%s",
                symbol, direction, rule, detail, merged_fields,
            )
        return None
