"""Fill simulation for entries and exits, plus shared execution helpers."""
from __future__ import annotations

import datetime as dt
import math
import uuid
from typing import Any

from libs.backtest.domain import (
    BacktestConfig,
    Candidate,
    ExecutionConfig,
    ExitReason,
    FilledTrade,
    OpenPosition,
    PlannedOrder,
    PositionStatus,
)
from libs.common.logging import get_logger

logger = get_logger(__name__)


def _candidate_tail_risk_components(candidate: Candidate) -> list[float]:
    components: list[float] = []
    features = candidate.features

    reaction = features.get("reaction_day_return")
    if reaction is not None:
        try:
            reaction_val = abs(float(reaction))
        except (TypeError, ValueError):
            reaction_val = None
        if reaction_val is not None:
            components.append(min(1.0, reaction_val / 0.12))

    oneoff = features.get("oneoff_penalty")
    if oneoff is not None:
        try:
            oneoff_val = float(oneoff)
        except (TypeError, ValueError):
            oneoff_val = None
        if oneoff_val is not None:
            components.append(min(1.0, max(0.0, oneoff_val)))

    market_temperature = features.get("pre_event_market_temperature")
    if market_temperature is not None:
        try:
            temp_val = float(market_temperature)
        except (TypeError, ValueError):
            temp_val = None
        if temp_val is not None:
            components.append(min(1.0, max(0.0, (temp_val - 0.6) / 0.9)))

    entropy = features.get("pre_event_entropy_60d")
    if entropy is not None:
        try:
            entropy_val = float(entropy)
        except (TypeError, ValueError):
            entropy_val = None
        if entropy_val is not None:
            components.append(min(1.0, max(0.0, (entropy_val - 1.5) / 0.7)))

    return components


# ---------------------------------------------------------------------------
# Slippage helpers
# ---------------------------------------------------------------------------


def _long_entry_fill(open_price: float, slippage_bps: float) -> float:
    """Buy at open + slippage (pays more)."""
    return open_price * (1.0 + slippage_bps / 10_000)


def _long_exit_fill(price: float, slippage_bps: float) -> float:
    """Sell at price - slippage (receives less)."""
    return price * (1.0 - slippage_bps / 10_000)


def _short_entry_fill(open_price: float, slippage_bps: float) -> float:
    """Sell short at open - slippage (receives less)."""
    return open_price * (1.0 - slippage_bps / 10_000)


def _short_exit_fill(price: float, slippage_bps: float) -> float:
    """Buy to cover at price + slippage (pays more)."""
    return price * (1.0 + slippage_bps / 10_000)


# ---------------------------------------------------------------------------
# Entry simulation
# ---------------------------------------------------------------------------


