"""Position sizing, entry gates, and order planning for the backtester."""
from __future__ import annotations

import math
from typing import Any

from libs.backtest.domain import (
    BacktestConfig,
    Candidate,
    DailyPortfolioState,
    ExecutionConfig,
    EventTypeProfile,
    OpenPosition,
    PlannedOrder,
    RiskConfig,
)
from libs.common.logging import get_logger

logger = get_logger(__name__)

# Default drawdown kill-switch threshold (not in JSON schema)
_KILL_SWITCH_DRAWDOWN_PCT = 25.0


def _resolve_sizing_equity(portfolio_state: DailyPortfolioState) -> float:
    """Return sizing_equity if set, otherwise fall back to equity."""
    if portfolio_state.sizing_equity is not None:
        return portfolio_state.sizing_equity
    return portfolio_state.equity


def _dynamic_atr_scaler(candidate: Candidate, config: RiskConfig) -> float:
    """Compute a dynamic multiplier for the ATR stop based on reaction size and entropy.

    Returns a scaler applied on top of the base stop_atr_multiplier:
    - Small reaction + low entropy -> tighter stop (scaler < 1.0)
    - Large reaction + high entropy -> wider stop (scaler > 1.0)
    - Missing features gracefully fall back to 1.0.
    """
    if not config.dynamic_stop_enabled:
        return 1.0

    reaction = candidate.features.get("reaction_day_return") if candidate.features else None
    entropy = candidate.features.get("pre_event_entropy_60d") if candidate.features else None

    reaction_scaler = 1.0
    if reaction is not None:
        try:
            r = abs(float(reaction))
            if r <= config.dynamic_stop_reaction_low:
                reaction_scaler = config.dynamic_stop_reaction_scaler_low
            elif r >= config.dynamic_stop_reaction_high:
                reaction_scaler = config.dynamic_stop_reaction_scaler_high
            else:
                frac = (r - config.dynamic_stop_reaction_low) / (
                    config.dynamic_stop_reaction_high - config.dynamic_stop_reaction_low
                )
                reaction_scaler = config.dynamic_stop_reaction_scaler_low + frac * (
                    config.dynamic_stop_reaction_scaler_high - config.dynamic_stop_reaction_scaler_low
                )
        except (TypeError, ValueError):
            pass

    entropy_scaler = 1.0
    if entropy is not None:
        try:
            e = float(entropy)
            if e <= config.dynamic_stop_entropy_low:
                entropy_scaler = config.dynamic_stop_entropy_scaler_low
            elif e >= config.dynamic_stop_entropy_high:
                entropy_scaler = config.dynamic_stop_entropy_scaler_high
            else:
                frac = (e - config.dynamic_stop_entropy_low) / (
                    config.dynamic_stop_entropy_high - config.dynamic_stop_entropy_low
                )
                entropy_scaler = config.dynamic_stop_entropy_scaler_low + frac * (
                    config.dynamic_stop_entropy_scaler_high - config.dynamic_stop_entropy_scaler_low
                )
        except (TypeError, ValueError):
            pass

    combined = reaction_scaler * entropy_scaler
    return max(config.dynamic_stop_combined_floor, min(config.dynamic_stop_combined_ceiling, combined))


def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float:
    """Compute stop price based on ATR-14 or a percentage fallback.

    Uses entry_price_est (reaction close) as the price basis.
    For long: stop below entry. For short: stop above entry.
    Actual fill uses the real open + slippage; R-multiple uses actual fill price.
    """
    price = candidate.entry_price_est
    dynamic_scaler = _dynamic_atr_scaler(candidate, config)
    if candidate.atr_14 and candidate.atr_14 > 0:
        stop_distance = candidate.atr_14 * config.stop_atr_multiplier * dynamic_scaler
    else:
        # Fallback: 2% of price
        stop_distance = price * 0.02 * dynamic_scaler
    if candidate.trade_direction == "short":
        return price + stop_distance
    atr_stop = max(0.01, price - stop_distance)
    if candidate.engine_use_reaction_day_low_stop is False:
        return atr_stop
    reaction_day_low = candidate.features.get("reaction_day_low")
    if reaction_day_low is None:
        return atr_stop
    try:
        reaction_stop = float(reaction_day_low)
    except (TypeError, ValueError):
        return atr_stop
    if reaction_stop <= 0:
        return atr_stop
    return max(0.01, max(atr_stop, reaction_stop))


def _resolve_stop_risk_config(candidate: Candidate, config: BacktestConfig) -> RiskConfig:
    profile = config.get_event_profile(candidate.event_type)
    stop_atr_mult = (
        candidate.engine_stop_atr_multiplier
        if candidate.engine_stop_atr_multiplier is not None
        else (
            profile.stop_atr_multiplier_override
            if profile and profile.stop_atr_multiplier_override is not None
            else config.risk.stop_atr_multiplier
        )
    )
    return RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult})