def simulate_entry(
    plan: PlannedOrder,
    bar: dict[str, Any],
    config: ExecutionConfig,
    position_id: str | None = None,
) -> OpenPosition | None:
    """Simulate filling a planned entry at the bar's open or reaction close.

    Returns None (position NOT opened) if bar is missing or the required price is invalid.
    No zero imputation — missing bar = no entry.
    """
    if bar is None:
        logger.warning("entry_skip_missing_bar", event_id=plan.candidate.event_id)
        return None

    if plan.skip_reason is not None:
        logger.debug("entry_skip_gate_rejected", reason=plan.skip_reason)
        return None

    if plan.shares <= 0:
        logger.warning("entry_skip_zero_shares", event_id=plan.candidate.event_id)
        return None

    entry_policy = plan.entry_timing_policy or "next_open"
    if entry_policy == "reaction_close":
        reference_price = bar.get("close")
        if reference_price is None or reference_price <= 0:
            logger.warning("entry_skip_invalid_close", event_id=plan.candidate.event_id, bar=bar)
            return None
    else:
        reference_price = bar.get("open")
        if reference_price is None or reference_price <= 0:
            logger.warning("entry_skip_invalid_open", event_id=plan.candidate.event_id, bar=bar)
            return None

    is_short = plan.candidate.trade_direction == "short"
    if is_short:
        fill_price = _short_entry_fill(float(reference_price), config.slippage_bps_base)
    else:
        fill_price = _long_entry_fill(float(reference_price), config.slippage_bps_base)
    slippage_bps_actual = abs(fill_price / float(reference_price) - 1.0) * 10_000

    pid = position_id or str(uuid.uuid4())

    # entry_date here is the bar date (execution_date of the candidate)
    bar_date_raw = bar.get("date")
    if isinstance(bar_date_raw, str):
        entry_date = dt.date.fromisoformat(bar_date_raw)
    elif isinstance(bar_date_raw, dt.date):
        entry_date = bar_date_raw
    else:
        entry_date = plan.candidate.execution_date

    return OpenPosition(
        position_id=pid,
        plan=plan,
        entry_date=entry_date,
        entry_price=fill_price,
        entry_fill_slippage_bps=slippage_bps_actual,
        current_stop=plan.stop_price,
        target_price=plan.target_price,
        peak_price=fill_price,
        shares_open=plan.shares,
        shares_total=plan.shares,
        parent_position_id=plan.parent_position_id,
        is_add_on=plan.is_add_on,
        days_held=0,
        status=PositionStatus.ENTERED,
    )


# ---------------------------------------------------------------------------
# Exit simulation
# ---------------------------------------------------------------------------