def compute_target_price(
    entry_price_est: float,
    stop_price: float,
    target_r: float = 2.0,
    *,
    target_model: str = "fixed_r",
    target_atr_multiplier: float = 1.5,
    atr_14: float | None = None,
    trade_direction: str = "long",
) -> float:
    """Compute target price using fixed R-multiple or ATR-based model.

    For long: target above entry. For short: target below entry.

    Models:
      - "fixed_r": target = entry +/- risk * target_r
      - "atr_multiple": target = entry +/- atr_14 * target_atr_multiplier
    """
    sign = -1.0 if trade_direction == "short" else 1.0

    if target_model == "atr_multiple" and atr_14 and atr_14 > 0:
        return entry_price_est + sign * atr_14 * target_atr_multiplier

    # Default: fixed R-multiple
    risk = abs(entry_price_est - stop_price)
    if risk <= 0:
        return entry_price_est * (0.90 if trade_direction == "short" else 1.10)
    return entry_price_est + sign * risk * target_r


def compute_shares(
    equity: float,
    entry_price: float,
    stop_price: float,
    config: RiskConfig,
    risk_pct_override: float | None = None,
) -> int:
    """Compute integer share count. Always math.floor() -- never round up."""
    stop_distance = abs(entry_price - stop_price)
    if stop_distance <= 0:
        return 0
    risk_dollars = equity * (risk_pct_override if risk_pct_override is not None else config.per_trade_risk_pct)
    raw_shares = risk_dollars / stop_distance
    return max(0, math.floor(raw_shares))


def _cap_shares_to_cash(
    shares: int,
    candidate: Candidate,
    portfolio_state: DailyPortfolioState,
) -> int:
    """Clamp long share count to the available cash budget.

    The prior behavior rejected the order entirely when risk-based sizing
    implied a notional bigger than current cash. For long-only sleeves with
    low participation this discards viable trades unnecessarily. We instead
    scale down to the largest whole-share position that the account can fund.
    """
    if shares <= 0:
        return 0
    entry_price = float(candidate.entry_price_est)
    if entry_price <= 0:
        return 0
    if candidate.trade_direction == "long":
        max_cash_shares = math.floor(portfolio_state.cash_available / entry_price)
        return max(0, min(shares, max_cash_shares))
    return shares


def _cap_shares_by_position_limits(
    shares: int,
    candidate: Candidate,
    portfolio_state: DailyPortfolioState,
    config: BacktestConfig,
) -> int:
    """Apply notional and liquidity caps before the final cash clamp."""
    if shares <= 0:
        return 0

    entry_price = float(candidate.entry_price_est)
    if entry_price <= 0:
        return 0

    capped = shares

    max_position_value_pct = (
        candidate.engine_max_position_value_pct
        if candidate.engine_max_position_value_pct is not None
        else config.risk.max_position_value_pct
    )
    if max_position_value_pct is not None and max_position_value_pct > 0:
        max_position_value = _resolve_sizing_equity(portfolio_state) * max_position_value_pct
        capped = min(capped, math.floor(max_position_value / entry_price))

    max_adv_fraction = (
        candidate.engine_max_adv_fraction
        if candidate.engine_max_adv_fraction is not None
        else config.risk.max_adv_fraction
    )
    if max_adv_fraction is not None and max_adv_fraction > 0 and candidate.avg_dollar_volume > 0:
        max_adv_notional = candidate.avg_dollar_volume * max_adv_fraction
        capped = min(capped, math.floor(max_adv_notional / entry_price))

    return max(0, capped)


def _remaining_risk_budget_dollars(
    candidate: Candidate,
    portfolio_state: DailyPortfolioState,
    config: BacktestConfig,
    *,
    engine_daily_new_risk_used: float = 0.0,
) -> tuple[float, float, float]:
    """Return remaining (effective, daily, engine) risk budget in dollars."""
    sizing_equity = _resolve_sizing_equity(portfolio_state)
    daily_budget = sizing_equity * config.risk.max_daily_new_risk_pct
    daily_remaining = max(0.0, daily_budget - portfolio_state.daily_new_risk_used)

    engine_budget = daily_budget * candidate.engine_risk_budget_pct
    engine_remaining = max(0.0, engine_budget - engine_daily_new_risk_used)
    effective_remaining = min(daily_remaining, engine_remaining)
    return effective_remaining, daily_remaining, engine_remaining


def _cap_shares_to_remaining_risk_budget(
    shares: int,
    entry_price: float,
    stop_price: float,
    remaining_risk_dollars: float,
) -> int:
    """Clip shares to fit the remaining risk budget."""
    if shares <= 0:
        return 0
    stop_distance = abs(entry_price - stop_price)
    if stop_distance <= 0:
        return 0
    max_budget_shares = math.floor(remaining_risk_dollars / stop_distance)
    return max(0, min(shares, max_budget_shares))


def _count_sector_positions(open_positions: list[OpenPosition], sector: str) -> int:
    return sum(1 for p in open_positions if p.plan.candidate.sector == sector)


def _open_symbols(open_positions: list[OpenPosition]) -> set[str]:
    return {p.plan.candidate.symbol for p in open_positions}


def _is_a_tier_candidate(candidate: Candidate, config: BacktestConfig) -> bool:
    threshold = config.signal.a_tier_score_threshold
    return threshold is not None and candidate.score >= threshold


def _resolve_per_trade_risk_pct(candidate: Candidate, config: BacktestConfig) -> float:
    if candidate.engine_per_trade_risk_pct is not None:
        return candidate.engine_per_trade_risk_pct
    if _is_a_tier_candidate(candidate, config) and config.risk.per_trade_risk_pct_a_tier is not None:
        return config.risk.per_trade_risk_pct_a_tier
    return config.risk.per_trade_risk_pct