def simulate_exit(
    position: OpenPosition,
    bar: dict[str, Any],
    config: ExecutionConfig,
    current_date: dt.date,
) -> FilledTrade | None:
    """Check if position should exit on this bar. Returns FilledTrade or None.

    Handles:
    - Stop loss (low ≤ stop_price)
    - Target (high ≥ target_price) — with partial exit support
    - Same-bar conflict (controlled by same_bar_priority)
    - Time exit (days_held >= max_holding_days)
    - Kill switch / missing bar handled upstream

    Partial exits: when target_1_fraction < 1.0 and target is hit, exits only
    that fraction, moves stop to breakeven for remaining shares, and returns
    the partial FilledTrade. Remaining shares continue with trailing stop.

    Slippage is applied in the unfavorable direction for long positions.
    """
    if bar is None:
        return None

    bar_low = bar.get("low")
    bar_high = bar.get("high")
    bar_close = bar.get("close")
    slippage = config.slippage_bps_base
    is_short = position.plan.candidate.trade_direction == "short"

    # Direction-aware stop/target detection
    if is_short:
        # Short: stop is above entry (hit when price goes up), target below entry (hit when price drops)
        stop_hit = bar_high is not None and float(bar_high) >= position.current_stop
        target_hit = bar_low is not None and float(bar_low) <= position.target_price
        exit_fill_fn = _short_exit_fill
    else:
        # Long: stop below entry (hit when price drops), target above entry (hit when price rises)
        stop_hit = bar_low is not None and float(bar_low) <= position.current_stop
        target_hit = bar_high is not None and float(bar_high) >= position.target_price
        exit_fill_fn = _long_exit_fill

    exit_reason: ExitReason | None = None
    exit_fill_price: float | None = None

    if stop_hit and target_hit:
        # Same-bar conflict
        if config.same_bar_priority == "stop_first_conservative":
            exit_reason = ExitReason.STOP
            exit_fill_price = exit_fill_fn(position.current_stop, slippage)
        else:  # target_first_aggressive
            exit_reason = ExitReason.TARGET
            exit_fill_price = exit_fill_fn(position.target_price, slippage)
    elif stop_hit:
        exit_reason = ExitReason.STOP
        exit_fill_price = exit_fill_fn(position.current_stop, slippage)
    elif target_hit:
        exit_reason = ExitReason.TARGET
        exit_fill_price = exit_fill_fn(position.target_price, slippage)
    elif position.days_held >= config.max_holding_days:
        # Dynamic hold: extend mhd for positions with strong R
        effective_mhd = config.max_holding_days
        if config.dynamic_hold_enabled and bar_close is not None:
            close_val = float(bar_close)
            stop_dist = abs(position.entry_price - position.plan.stop_price)
            if stop_dist > 0:
                unrealized_r = (
                    (position.entry_price - close_val) / stop_dist if is_short
                    else (close_val - position.entry_price) / stop_dist
                )
                if unrealized_r >= config.dynamic_hold_extend_r:
                    effective_mhd = config.dynamic_hold_extend_to

        if position.days_held >= effective_mhd:
            exit_reason = ExitReason.TIME
            if bar_close is not None and float(bar_close) > 0:
                exit_fill_price = exit_fill_fn(float(bar_close), slippage)
            else:
                exit_fill_price = position.entry_price

    # Dynamic hold: early cut for underwater positions
    if (
        exit_reason is None
        and config.dynamic_hold_enabled
        and config.dynamic_hold_checkpoints
        and bar_close is not None
    ):
        close_val = float(bar_close)
        stop_dist = abs(position.entry_price - position.plan.stop_price)
        if stop_dist > 0:
            unrealized_r = (
                (position.entry_price - close_val) / stop_dist if is_short
                else (close_val - position.entry_price) / stop_dist
            )
            for check_day, min_r in config.dynamic_hold_checkpoints:
                if position.days_held >= check_day and unrealized_r < min_r:
                    exit_reason = ExitReason.NO_PROGRESS
                    exit_fill_price = exit_fill_fn(close_val, slippage)
                    break

    # No-follow-through early exit
    # Long: D+1 close < entry. Short: D+1 close > entry.
    if (
        exit_reason is None
        and config.no_follow_through_exit
        and position.days_held == 1
        and bar_close is not None
    ):
        close_val = float(bar_close)
        nft_triggered = (close_val > position.entry_price) if is_short else (close_val < position.entry_price)
        if nft_triggered:
            exit_reason = ExitReason.NO_FOLLOW_THROUGH
            exit_fill_price = exit_fill_fn(close_val, slippage)

    if (
        exit_reason is None
        and config.expected_decay_exit_enabled
        and config.expected_decay_lambda is not None
        and config.expected_decay_score_floor is not None
        and bar_close is not None
        and position.days_held >= max(1, int(config.expected_decay_min_days_held))
    ):
        residual_signal = position.plan.candidate.score * math.exp(
            -float(config.expected_decay_lambda) * float(position.days_held)
        )
        if residual_signal < float(config.expected_decay_score_floor):
            exit_reason = ExitReason.DECAY
            exit_fill_price = exit_fill_fn(float(bar_close), slippage)

    if exit_reason is None or exit_fill_price is None:
        return None

    # --- Partial exit logic ---
    fraction = config.target_1_fraction
    if (
        exit_reason == ExitReason.TARGET
        and fraction is not None
        and 0.0 < fraction < 1.0
        and position.status != PositionStatus.PARTIALLY_EXITED
    ):
        partial_shares = max(1, math.floor(position.shares_open * fraction))
        remaining_shares = position.shares_open - partial_shares

        if remaining_shares > 0:
            # Build partial fill trade
            partial_trade = _build_filled_trade_partial(
                position, exit_fill_price, exit_reason, current_date, config,
                shares=partial_shares,
            )

            # Mutate position: reduce shares, move stop to breakeven, mark partial
            position.shares_open = remaining_shares
            position.current_stop = position.entry_price  # breakeven stop
            position.status = PositionStatus.PARTIALLY_EXITED
            position.partial_fills.append(partial_trade)

            return partial_trade

    return _build_filled_trade(position, exit_fill_price, exit_reason, current_date, config)


def simulate_kill_switch_exit(
    position: OpenPosition,
    bar: dict[str, Any] | None,
    current_date: dt.date,
    config: ExecutionConfig,
    *,
    exit_reason: ExitReason = ExitReason.KILL_SWITCH,
) -> FilledTrade:
    """Force-close a position due to kill switch or end-of-backtest."""
    slippage = config.slippage_bps_base
    is_short = position.plan.candidate.trade_direction == "short"
    exit_fill_fn = _short_exit_fill if is_short else _long_exit_fill
    if bar is not None and bar.get("close") is not None:
        exit_price = exit_fill_fn(float(bar["close"]), slippage)
    else:
        exit_price = position.entry_price  # last known price fallback

    return _build_filled_trade(
        position, exit_price, exit_reason, current_date, config
    )


def simulate_recycle_close_exit(
    position: OpenPosition,
    bar: dict[str, Any] | None,
    current_date: dt.date,
    config: ExecutionConfig,
) -> FilledTrade | None:
    """Close a position at the current close to recycle capital into a stronger candidate."""
    if bar is None or bar.get("close") is None:
        return None
    slippage = config.slippage_bps_base
    is_short = position.plan.candidate.trade_direction == "short"
    exit_fill_fn = _short_exit_fill if is_short else _long_exit_fill
    exit_price = exit_fill_fn(float(bar["close"]), slippage)
    return _build_filled_trade(
        position,
        exit_price,
        ExitReason.RECYCLE,
        current_date,
        config,
    )


def simulate_rotation_exit(
    position: OpenPosition,
    bar: dict[str, Any] | None,
    current_date: dt.date,
    config: ExecutionConfig,
) -> FilledTrade | None:
    """Close a position to rotate capital into a better opportunity."""
    if bar is None or bar.get("close") is None:
        return None
    slippage = config.slippage_bps_base
    is_short = position.plan.candidate.trade_direction == "short"
    exit_fill_fn = _short_exit_fill if is_short else _long_exit_fill
    exit_price = exit_fill_fn(float(bar["close"]), slippage)
    return _build_filled_trade(
        position, exit_price, ExitReason.ROTATION, current_date, config,
    )


def simulate_missing_bar_exit(
    position: OpenPosition,
    current_date: dt.date,
    config: ExecutionConfig,
) -> FilledTrade:
    """Close a position when bar data is unavailable for too long."""
    return _build_filled_trade(
        position, position.entry_price, ExitReason.MISSING_BAR, current_date, config
    )


def simulate_scheduled_open_exit(
    position: OpenPosition,
    bar: dict[str, Any] | None,
    config: ExecutionConfig,
    current_date: dt.date,
    reason: str,
    fraction: float = 1.0,
) -> FilledTrade | None:
    """Execute a queued next-open exit generated by prior close logic."""
    if bar is None or bar.get("open") is None:
        return None

    open_price = float(bar["open"])
    is_short = position.plan.candidate.trade_direction == "short"
    exit_fill_fn = _short_exit_fill if is_short else _long_exit_fill
    exit_price = exit_fill_fn(open_price, config.slippage_bps_base)
    if reason == "EARLY_FAILURE":
        exit_reason = ExitReason.EARLY_FAILURE
    elif reason == "GIVEBACK":
        exit_reason = ExitReason.GIVEBACK
    elif reason == "DIVIDEND_CAPTURE":
        exit_reason = ExitReason.DIVIDEND_CAPTURE
    else:
        exit_reason = ExitReason.NO_PROGRESS

    if 0.0 < fraction < 1.0 and position.shares_open > 1:
        partial_shares = max(1, math.floor(position.shares_open * fraction))
        remaining_shares = position.shares_open - partial_shares
        if remaining_shares > 0:
            trade = _build_filled_trade_partial(
                position,
                exit_price,
                exit_reason,
                current_date,
                config,
                shares=partial_shares,
            )
            position.shares_open = remaining_shares
            position.status = PositionStatus.PARTIALLY_EXITED
            position.partial_fills.append(trade)
            return trade

    trade = _build_filled_trade(position, exit_price, exit_reason, current_date, config)
    position.shares_open = 0
    position.status = PositionStatus.CLOSED
    return trade