def _resolve_oneoff_threshold(candidate: Candidate, config: BacktestConfig) -> float:
    if candidate.engine_veto_oneoff_penalty is not None:
        return candidate.engine_veto_oneoff_penalty
    return config.risk.veto_oneoff_penalty


def _allow_oneoff_downsizing(candidate: Candidate, config: BacktestConfig) -> bool:
    if candidate.engine_allow_oneoff_downsizing is not None:
        return candidate.engine_allow_oneoff_downsizing
    return config.risk.allow_oneoff_downsizing


def _resolve_oneoff_downsize_floor(candidate: Candidate, config: BacktestConfig) -> float:
    if candidate.engine_oneoff_downsize_floor is not None:
        return candidate.engine_oneoff_downsize_floor
    return config.risk.oneoff_downsize_floor


def _oneoff_risk_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    if not _allow_oneoff_downsizing(candidate, config):
        return 1.0

    oneoff = candidate.features.get("oneoff_penalty")
    if oneoff is None:
        return 1.0

    threshold = _resolve_oneoff_threshold(candidate, config)
    try:
        oneoff_value = float(oneoff)
    except (TypeError, ValueError):
        return 1.0

    if oneoff_value < threshold:
        return 1.0

    span = max(1e-9, 1.0 - threshold)
    excess = min(1.0, max(0.0, (oneoff_value - threshold) / span))
    floor = min(1.0, max(0.0, _resolve_oneoff_downsize_floor(candidate, config)))
    return 1.0 - excess * (1.0 - floor)


def _resolve_effective_per_trade_risk_pct(candidate: Candidate, config: BacktestConfig) -> float:
    return _resolve_per_trade_risk_pct(candidate, config) * _oneoff_risk_scaler(candidate, config)


def _macro_regime_state(config: BacktestConfig, macro_data: dict[str, Any] | None) -> str:
    if not config.risk.macro_regime_enabled or not macro_data:
        return "disabled"

    if config.risk.macro_regime_mode == "spy_qqq_scaler":
        spy_close = macro_data.get("spy_close")
        spy_sma = macro_data.get("spy_sma_20")
        qqq_close = macro_data.get("qqq_close")
        qqq_sma = macro_data.get("qqq_sma_20")
        if None in (spy_close, spy_sma, qqq_close, qqq_sma):
            return "unknown"
        spy_on = float(spy_close) >= float(spy_sma)
        qqq_on = float(qqq_close) >= float(qqq_sma)
        if spy_on and qqq_on:
            return "risk_on"
        if spy_on or qqq_on:
            return "neutral"
        return "risk_off"

    spy_close = macro_data.get("spy_close")
    spy_sma = macro_data.get("spy_sma_20")
    if spy_close is None or spy_sma is None:
        return "unknown"
    return "risk_off" if float(spy_close) < float(spy_sma) else "risk_on"


def _macro_size_scaler(config: BacktestConfig, regime_state: str) -> float:
    if regime_state in {"disabled", "unknown", "risk_on"}:
        return 1.0
    if config.risk.macro_regime_mode == "spy_qqq_scaler":
        if regime_state == "neutral":
            return config.risk.macro_regime_neutral_size_scaler or 0.6
        if regime_state == "risk_off":
            return config.risk.macro_regime_risk_off_size_scaler or 0.35
    if regime_state == "risk_off":
        return config.risk.macro_regime_size_scaler
    return 1.0


def _vix_continuous_size_scaler(config: BacktestConfig, macro_data: dict[str, Any] | None) -> float:
    """Continuous position sizing scaler based on VIX level.

    Uses FRED series VIXCLS from macro_observations table.

    Mode "vix_continuous" (default): defensive — scales DOWN as VIX rises.
      Linearly interpolates between 1.0 (at vix_low) and vix_min (at vix_high).

    Mode "vix_pead": PEAD-optimized — scales UP when VIX > 18 (favorable PEAD
      regime with 62.3% WR), penalizes VIX 15-18 complacent zone (48.2% WR).
      Uses vix_size_scaler_min as the complacent-zone floor.

    Returns 1.0 if VIX data is missing or mode is not vix_continuous/vix_pead.
    """
    if config.risk.macro_regime_mode not in ("vix_continuous", "vix_pead"):
        return 1.0
    if not macro_data:
        return 1.0
    vix = macro_data.get("VIXCLS")
    if vix is None:
        return 1.0
    try:
        vix_val = float(vix)
    except (TypeError, ValueError):
        return 1.0

    if config.risk.macro_regime_mode == "vix_pead":
        # PEAD-optimized: high VIX = boost, mid VIX = penalize
        if vix_val > 18:
            return min(1.3, 1.0 + (vix_val - 18) / 20)  # +5% per VIX point above 18, max 1.3
        if 15 < vix_val <= 18:
            return config.risk.vix_size_scaler_min  # complacent zone penalty
        return 1.0  # low VIX = normal

    # Default: defensive
    low = config.risk.vix_size_scaler_low
    high = config.risk.vix_size_scaler_high
    floor = config.risk.vix_size_scaler_min
    if vix_val <= low:
        return 1.0
    if vix_val >= high:
        return floor
    frac = (vix_val - low) / (high - low)
    return 1.0 - frac * (1.0 - floor)