# ---------------------------------------------------------------------------
# Trailing stop update
# ---------------------------------------------------------------------------


def update_trailing_stop(
    position: OpenPosition,
    bar: dict[str, Any],
    trailing_model: str = "bar_low",
    warmup_days: int = 0,
) -> None:
    """Ratchet stop towards price for trailing model. Mutates position in place.

    For long: ratchet stop UP (never down). Tracks peak_price as highest high.
    For short: ratchet stop DOWN (never up). Tracks peak_price as lowest low.

    Models:
      - "bar_low"/"bar_high": trail to bar extreme (tightest, aggressive)
      - "pct_3": trail at peak_price * (1 +/- 3%) -- moderate
      - "pct_5": trail at peak_price * (1 +/- 5%) -- wider

    Args:
        warmup_days: Skip trailing until position has been held this many days.
    """
    is_short = position.plan.candidate.trade_direction == "short"

    # Track best price (peak for long = highest high, for short = lowest low)
    if is_short:
        bar_low = bar.get("low")
        if bar_low is not None:
            position.peak_price = min(position.peak_price, float(bar_low))
    else:
        bar_high = bar.get("high")
        if bar_high is not None:
            position.peak_price = max(position.peak_price, float(bar_high))

    # Don't tighten stop during warmup period
    if position.days_held < warmup_days:
        return

    if is_short:
        # Short: trail stop DOWN towards price (tighter = lower stop)
        if trailing_model == "bar_low" or trailing_model == "bar_high":
            bar_high = bar.get("high")
            if bar_high is not None:
                position.current_stop = min(position.current_stop, float(bar_high))
        elif trailing_model.startswith("pct_"):
            try:
                trail_pct = float(trailing_model.split("_")[1]) / 100.0
            except (IndexError, ValueError):
                trail_pct = 0.03
            trail_stop = position.peak_price * (1.0 + trail_pct)
            position.current_stop = min(position.current_stop, trail_stop)
    else:
        # Long: trail stop UP (original behavior)
        if trailing_model == "bar_low":
            bar_low = bar.get("low")
            if bar_low is not None:
                new_stop = max(position.current_stop, float(bar_low))
                position.current_stop = new_stop
        elif trailing_model.startswith("pct_"):
            try:
                trail_pct = float(trailing_model.split("_")[1]) / 100.0
            except (IndexError, ValueError):
                trail_pct = 0.03
            trail_stop = position.peak_price * (1.0 - trail_pct)
            position.current_stop = max(position.current_stop, trail_stop)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _build_filled_trade_partial(
    position: OpenPosition,
    exit_price: float,
    exit_reason: ExitReason,
    exit_date: dt.date,
    config: ExecutionConfig,
    shares: int,
) -> FilledTrade:
    """Build a FilledTrade for a partial exit (specific share count)."""
    is_short = position.plan.candidate.trade_direction == "short"
    commission = shares * config.commission_per_share  # only exit leg for partial
    if is_short:
        gross_pnl = (position.entry_price - exit_price) * shares
    else:
        gross_pnl = (exit_price - position.entry_price) * shares
    net_pnl = gross_pnl - commission

    entry_price = position.entry_price
    if is_short:
        pnl_pct = (entry_price - exit_price) / entry_price if entry_price != 0 else 0.0
    else:
        pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0

    stop_distance = abs(entry_price - position.plan.stop_price)
    if stop_distance > 0:
        r_multiple = gross_pnl / shares / stop_distance
    else:
        r_multiple = 0.0

    holding_days = (exit_date - position.entry_date).days
    trade_id = str(uuid.uuid4())

    return FilledTrade(
        trade_id=trade_id,
        position_id=position.position_id,
        event_id=position.plan.candidate.event_id,
        symbol=position.plan.candidate.symbol,
        source_symbol=position.plan.candidate.source_symbol,
        event_date=position.plan.event_date or position.plan.candidate.event_date,
        event_type=position.plan.candidate.event_type or "",
        score=position.plan.candidate.score or 0.0,
        timing_class=position.plan.timing_class,
        engine_id=position.plan.engine_id,
        entry_timing_policy=position.plan.entry_timing_policy,
        shadow_only=position.plan.shadow_only,
        parent_position_id=position.parent_position_id,
        is_add_on=position.is_add_on,
        trade_symbol_mode=position.plan.candidate.trade_symbol_mode,
        entry_date=position.entry_date,
        exit_date=exit_date,
        entry_price=position.entry_price,
        exit_price=exit_price,
        exit_reason=exit_reason,
        shares=shares,
        commission=commission,
        slippage_bps=config.slippage_bps_base,
        gross_pnl=gross_pnl,
        net_pnl=net_pnl,
        pnl_pct=pnl_pct,
        r_multiple=r_multiple,
        holding_days=holding_days,
    )


def _build_filled_trade(
    position: OpenPosition,
    exit_price: float,
    exit_reason: ExitReason,
    exit_date: dt.date,
    config: ExecutionConfig,
) -> FilledTrade:
    is_short = position.plan.candidate.trade_direction == "short"
    shares = position.shares_open
    commission = shares * config.commission_per_share * 2  # entry + exit legs
    if is_short:
        gross_pnl = (position.entry_price - exit_price) * shares
    else:
        gross_pnl = (exit_price - position.entry_price) * shares
    net_pnl = gross_pnl - commission

    entry_price = position.entry_price
    if is_short:
        pnl_pct = (entry_price - exit_price) / entry_price if entry_price != 0 else 0.0
    else:
        pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0

    # R-multiple uses actual fill price
    stop_distance = abs(entry_price - position.plan.stop_price)
    if stop_distance > 0:
        r_multiple = gross_pnl / shares / stop_distance
    else:
        r_multiple = 0.0

    holding_days = (exit_date - position.entry_date).days

    trade_id = str(uuid.uuid4())

    return FilledTrade(
        trade_id=trade_id,
        position_id=position.position_id,
        event_id=position.plan.candidate.event_id,
        symbol=position.plan.candidate.symbol,
        source_symbol=position.plan.candidate.source_symbol,
        event_date=position.plan.event_date or position.plan.candidate.event_date,
        event_type=position.plan.candidate.event_type or "",
        score=position.plan.candidate.score or 0.0,
        timing_class=position.plan.timing_class,
        engine_id=position.plan.engine_id,
        entry_timing_policy=position.plan.entry_timing_policy,
        shadow_only=position.plan.shadow_only,
        parent_position_id=position.parent_position_id,
        is_add_on=position.is_add_on,
        trade_symbol_mode=position.plan.candidate.trade_symbol_mode,
        entry_date=position.entry_date,
        exit_date=exit_date,
        entry_price=position.entry_price,
        exit_price=exit_price,
        exit_reason=exit_reason,
        shares=shares,
        commission=commission,
        slippage_bps=config.slippage_bps_base,
        gross_pnl=gross_pnl,
        net_pnl=net_pnl,
        pnl_pct=pnl_pct,
        r_multiple=r_multiple,
        holding_days=holding_days,
    )


# ---------------------------------------------------------------------------
# Shared execution helpers (used by both BacktestRunner and PaperTradingEngine)
# ---------------------------------------------------------------------------