def _credit_spread_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale position size based on HY credit spread regime.

    Uses macro_hy_spread (ICE BofA HY OAS) from candidate features (Parquet).
    Tight spreads = risk-on, wide/stress spreads = scale down.

    Returns 1.0 if feature is missing or scaler is disabled.
    """
    if not config.risk.credit_spread_size_scaler_enabled:
        return 1.0
    spread = candidate.features.get("macro_hy_spread")
    if spread is None:
        return 1.0
    try:
        spread_val = float(spread)
    except (TypeError, ValueError):
        return 1.0
    if spread_val >= config.risk.credit_spread_wide_threshold:
        return config.risk.credit_spread_stress_scaler
    if spread_val >= config.risk.credit_spread_tight_threshold:
        return config.risk.credit_spread_wide_scaler
    return 1.0


def _yield_curve_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale position size based on yield curve regime (T10Y2Y).

    Uses macro_t10y2y from candidate features (Parquet enrichment).
    Normal curve = full size, flat/inverted = scale down.

    Returns 1.0 if feature is missing or scaler is disabled.
    """
    if not config.risk.yield_curve_size_scaler_enabled:
        return 1.0
    t10y2y = candidate.features.get("macro_t10y2y")
    if t10y2y is None:
        return 1.0
    try:
        yc_val = float(t10y2y)
    except (TypeError, ValueError):
        return 1.0
    if yc_val >= config.risk.yield_curve_normal_threshold:
        return 1.0
    if yc_val < 0:
        return config.risk.yield_curve_inverted_scaler
    return config.risk.yield_curve_flat_scaler


def _linear_engine_stress_scaler(
    value: float | None,
    low: float | None,
    high: float | None,
    floor: float | None,
) -> float:
    if value is None or low is None or high is None or floor is None:
        return 1.0
    try:
        value_f = float(value)
        low_f = float(low)
        high_f = float(high)
        floor_f = float(floor)
    except (TypeError, ValueError):
        return 1.0
    if high_f <= low_f:
        return 1.0
    if value_f <= low_f:
        return 1.0
    floor_f = max(0.0, min(1.0, floor_f))
    if value_f >= high_f:
        return floor_f
    frac = (value_f - low_f) / (high_f - low_f)
    return 1.0 - frac * (1.0 - floor_f)


def _engine_macro_stress_scaler(candidate: Candidate, macro_data: dict[str, Any] | None) -> float:
    scalers: list[float] = []

    vix_value = None
    if macro_data:
        vix_value = macro_data.get("VIXCLS")
        if vix_value is None:
            vix_value = macro_data.get("macro_vix")
    vix_scaler = _linear_engine_stress_scaler(
        vix_value,
        candidate.engine_macro_vix_size_scaler_low,
        candidate.engine_macro_vix_size_scaler_high,
        candidate.engine_macro_vix_size_scaler_min,
    )
    if vix_scaler != 1.0:
        scalers.append(vix_scaler)

    hy_value = candidate.features.get("macro_hy_spread")
    hy_scaler = _linear_engine_stress_scaler(
        hy_value,
        candidate.engine_macro_hy_spread_size_scaler_low,
        candidate.engine_macro_hy_spread_size_scaler_high,
        candidate.engine_macro_hy_spread_size_scaler_min,
    )
    if hy_scaler != 1.0:
        scalers.append(hy_scaler)

    return min(scalers) if scalers else 1.0


def _engine_score_size_scaler(candidate: Candidate) -> float:
    low = candidate.engine_score_size_scaler_low
    high = candidate.engine_score_size_scaler_high
    floor = candidate.engine_score_size_scaler_min
    score = candidate.score
    if low is None or high is None or floor is None:
        return 1.0
    try:
        low_f = float(low)
        high_f = float(high)
        floor_f = float(floor)
        score_f = float(score)
    except (TypeError, ValueError):
        return 1.0
    if high_f <= low_f:
        return 1.0
    floor_f = max(0.0, min(1.0, floor_f))
    if score_f <= low_f:
        return floor_f
    if score_f >= high_f:
        return 1.0
    frac = (score_f - low_f) / (high_f - low_f)
    return floor_f + frac * (1.0 - floor_f)


def _engine_entropy_size_scaler(candidate: Candidate) -> float:
    low = candidate.engine_entropy_size_scaler_low
    high = candidate.engine_entropy_size_scaler_high
    floor = candidate.engine_entropy_size_scaler_min
    entropy = candidate.features.get("pre_event_entropy_60d")
    if low is None or high is None or floor is None or entropy is None:
        return 1.0
    try:
        low_f = float(low)
        high_f = float(high)
        floor_f = float(floor)
        entropy_f = float(entropy)
    except (TypeError, ValueError):
        return 1.0
    if high_f <= low_f:
        return 1.0
    floor_f = max(0.0, min(1.0, floor_f))
    if entropy_f <= low_f:
        return 1.0
    if entropy_f >= high_f:
        return floor_f
    frac = (entropy_f - low_f) / (high_f - low_f)
    return 1.0 - frac * (1.0 - floor_f)


def _reaction_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale position size inversely with reaction magnitude.

    Large reactions (>8%) carry mean-reversion risk. Live paper trading showed:
      - Losses avg reaction +10.8% → big positions that reverse
      - Wins avg reaction +0.7% → moderate positions that drift

    Scaling: full size at reaction_threshold, half at 2x threshold.
    Disabled when reaction_size_cap_threshold is None (default).
    """
    threshold = config.risk.reaction_size_cap_threshold
    if threshold is None:
        return 1.0
    reaction = abs(candidate.features.get("reaction_day_return", 0.0) or 0.0)
    if reaction <= threshold:
        return 1.0
    # Linear scale-down: threshold → 1.0, 2*threshold → 0.5, 3*threshold → 0.33
    return threshold / reaction


def _momentum_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale position size based on pre-event momentum.

    High momentum (>threshold): scale DOWN — these have lower forward returns
    (corr -0.19, Q5 WR 38%). Linear from 1.0 at threshold to floor at 2x.

    Low momentum (contrarian boost, <contrarian_threshold): scale UP — stocks
    below SMA20 have WR 55.4% vs 44.4%. Linear from 1.0 to boost_max.
    """
    feature_name = config.risk.momentum_size_scaler_feature
    mom = candidate.features.get(feature_name)
    if mom is None:
        return 1.0
    mom = float(mom)

    # Downscale high momentum
    threshold = config.risk.momentum_size_scaler_threshold
    if threshold is not None and mom > threshold:
        floor = config.risk.momentum_size_scaler_floor
        excess = (mom - threshold) / threshold
        return max(floor, 1.0 - excess * (1.0 - floor))

    # Boost low momentum (contrarian)
    ct = config.risk.contrarian_boost_threshold
    if ct is not None and mom < ct:
        boost_max = config.risk.contrarian_boost_max
        # Linear: ct → 1.0, 2*ct (more negative) → boost_max
        depth = (ct - mom) / abs(ct) if ct != 0 else 0
        return min(boost_max, 1.0 + depth * (boost_max - 1.0))

    return 1.0


def _volatility_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale position size inversely with pre-event realized volatility.

    Low vol stocks have more predictable PEAD drift → larger positions.
    High vol stocks have mean-reversion risk → smaller positions.
    Linear interpolation from 1.0 at vol_low to floor at vol_high.
    """
    if not config.risk.volatility_size_scaler_enabled:
        return 1.0
    vol = candidate.features.get("pre_event_volatility_20d")
    if vol is None:
        return 1.0
    vol = float(vol)
    low = config.risk.volatility_size_scaler_low
    high = config.risk.volatility_size_scaler_high
    floor = config.risk.volatility_size_scaler_min
    if vol <= low:
        return 1.0
    if vol >= high:
        return floor
    frac = (vol - low) / (high - low)
    return 1.0 - frac * (1.0 - floor)


def _breadth_crowding_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale size down on crowded slate days.

    This is a lightweight approximation of breadth/percolation throttles and
    correlation penalties from the research catalog. It relies only on the
    selected candidate slate for the day, which is annotated upstream by the
    runner.
    """
    scaler = 1.0
    features = candidate.features

    if config.risk.breadth_throttle_enabled:
        total_count = features.get("daily_candidate_count_selected")
        if total_count is not None:
            try:
                total_count_val = float(total_count)
            except (TypeError, ValueError):
                total_count_val = 0.0
            threshold = max(1.0, float(config.risk.breadth_throttle_candidate_count_threshold))
            if total_count_val > threshold:
                floor = min(1.0, max(0.0, config.risk.breadth_throttle_min))
                scaler = min(scaler, max(floor, threshold / total_count_val))

    if config.risk.sector_crowding_penalty_enabled:
        sector_count = features.get("daily_sector_candidate_count_selected")
        if sector_count is not None:
            try:
                sector_count_val = float(sector_count)
            except (TypeError, ValueError):
                sector_count_val = 0.0
            threshold = max(1.0, float(config.risk.sector_crowding_candidate_count_threshold))
            if sector_count_val > threshold:
                floor = min(1.0, max(0.0, config.risk.sector_crowding_penalty_min))
                scaler = min(scaler, max(floor, threshold / sector_count_val))

    return scaler


def _tail_risk_adjuster_scaler(candidate: Candidate, config: BacktestConfig) -> float:
    """Scale size down for candidates with a stacked left-tail profile."""
    if not config.risk.tail_risk_adjuster_enabled:
        return 1.0

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

    if len(components) < max(1, config.risk.tail_risk_min_signals):
        return 1.0

    tail_score = sum(components) / len(components)
    threshold = min(0.999, max(0.0, config.risk.tail_risk_penalty_threshold))
    if tail_score <= threshold:
        return 1.0

    floor = min(1.0, max(0.0, config.risk.tail_risk_penalty_min))
    excess = (tail_score - threshold) / max(1e-9, 1.0 - threshold)
    return max(floor, 1.0 - excess * (1.0 - floor))