def build_effective_execution_config(
    candidate: Candidate,
    config: BacktestConfig,
) -> ExecutionConfig:
    """Resolve per-engine and per-event execution overrides.

    Shared by BacktestRunner and PaperTradingEngine to ensure identical
    stop/target/trailing behavior in both research and live trading.
    """
    execution_updates: dict[str, Any] = {}

    max_holding_days = candidate.engine_max_holding_days
    if max_holding_days is None:
        evt_profile = config.get_event_profile(candidate.event_type)
        if evt_profile and evt_profile.max_holding_days_override is not None:
            max_holding_days = evt_profile.max_holding_days_override
    # Shorten holding period for high-momentum entries
    if (
        max_holding_days is not None
        and config.risk.high_momentum_max_holding_days is not None
        and config.risk.high_momentum_holding_threshold is not None
    ):
        mom = candidate.features.get("pre_event_momentum_20d")
        if mom is not None and float(mom) > config.risk.high_momentum_holding_threshold:
            max_holding_days = min(max_holding_days, config.risk.high_momentum_max_holding_days)
    if max_holding_days is not None:
        execution_updates["max_holding_days"] = max_holding_days

    if candidate.engine_target_atr_multiplier is not None:
        execution_updates["target_atr_multiplier"] = candidate.engine_target_atr_multiplier
    if candidate.engine_trailing_model is not None:
        execution_updates["trailing_model"] = candidate.engine_trailing_model
    if candidate.engine_trailing_warmup_days is not None:
        execution_updates["trailing_warmup_days"] = candidate.engine_trailing_warmup_days
    if candidate.engine_early_failure_close_below_entry_and_reaction_close is not None:
        execution_updates["early_failure_close_below_entry_and_reaction_close"] = (
            candidate.engine_early_failure_close_below_entry_and_reaction_close
        )
    if candidate.engine_early_failure_no_progress_days is not None:
        execution_updates["early_failure_no_progress_days"] = (
            candidate.engine_early_failure_no_progress_days
        )
    if candidate.engine_early_failure_no_progress_r is not None:
        execution_updates["early_failure_no_progress_r"] = (
            candidate.engine_early_failure_no_progress_r
        )
    if candidate.engine_early_failure_no_progress_fraction is not None:
        execution_updates["early_failure_no_progress_fraction"] = (
            candidate.engine_early_failure_no_progress_fraction
        )

    if candidate.engine_dynamic_hold_checkpoints is not None:
        execution_updates["dynamic_hold_enabled"] = True
        execution_updates["dynamic_hold_checkpoints"] = [
            (int(cp[0]), float(cp[1])) for cp in candidate.engine_dynamic_hold_checkpoints
        ]
    if candidate.engine_dynamic_hold_extend_day is not None:
        execution_updates["dynamic_hold_enabled"] = True
        execution_updates["dynamic_hold_extend_day"] = candidate.engine_dynamic_hold_extend_day
    if candidate.engine_dynamic_hold_extend_r is not None:
        execution_updates["dynamic_hold_extend_r"] = candidate.engine_dynamic_hold_extend_r
    if candidate.engine_dynamic_hold_extend_to is not None:
        execution_updates["dynamic_hold_extend_to"] = candidate.engine_dynamic_hold_extend_to

    # Tiered targets: A-tier vs non-A-tier
    if config.execution.use_tiered_targets and config.signal.a_tier_score_threshold is not None:
        if candidate.score >= config.signal.a_tier_score_threshold:
            if config.execution.a_tier_target_1_r is not None:
                execution_updates["target_1_r"] = config.execution.a_tier_target_1_r
            if config.execution.a_tier_target_1_fraction is not None:
                execution_updates["target_1_fraction"] = config.execution.a_tier_target_1_fraction
        else:
            if config.execution.non_a_tier_target_1_r is not None:
                execution_updates["target_1_r"] = config.execution.non_a_tier_target_1_r
            if config.execution.non_a_tier_target_1_fraction is not None:
                execution_updates["target_1_fraction"] = config.execution.non_a_tier_target_1_fraction

    # Per-engine overrides (highest priority)
    if candidate.engine_target_1_r is not None:
        execution_updates["target_1_r"] = candidate.engine_target_1_r
    if candidate.engine_target_1_fraction is not None:
        execution_updates["target_1_fraction"] = candidate.engine_target_1_fraction

    # Adaptive exit: adjust trailing warmup based on close_location zone
    exec_cfg = config.execution
    if exec_cfg.adaptive_exit_enabled:
        cl = candidate.features.get("close_location")
        if cl is not None:
            try:
                cl_val = float(cl)
            except (TypeError, ValueError):
                cl_val = None
            if cl_val is not None:
                if cl_val >= exec_cfg.adaptive_exit_exhaustion_close_min:
                    execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_exhaustion_trailing_warmup
                elif exec_cfg.adaptive_exit_orderly_close_min <= cl_val <= exec_cfg.adaptive_exit_orderly_close_max:
                    execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_orderly_trailing_warmup

    # Tail-exit adjuster: tighten exits only for stacked tail-risk candidates.
    if config.risk.tail_exit_adjuster_enabled:
        tail_components = _candidate_tail_risk_components(candidate)
        if len(tail_components) >= max(1, config.risk.tail_exit_min_signals):
            tail_score = sum(tail_components) / len(tail_components)
            threshold = min(0.999, max(0.0, config.risk.tail_exit_threshold))
            if tail_score > threshold:
                current_mhd = execution_updates.get("max_holding_days", config.execution.max_holding_days)
                if config.risk.tail_exit_max_holding_days is not None and current_mhd is not None:
                    execution_updates["max_holding_days"] = min(
                        int(current_mhd),
                        int(config.risk.tail_exit_max_holding_days),
                    )

                current_no_progress_days = execution_updates.get(
                    "early_failure_no_progress_days",
                    config.execution.early_failure_no_progress_days,
                )
                if config.risk.tail_exit_no_progress_days is not None:
                    execution_updates["early_failure_no_progress_days"] = (
                        config.risk.tail_exit_no_progress_days
                        if current_no_progress_days is None
                        else min(int(current_no_progress_days), int(config.risk.tail_exit_no_progress_days))
                    )

                current_no_progress_r = execution_updates.get(
                    "early_failure_no_progress_r",
                    config.execution.early_failure_no_progress_r,
                )
                if config.risk.tail_exit_no_progress_r is not None:
                    execution_updates["early_failure_no_progress_r"] = (
                        config.risk.tail_exit_no_progress_r
                        if current_no_progress_r is None
                        else max(float(current_no_progress_r), float(config.risk.tail_exit_no_progress_r))
                    )

                current_no_progress_fraction = execution_updates.get(
                    "early_failure_no_progress_fraction",
                    config.execution.early_failure_no_progress_fraction,
                )
                if config.risk.tail_exit_no_progress_fraction is not None:
                    execution_updates["early_failure_no_progress_fraction"] = (
                        config.risk.tail_exit_no_progress_fraction
                        if current_no_progress_fraction is None
                        else max(float(current_no_progress_fraction), float(config.risk.tail_exit_no_progress_fraction))
                    )

    if not execution_updates:
        return config.execution
    return config.execution.model_copy(update=execution_updates)


def check_next_open_gap_cap(candidate: Candidate, bar: dict[str, Any] | None) -> str | None:
    """Reject next-open entries when the open gaps up more than the engine cap.

    Returns skip_reason string or None if the gap is acceptable.
    Shared by BacktestRunner and PaperTradingEngine.
    """
    if candidate.entry_timing_policy != "next_open":
        return None
    if candidate.engine_next_open_gap_cap_pct is None:
        return None
    if bar is None:
        return None

    open_price = bar.get("open")
    if open_price is None:
        return None

    entry_est = candidate.entry_price_est
    if entry_est is None or entry_est <= 0:
        return None

    gap_pct = (float(open_price) - entry_est) / entry_est
    if gap_pct > candidate.engine_next_open_gap_cap_pct:
        return f"next_open_gap_too_large:{gap_pct:.2%}>{candidate.engine_next_open_gap_cap_pct:.2%}"
    return None