def _technical_conviction_boost(candidate: Candidate, config: BacktestConfig) -> float:
    """Boost position size when pre-event technicals indicate conviction.

    Favorable conditions (size UP):
    - Low volatility (< 2% daily) = predictable drift → boost
    - Positive OBV slope = institutional accumulation → boost
    - RSI < 50 + positive reaction = oversold reversal → boost
    - BB %B < 0.5 = below midline, room to run → boost

    Each favorable condition adds up to +10% boost, capped at max.
    Returns >= 1.0 always (never reduces size).
    """
    if not config.risk.technical_conviction_boost_enabled:
        return 1.0

    boost = 1.0
    max_boost = config.risk.technical_conviction_boost_max
    features = candidate.features

    # Low vol = predictable drift
    vol = features.get("pre_event_volatility_20d")
    if vol is not None:
        vol = float(vol)
        if vol < 0.015:  # < 1.5% daily vol
            boost += 0.10
        elif vol < 0.02:  # < 2% daily vol
            boost += 0.05

    # Positive OBV = accumulation
    obv = features.get("pre_event_obv_slope_20d")
    if obv is not None:
        obv = float(obv)
        if obv > 0.1:  # strong accumulation
            boost += 0.10
        elif obv > 0:   # mild accumulation
            boost += 0.05

    # RSI below 50 with positive reaction = oversold bounce
    rsi = features.get("pre_event_rsi_14")
    reaction = features.get("reaction_day_return")
    if rsi is not None and reaction is not None:
        rsi = float(rsi)
        reaction = float(reaction)
        if rsi < 40 and reaction > 0:
            boost += 0.10
        elif rsi < 50 and reaction > 0.03:
            boost += 0.05

    # BB %B below midline = room to run
    bb = features.get("pre_event_bb_position")
    if bb is not None:
        bb = float(bb)
        if bb < 0.3:   # well below midline
            boost += 0.10
        elif bb < 0.5:  # below midline
            boost += 0.05

    return min(max_boost, boost)


def run_entry_gates(
    candidate: Candidate,
    portfolio_state: DailyPortfolioState,
    open_positions: list[OpenPosition],
    config: BacktestConfig,
    cooldown_remaining: int = 0,
    macro_data: dict[str, Any] | None = None,
    engine_daily_new_risk_used: float = 0.0,
) -> str | None:
    """Run entry gates. Returns skip_reason string or None (pass).

    Gates (in order):
    0. Macro regime (SPY below SMA — hard block only if size_scaler >= 1.0)
    1. Kill switch (drawdown >= threshold)
    2. Max total positions
    3. Duplicate symbol already open
    4. Sector concentration
    5. Daily new risk budget
    6. Cash available (estimated position cost)
    7. Loss-streak cooldown
    8. (removed — SUE gate)
    9. Event-type direction filter (bullish_only)
    10. High one-off risk (veto: oneoff_penalty >= threshold)
    11. Low parse confidence (veto: parse_confidence < threshold)
    12. Unknown direction (veto: event_direction == "unknown")
    13. Bearish direction (veto: event_direction == "bearish")
    """
    # Gate 0: Macro regime filter / tier restriction
    regime_state = _macro_regime_state(config, macro_data)
    regime_scaler = _macro_size_scaler(config, regime_state)
    if config.risk.macro_regime_enabled:
        if config.risk.macro_regime_mode == "spy_qqq_scaler":
            if regime_state == "risk_off" and config.risk.macro_regime_risk_off_a_tier_only:
                if candidate.trade_direction != "long" or not _is_a_tier_candidate(candidate, config):
                    return "macro_regime_risk_off_non_a_tier"
        elif regime_state == "risk_off" and regime_scaler >= 1.0:
            return "macro_regime_unfavorable"

    # Gate 1: Kill switch (skip if log-only mode)
    if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT:
        if not config.risk.kill_switch_log_only:
            return "kill_switch_drawdown"

    # Gate 2: Max positions
    if len(open_positions) >= config.risk.max_positions:
        return "max_positions_reached"

    # Gate 3: Duplicate symbol
    if candidate.symbol in _open_symbols(open_positions):
        if not candidate.is_add_on:
            return "duplicate_symbol"
        if candidate.parent_position_id is None:
            return "orphan_add_on"
        parent = next(
            (position for position in open_positions if position.position_id == candidate.parent_position_id),
            None,
        )
        if parent is None:
            return "orphan_add_on"
        allowed_add_on_count = candidate.engine_add_on_max_count or 1
        existing_add_on_count = sum(
            position.plan.candidate.symbol == candidate.symbol
            and position.parent_position_id == candidate.parent_position_id
            and position.is_add_on
            for position in open_positions
        )
        if existing_add_on_count >= allowed_add_on_count:
            return "duplicate_add_on"

    # Gate 4: Sector concentration
    sector_count = _count_sector_positions(open_positions, candidate.sector)
    max_positions_per_sector = (
        candidate.engine_max_positions_per_sector
        if candidate.engine_max_positions_per_sector is not None
        else config.risk.max_positions_per_sector
    )
    if sector_count >= max_positions_per_sector:
        return "sector_limit"

    # Gate 5: Daily new risk budget
    trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, config)
    trade_risk = _resolve_sizing_equity(portfolio_state) * trade_risk_pct
    remaining_risk, daily_remaining, engine_remaining = _remaining_risk_budget_dollars(
        candidate,
        portfolio_state,
        config,
        engine_daily_new_risk_used=engine_daily_new_risk_used,
    )
    daily_budget = _resolve_sizing_equity(portfolio_state) * config.risk.max_daily_new_risk_pct
    if daily_remaining <= 0:
        return "daily_risk_budget"

    engine_budget = daily_budget * candidate.engine_risk_budget_pct
    if engine_budget <= 0:
        return "engine_daily_risk_budget"
    if engine_remaining <= 0:
        return "engine_daily_risk_budget"
    if not config.risk.allow_budget_downsizing:
        if portfolio_state.daily_new_risk_used + trade_risk > daily_budget:
            return "daily_risk_budget"
        if engine_daily_new_risk_used + trade_risk > engine_budget:
            return "engine_daily_risk_budget"
    elif remaining_risk <= 0:
        return "daily_risk_budget"

    # Gate 6: Cash available (estimate position cost)
    stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, config))
    est_shares = compute_shares(
        _resolve_sizing_equity(portfolio_state),
        candidate.entry_price_est,
        stop_price,
        config.risk,
        risk_pct_override=trade_risk_pct,
    )
    if candidate.forced_shares is not None:
        est_shares = candidate.forced_shares
    est_shares = _cap_shares_to_cash(est_shares, candidate, portfolio_state)
    if est_shares <= 0:
        return "insufficient_cash"

    # Gate 7: Cooldown
    if cooldown_remaining > 0:
        return "cooldown"

    # Gate 9: Event-type direction filter
    profile = config.get_event_profile(candidate.event_type)
    if profile:
        reaction = candidate.features.get("reaction_day_return")
        if profile.direction_filter == "bullish_only" and reaction is not None and float(reaction) < 0:
            return "direction_filter_bearish"
        if profile.direction_filter == "bearish_only" and reaction is not None and float(reaction) > 0:
            return "direction_filter_bullish"

    # --- Veto gates: document quality hard filters ---

    # Gate 10: High one-off risk
    oneoff = candidate.features.get("oneoff_penalty")
    oneoff_threshold = _resolve_oneoff_threshold(candidate, config)
    if (
        oneoff is not None
        and float(oneoff) >= oneoff_threshold
        and not _allow_oneoff_downsizing(candidate, config)
    ):
        return "high_oneoff_risk"

    # Gate 11: Low parse confidence
    parse_conf = candidate.features.get("parse_confidence_overall")
    parse_threshold = (
        candidate.engine_veto_parse_confidence_min
        if candidate.engine_veto_parse_confidence_min is not None
        else config.risk.veto_parse_confidence_min
    )
    if parse_conf is not None and float(parse_conf) < parse_threshold:
        return "low_parse_confidence"

    # Gate 12: Unknown direction
    # Engines that explicitly target unknown direction (event_directions includes "unknown")
    # are exempt — their selection IS the intent to handle these events.
    event_dir = candidate.features.get("event_direction")
    if (
        config.risk.veto_unknown_direction
        and event_dir is not None
        and str(event_dir).lower() == "unknown"
        and not candidate.engine_allow_unknown_direction
    ):
        return "unknown_direction"

    # Gate 13: Bearish direction (all event types, document-based)
    forced_trade_direction = str(candidate.engine_forced_trade_direction or "").lower()
    if (
        config.risk.veto_bearish_direction
        and event_dir is not None
        and str(event_dir).lower() == "bearish"
        and forced_trade_direction != "long"
    ):
        return "bearish_direction"

    return None  # all gates passed


def build_planned_order(
    candidate: Candidate,
    portfolio_state: DailyPortfolioState,
    open_positions: list[OpenPosition],
    config: BacktestConfig,
    execution_config: ExecutionConfig | None = None,
    cooldown_remaining: int = 0,
    macro_data: dict[str, Any] | None = None,
    engine_daily_new_risk_used: float = 0.0,
) -> PlannedOrder:
    """Build a PlannedOrder. skip_reason is non-None if any gate rejected it."""
    skip_reason = run_entry_gates(
        candidate, portfolio_state, open_positions, config, cooldown_remaining,
        macro_data=macro_data,
        engine_daily_new_risk_used=engine_daily_new_risk_used,
    )

    exec_cfg = execution_config or config.execution
    trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, config)

    # Apply event-type-specific overrides for stop/target ATR multipliers
    profile = config.get_event_profile(candidate.event_type)
    target_atr_mult = (
        candidate.engine_target_atr_multiplier
        if candidate.engine_target_atr_multiplier is not None
        else (
            profile.target_atr_multiplier_override
            if profile and profile.target_atr_multiplier_override is not None
            else exec_cfg.target_atr_multiplier
        )
    )

    stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, config))
    target_r = exec_cfg.target_1_r
    if target_r is None and exec_cfg.use_tiered_targets:
        if _is_a_tier_candidate(candidate, config):
            if exec_cfg.a_tier_target_1_r is not None:
                target_r = exec_cfg.a_tier_target_1_r
        else:
            if exec_cfg.non_a_tier_target_1_r is not None:
                target_r = exec_cfg.non_a_tier_target_1_r
    if target_r is None:
        target_r = 2.0

    shares = 0
    risk_dollars = 0.0
    if skip_reason is None:
        if candidate.forced_shares is not None:
            shares = candidate.forced_shares
        else:
            shares = compute_shares(
                _resolve_sizing_equity(portfolio_state),
                candidate.entry_price_est,
                stop_price,
                config.risk,
                risk_pct_override=trade_risk_pct,
            )
        if shares == 0:
            skip_reason = "zero_shares"
        else:
            regime_state = _macro_regime_state(config, macro_data)
            scaler = _macro_size_scaler(config, regime_state)
            if config.risk.macro_regime_enabled and scaler < 1.0:
                shares = max(1, math.floor(shares * scaler))
            vix_scaler = _vix_continuous_size_scaler(config, macro_data)
            if vix_scaler != 1.0:
                shares = max(1, math.floor(shares * vix_scaler))
            reaction_scaler = _reaction_size_scaler(candidate, config)
            if reaction_scaler < 1.0:
                shares = max(1, math.floor(shares * reaction_scaler))
            mom_scaler = _momentum_size_scaler(candidate, config)
            if mom_scaler != 1.0:
                shares = max(1, math.floor(shares * mom_scaler))
            vol_scaler = _volatility_size_scaler(candidate, config)
            if vol_scaler != 1.0:
                shares = max(1, math.floor(shares * vol_scaler))
            breadth_scaler = _breadth_crowding_size_scaler(candidate, config)
            if breadth_scaler != 1.0:
                shares = max(1, math.floor(shares * breadth_scaler))
            tail_scaler = _tail_risk_adjuster_scaler(candidate, config)
            if tail_scaler != 1.0:
                shares = max(1, math.floor(shares * tail_scaler))
            conviction_boost = _technical_conviction_boost(candidate, config)
            if conviction_boost > 1.0:
                shares = max(1, math.floor(shares * conviction_boost))
            cs_scaler = _credit_spread_size_scaler(candidate, config)
            if cs_scaler != 1.0:
                shares = max(1, math.floor(shares * cs_scaler))
            yc_scaler = _yield_curve_size_scaler(candidate, config)
            if yc_scaler != 1.0:
                shares = max(1, math.floor(shares * yc_scaler))
            engine_score_scaler = _engine_score_size_scaler(candidate)
            if engine_score_scaler != 1.0:
                shares = max(1, math.floor(shares * engine_score_scaler))
            engine_entropy_scaler = _engine_entropy_size_scaler(candidate)
            if engine_entropy_scaler != 1.0:
                shares = max(1, math.floor(shares * engine_entropy_scaler))
            engine_macro_scaler = _engine_macro_stress_scaler(candidate, macro_data)
            if engine_macro_scaler != 1.0:
                shares = max(1, math.floor(shares * engine_macro_scaler))
            if config.risk.allow_budget_downsizing:
                remaining_risk, daily_remaining, engine_remaining = _remaining_risk_budget_dollars(
                    candidate,
                    portfolio_state,
                    config,
                    engine_daily_new_risk_used=engine_daily_new_risk_used,
                )
                shares = _cap_shares_to_remaining_risk_budget(
                    shares,
                    candidate.entry_price_est,
                    stop_price,
                    remaining_risk,
                )
            shares = _cap_shares_by_position_limits(
                shares,
                candidate,
                portfolio_state,
                config,
            )
            shares = _cap_shares_to_cash(shares, candidate, portfolio_state)
            if shares == 0:
                if config.risk.allow_budget_downsizing and (
                    daily_remaining <= 0 or engine_remaining <= 0
                ):
                    skip_reason = "daily_risk_budget" if daily_remaining <= 0 else "engine_daily_risk_budget"
                else:
                    skip_reason = "zero_shares"
                risk_dollars = 0.0
                target_price = compute_target_price(
                    candidate.entry_price_est,
                    stop_price,
                    target_r,
                    target_model=exec_cfg.target_model,
                    target_atr_multiplier=target_atr_mult,
                    atr_14=candidate.atr_14,
                    trade_direction=candidate.trade_direction,
                )
                return PlannedOrder(
                    candidate=candidate,
                    shares=shares,
                    entry_price_limit=candidate.entry_price_est,
                    stop_price=stop_price,
                    target_price=target_price,
                    risk_dollars=risk_dollars,
                    event_date=candidate.event_date,
                    timing_class=candidate.timing_class,
                    engine_id=candidate.engine_id,
                    entry_timing_policy=candidate.entry_timing_policy,
                    shadow_only=candidate.shadow_only,
                    parent_position_id=candidate.parent_position_id,
                    is_add_on=candidate.is_add_on,
                    skip_reason=skip_reason,
                )

            risk_dollars = abs(candidate.entry_price_est - stop_price) * shares

    target_price = compute_target_price(
        candidate.entry_price_est,
        stop_price,
        target_r,
        target_model=exec_cfg.target_model,
        target_atr_multiplier=target_atr_mult,
        atr_14=candidate.atr_14,
        trade_direction=candidate.trade_direction,
    )

    return PlannedOrder(
        candidate=candidate,
        shares=shares,
        entry_price_limit=candidate.entry_price_est,
        stop_price=stop_price,
        target_price=target_price,
        risk_dollars=risk_dollars,
        event_date=candidate.event_date,
        timing_class=candidate.timing_class,
        engine_id=candidate.engine_id,
        entry_timing_policy=candidate.entry_timing_policy,
        shadow_only=candidate.shadow_only,
        parent_position_id=candidate.parent_position_id,
        is_add_on=candidate.is_add_on,
        skip_reason=skip_reason,
    )
