"""VolBreakout52w — honest, look-ahead-safe descendant of the retired topgainer family.

Buy at next_open T when T-1 close is a 52-week high with volume confirmation;
hold to next-day close. Designed to NEVER repeat the topgainer v1-v54 lookahead bug
(see memory: project_topgainer_phase1_lookahead_2026-05-05.md):

  Phase-1 pre-screen used today's daily_high → +267% Sharpe 13.73 collapsed to
  -4.3% Sharpe -1.04 once removed.

Trigger conditions (ALL evaluated using only data ending T-1):
  1. close_T-1 > max(high[T-252..T-2])
  2. volume_T-1 >= 2 * median_volume_20d_T-2
  3. ATR_14_T-1 / close_T-1 in [0.015, 0.06]

Entry: T next_open. Skip if pre-open implied gap > +4% (when gap data available).
Exit:  -3% intraday stop, +5% target, max_holding_days = 2 (mandatory MOC day 2).

Architectural choice (a) — synthetic Candidate emission into the existing
``_scheduled_delayed_entries`` queue, mirroring EarningsRunup / PeerSympathy.
Production path (b) — pre-compute features into the snapshot — left as a follow-up.

Look-ahead defenses (NON-NEGOTIABLE):
  * BarHistoryProvider boundary returns bars STRICTLY before decision_date.
  * ``_assert_features_strictly_before_decision_open`` checks every feature
    timestamp against 09:30 ET on the decision day.
  * Defence-in-depth: ``evaluate_trigger`` re-asserts ``last_bar_date < decision_date``
    so a future maintainer cannot accidentally introduce a T+0 feature path.
  * ``FrozenT1Features`` typed wrapper raises ``LookaheadViolationError`` on
    construction if any field's source date >= decision_date.
"""
from __future__ import annotations

import datetime as dt
import math
import statistics
from dataclasses import dataclass, field
from typing import Any, Iterable, Protocol

from libs.backtest.domain import (
    Candidate,
    LookaheadViolationError,
    StrategyEngineConfig,
)
from libs.common.logging import get_logger

logger = get_logger(__name__)

VOL_BREAKOUT_52W_EVENT_TYPE = "vol_breakout_52w"

# Eastern-time market open used as the leakage cutoff.
_ET_MARKET_OPEN = dt.time(9, 30)
_ET_OFFSET = dt.timedelta(hours=-5)  # EST; DST irrelevant for an ordering bound

# Forbidden field substrings at the screener level — any column whose name
# encodes the entry-day's intraday/EOD data is a categorical look-ahead.
_FORBIDDEN_T0_FIELD_SUBSTRINGS: tuple[str, ...] = (
    "daily_high",
    "daily_low",
    "daily_close",
    "intraday_high",
    "intraday_low",
)


# ---------------------------------------------------------------------------
# Provider Protocols
# ---------------------------------------------------------------------------


class BarHistoryProvider(Protocol):
    """Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``."""

    def get_bars_before(
        self,
        symbol: str,
        as_of_date: dt.date,
        lookback_days: int,
    ) -> list[tuple[dt.date, dict[str, Any]]]: ...


class PreOpenGapProvider(Protocol):
    """Returns the implied pre-open gap for ``symbol`` on ``decision_date``'s next session.

    ``None`` if data is unavailable for that symbol/date. Implementations MUST
    use only premarket data observed before 09:30 ET on the next trading day.
    """

    def get_pre_open_gap_pct(
        self,
        symbol: str,
        next_trading_date: dt.date,
        prev_close: float,
    ) -> float | None: ...


# ---------------------------------------------------------------------------
# Lookahead defense — cutoff and assertions
# ---------------------------------------------------------------------------


def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
    """09:30 ET on decision_date, expressed as a UTC-aware timestamp."""
    et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
    utc_naive = et_naive - _ET_OFFSET
    return utc_naive.replace(tzinfo=dt.timezone.utc)


def _assert_features_strictly_before_decision_open(
    symbol: str,
    decision_date: dt.date,
    feature_timestamps: Iterable[dt.datetime],
) -> None:
    """Raise LookaheadViolationError if any feature timestamp >= 09:30 ET on decision_date."""
    cutoff = _decision_cutoff_utc(decision_date)
    for ts in feature_timestamps:
        if ts is None:
            continue
        if ts.tzinfo is None:
            raise LookaheadViolationError(
                f"VolBreakout52w feature timestamp for {symbol} is naive ({ts.isoformat()}); "
                "all timestamps must be timezone-aware to compare against the cutoff"
            )
        if ts >= cutoff:
            raise LookaheadViolationError(
                f"VolBreakout52w feature timestamp {ts.isoformat()} for {symbol} is "
                f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation"
            )


def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
    """Timestamp the daily-close bar at 16:00 ET on its trading day, in UTC."""
    et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
    utc_naive = et_naive - _ET_OFFSET
    return utc_naive.replace(tzinfo=dt.timezone.utc)


# ---------------------------------------------------------------------------
# FrozenT1Features — typed wrapper that refuses to hold T+0 data
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class FrozenT1Features:
    """Strict T-1 (or earlier) feature bundle.

    Construction validates that every source date is strictly before
    ``decision_date`` AND that no field name encodes entry-day data
    (``daily_high``, ``daily_low``, ``daily_close``, ...). Either raises
    ``LookaheadViolationError`` immediately.

    This is the categorical defense against the topgainer v1-v54 bug — even if
    the BarHistoryProvider were leaky, this wrapper refuses to carry forward
    any T+0 information into the screener.
    """

    symbol: str
    decision_date: dt.date
    last_bar_date: dt.date
    last_close: float
    high_252d_max: float           # max(high[T-252..T-2]); excludes last_bar_date by construction
    high_252d_max_window: list[dt.date] = field(default_factory=list)
    volume_t_minus_1: float = 0.0
    median_volume_20d_t_minus_2: float = 0.0
    atr_14_t_minus_1: float = 0.0
    atr_normalized_t_minus_1: float = 0.0
    avg_dollar_volume_20d: float = 0.0
    last_bar_timestamp: dt.datetime | None = None  # tz-aware
    extra: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        # 1) Forbidden field substrings on user-supplied extras.
        for k in self.extra.keys():
            kl = str(k).lower()
            for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS:
                if forbidden in kl:
                    raise LookaheadViolationError(
                        f"FrozenT1Features for {self.symbol}: field {k!r} contains "
                        f"forbidden substring {forbidden!r} — these encode entry-day "
                        "data and constitute a categorical look-ahead"
                    )
        # 2) last_bar_date must be strictly before decision_date.
        if self.last_bar_date >= self.decision_date:
            raise LookaheadViolationError(
                f"FrozenT1Features for {self.symbol}: last_bar_date {self.last_bar_date.isoformat()} "
                f"is not strictly before decision_date {self.decision_date.isoformat()}"
            )
        # 3) high_252d_max_window dates must be strictly before decision_date.
        for d in self.high_252d_max_window:
            if d >= self.decision_date:
                raise LookaheadViolationError(
                    f"FrozenT1Features for {self.symbol}: 252d window includes "
                    f"{d.isoformat()} which is not strictly before "
                    f"{self.decision_date.isoformat()}"
                )
        # 4) last_bar_timestamp (if provided) must be strictly before 09:30 ET on decision_date.
        if self.last_bar_timestamp is not None:
            _assert_features_strictly_before_decision_open(
                self.symbol, self.decision_date, [self.last_bar_timestamp]
            )

    def __getattr__(self, item: str) -> Any:  # pragma: no cover - defensive
        # Only invoked if normal attribute lookup fails, but we want to be
        # explicit about forbidden access patterns even on dynamic getattr.
        kl = item.lower()
        for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS:
            if forbidden in kl:
                raise LookaheadViolationError(
                    f"FrozenT1Features for {self.symbol}: access to {item!r} blocked — "
                    f"contains forbidden substring {forbidden!r}"
                )
        raise AttributeError(item)


# ---------------------------------------------------------------------------
# Pure trigger feature computations
# ---------------------------------------------------------------------------


def compute_52w_high_breakout(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    lookback_days: int = 252,
) -> tuple[bool, float, float, list[dt.date]]:
    """Return (is_breakout, last_close, prior_max_high, used_window_dates).

    ``bars`` must be chronologically ordered AND strictly before the decision_date.
    The "prior 252-day high" is computed over the [-(lookback+1) .. -2] slice —
    i.e. the 252 days BEFORE T-1 — so T-1's own high never enters the max.
    Returns ``(False, last_close, 0.0, [])`` on insufficient history.
    """
    if len(bars) < 2:
        return False, 0.0, 0.0, []
    last_date, last_bar = bars[-1]
    last_close = float(last_bar.get("close", 0.0))
    if last_close <= 0:
        return False, 0.0, 0.0, []

    # Window = the 252 bars BEFORE T-1 (excludes T-1 itself).
    prior_window = bars[-(lookback_days + 1):-1]
    if len(prior_window) < max(20, lookback_days // 4):
        # Need at least a minimal window to claim a 52w high.
        return False, last_close, 0.0, []

    used_dates = [d for d, _ in prior_window]
    prior_max_high = max(float(b.get("high", 0.0)) for _, b in prior_window)
    is_breakout = last_close > prior_max_high
    return bool(is_breakout), last_close, float(prior_max_high), used_dates


def compute_volume_ratio(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    median_window: int = 20,
) -> tuple[float | None, float | None]:
    """Return (volume_T-1, median_volume_20d_T-2).

    Median is computed over the 20 bars BEFORE T-1 — i.e. ending at T-2.
    Returns (None, None) on insufficient data.
    """
    if len(bars) < median_window + 1:
        return None, None
    last_volume = float(bars[-1][1].get("volume", 0.0))
    prior_window = bars[-(median_window + 1):-1]
    prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window]
    if not prior_volumes:
        return None, None
    median_vol = float(statistics.median(prior_volumes))
    return last_volume, median_vol


def compute_atr_normalized(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    window: int = 14,
) -> float | None:
    """Compute ATR_14 / close_T-1 from the last 15 bars (need T-15..T-1)."""
    if len(bars) < window + 1:
        return None
    recent = bars[-(window + 1):]
    trs: list[float] = []
    prev_close = float(recent[0][1].get("close", 0.0))
    for d, bar in recent[1:]:
        high = float(bar.get("high", 0.0))
        low = float(bar.get("low", 0.0))
        close = float(bar.get("close", 0.0))
        tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
        trs.append(tr)
        prev_close = close
    if not trs:
        return None
    atr = statistics.fmean(trs)
    last_close = float(bars[-1][1].get("close", 0.0))
    if last_close <= 0:
        return None
    return atr / last_close


def compute_avg_dollar_volume_20d(
    bars: list[tuple[dt.date, dict[str, Any]]],
) -> float:
    """Mean(close * volume) over the last 20 bars."""
    if not bars:
        return 0.0
    tail = bars[-20:]
    if not tail:
        return 0.0
    return statistics.fmean(
        float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
        for _, b in tail
    )


# ---------------------------------------------------------------------------
# Trigger evaluation
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class VolBreakout52wTriggerInputs:
    """Bundle of T-1-or-earlier inputs for one (symbol, decision_date) trigger.

    NOTE: ``last_bar_date`` MUST be strictly before ``decision_date``. The
    builder enforces this and ``evaluate_trigger`` re-asserts as defence in depth.
    """

    symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    last_bar_date: dt.date
    last_bar_timestamp: dt.datetime  # tz-aware
    last_close: float
    prior_252d_max_high: float
    is_52w_breakout: bool
    volume_t_minus_1: float
    median_volume_20d_t_minus_2: float
    atr_normalized_t_minus_1: float
    avg_dollar_volume_20d: float
    pre_open_gap_pct: float | None  # may be None when missing-data path is taken


def evaluate_trigger(
    inputs: VolBreakout52wTriggerInputs,
    engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
    """Pure trigger check. Returns (passes, reject_reason).

    Defence-in-depth: re-assert ``last_bar_date < decision_date`` so any future
    code path that bypasses the BarHistoryProvider boundary still trips here.
    """
    if inputs.last_bar_date >= inputs.decision_date:
        raise LookaheadViolationError(
            f"VolBreakout52w {inputs.symbol}: last_bar_date {inputs.last_bar_date.isoformat()} "
            f"is not strictly before decision_date {inputs.decision_date.isoformat()}"
        )

    # Universe gates — ADV and price.
    adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0)
    if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min:
        return False, (
            f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} "
            f"< min {adv_min:,.0f}"
        )
    price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0)
    if price_min > 0 and inputs.last_close < price_min:
        return False, f"last_close {inputs.last_close:.2f} < min price {price_min:.2f}"

    # 1) 52-week breakout.
    if not inputs.is_52w_breakout:
        return False, (
            f"close {inputs.last_close:.4f} <= prior 252d max high "
            f"{inputs.prior_252d_max_high:.4f}"
        )

    # 2) Volume confirmation.
    vol_ratio_min = float(getattr(engine, "vol_breakout_52w_volume_ratio_min", 2.0) or 0.0)
    if inputs.median_volume_20d_t_minus_2 <= 0:
        return False, "median_volume_20d_t_minus_2 <= 0"
    actual_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2
    if actual_ratio < vol_ratio_min:
        return False, (
            f"volume_ratio {actual_ratio:.3f} < min {vol_ratio_min:.3f}"
        )

    # 3) ATR / close band — filter parabolics AND too-quiet stocks.
    atr_min = float(getattr(engine, "vol_breakout_52w_atr_normalized_min", 0.015) or 0.0)
    atr_max = float(getattr(engine, "vol_breakout_52w_atr_normalized_max", 0.06) or 1.0)
    if inputs.atr_normalized_t_minus_1 < atr_min:
        return False, (
            f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} < min {atr_min:.4f}"
        )
    if inputs.atr_normalized_t_minus_1 > atr_max:
        return False, (
            f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} > max {atr_max:.4f}"
        )

    # 4) Pre-open gap fade guard.
    gap_max = float(getattr(engine, "vol_breakout_52w_pre_open_gap_max", 0.04) or 0.0)
    if inputs.pre_open_gap_pct is not None and gap_max > 0:
        if inputs.pre_open_gap_pct > gap_max:
            return False, (
                f"pre_open_gap_pct {inputs.pre_open_gap_pct:.4f} > max {gap_max:.4f}"
            )
    # If pre_open_gap_pct is None, the missing-data path was already chosen at the
    # builder level (skip-with-warning vs. hard-fail).

    return True, None


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


def build_candidates(
    decision_date: dt.date,
    next_trading_date: dt.date,
    universe_symbols: Iterable[str],
    engine: StrategyEngineConfig,
    bar_provider: BarHistoryProvider,
    pre_open_gap_provider: PreOpenGapProvider | None = None,
    *,
    _missing_gap_warned: dict[str, bool] | None = None,
) -> list[Candidate]:
    """Construct synthetic VolBreakout52w candidates for ``next_trading_date`` execution.

    Decision logic runs at T-1 close (=decision_date close); orders fill at
    T+1 next_open. Every input must satisfy ``timestamp < decision_date 09:30 ET``.

    ``pre_open_gap_provider`` is optional. When None, behavior depends on
    ``engine.vol_breakout_52w_skip_if_no_gap_data``:
      * True  → skip the gap guard (no enforcement) and log a one-shot warning.
      * False → do not enforce (no enforcement) and log a one-shot warning.
    Either way the engine emits candidates without the gap guard. A loud
    one-shot log surfaces the missing infra.
    """
    if not getattr(engine, "vol_breakout_52w_enabled", False):
        return []
    if next_trading_date <= decision_date:
        raise LookaheadViolationError(
            f"VolBreakout52w next_trading_date {next_trading_date.isoformat()} must be "
            f"strictly after decision_date {decision_date.isoformat()}"
        )

    lookback = int(getattr(engine, "vol_breakout_52w_lookback_days", 252) or 252)
    median_window = int(getattr(engine, "vol_breakout_52w_volume_median_window", 20) or 20)
    skip_if_no_gap = bool(getattr(engine, "vol_breakout_52w_skip_if_no_gap_data", False))

    # One-shot missing-gap warning aggregation. Caller may pass a shared dict.
    warned = _missing_gap_warned if _missing_gap_warned is not None else {}
    if pre_open_gap_provider is None and not warned.get("logged"):
        if skip_if_no_gap:
            logger.warning(
                "vol_breakout_52w_pre_open_gap_provider_missing",
                action="skip_gap_guard",
                detail=(
                    "PreOpenGapProvider not wired; the +4% gap-fade guard is INACTIVE. "
                    "Backtest results will under-penalize gap-up days. Mark all derived "
                    "PnL as 'missing pre-open guard'."
                ),
            )
        else:
            logger.warning(
                "vol_breakout_52w_pre_open_gap_provider_missing",
                action="no_enforcement_no_skip",
                detail="PreOpenGapProvider not wired and skip flag is False — gap guard inactive.",
            )
        warned["logged"] = True

    candidates: list[Candidate] = []
    seen_symbols: set[str] = set()

    # Need enough bars for both the 252d window and the 20d volume median.
    fetch_lookback = max(lookback + 5, median_window + 5)

    for raw_symbol in universe_symbols:
        symbol = str(raw_symbol).strip().upper()
        if not symbol or symbol in seen_symbols:
            continue
        seen_symbols.add(symbol)

        bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback)
        if not bars:
            continue

        # Strict T-1 check: most recent allowed bar must be < decision_date.
        last_bar_date, last_bar = bars[-1]
        if last_bar_date >= decision_date:
            raise LookaheadViolationError(
                f"VolBreakout52w bar for {symbol} on {last_bar_date.isoformat()} is not "
                f"strictly before decision_date {decision_date.isoformat()}"
            )

        last_close = float(last_bar.get("close", 0.0))
        if last_close <= 0:
            continue

        # --- Cheap universe gates first to short-circuit before the 252d scan ---
        adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0)
        price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0)
        if price_min > 0 and last_close < price_min:
            continue
        adv_20d = compute_avg_dollar_volume_20d(bars)
        if adv_min > 0 and adv_20d < adv_min:
            continue

        is_breakout, _last_close_check, prior_max_high, used_dates = compute_52w_high_breakout(
            bars, lookback_days=lookback
        )
        # Defence-in-depth: every used date in the 252d window must be strictly < decision_date.
        for d in used_dates:
            if d >= decision_date:
                raise LookaheadViolationError(
                    f"VolBreakout52w 252d window for {symbol} includes {d.isoformat()} "
                    f"which is not strictly before decision_date {decision_date.isoformat()}"
                )
        if not is_breakout:
            continue

        vol_t1, median_vol_t2 = compute_volume_ratio(bars, median_window=median_window)
        if vol_t1 is None or median_vol_t2 is None or median_vol_t2 <= 0:
            continue

        atr_norm = compute_atr_normalized(bars, window=14)
        if atr_norm is None:
            continue

        # Pre-open gap (optional).
        pre_open_gap_pct: float | None = None
        if pre_open_gap_provider is not None:
            try:
                pre_open_gap_pct = pre_open_gap_provider.get_pre_open_gap_pct(
                    symbol=symbol,
                    next_trading_date=next_trading_date,
                    prev_close=last_close,
                )
            except Exception as exc:  # noqa: BLE001
                logger.debug(
                    "vol_breakout_52w_pre_open_gap_provider_error",
                    symbol=symbol,
                    error=str(exc),
                )
                pre_open_gap_pct = None

        last_bar_ts = _bar_close_timestamp(last_bar_date)
        # Hot-path lookahead assertion.
        _assert_features_strictly_before_decision_open(
            symbol, decision_date, [last_bar_ts]
        )

        inputs = VolBreakout52wTriggerInputs(
            symbol=symbol,
            decision_date=decision_date,
            next_trading_date=next_trading_date,
            last_bar_date=last_bar_date,
            last_bar_timestamp=last_bar_ts,
            last_close=last_close,
            prior_252d_max_high=prior_max_high,
            is_52w_breakout=is_breakout,
            volume_t_minus_1=vol_t1,
            median_volume_20d_t_minus_2=median_vol_t2,
            atr_normalized_t_minus_1=atr_norm,
            avg_dollar_volume_20d=adv_20d,
            pre_open_gap_pct=pre_open_gap_pct,
        )

        passes, reason = evaluate_trigger(inputs, engine)
        if not passes:
            logger.debug(
                "vol_breakout_52w_trigger_skipped",
                symbol=symbol,
                decision_date=decision_date.isoformat(),
                reason=reason,
            )
            continue

        candidate = _build_candidate_from_inputs(inputs, engine)
        candidates.append(candidate)

    return candidates


# ---------------------------------------------------------------------------
# Candidate construction
# ---------------------------------------------------------------------------


def _build_candidate_from_inputs(
    inputs: VolBreakout52wTriggerInputs,
    engine: StrategyEngineConfig,
) -> Candidate:
    # Map pct exits onto the existing ATR-multiplier / R-multiple machinery.
    synthetic_atr = max(inputs.last_close * 0.02, 0.01)
    stop_pct = float(getattr(engine, "vol_breakout_52w_stop_pct", 0.03) or 0.03)
    target_pct = float(getattr(engine, "vol_breakout_52w_target_pct", 0.05) or 0.05)
    stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.5
    target_r = target_pct / stop_pct if stop_pct > 0 else 1.67
    max_holding_days = max(1, int(getattr(engine, "vol_breakout_52w_max_holding_days", 2) or 2))

    # Score: deterministic function of the volume spike — higher conviction at higher ratio.
    if inputs.median_volume_20d_t_minus_2 > 0:
        vol_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2
    else:
        vol_ratio = 1.0
    score = 0.5 + 0.05 * (vol_ratio - 2.0)
    score = max(0.0, min(0.99, score))
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

    event_id = (
        f"synth_vol_breakout_52w_{inputs.symbol.lower()}_"
        f"{inputs.decision_date.isoformat()}"
    )

    features = {
        "vol_breakout_52w_decision_date": inputs.decision_date.isoformat(),
        "vol_breakout_52w_last_close": inputs.last_close,
        "vol_breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high,
        "vol_breakout_52w_volume_t_minus_1": inputs.volume_t_minus_1,
        "vol_breakout_52w_median_volume_20d_t_minus_2": inputs.median_volume_20d_t_minus_2,
        "vol_breakout_52w_volume_ratio": round(vol_ratio, 4),
        "vol_breakout_52w_atr_normalized_t_minus_1": round(inputs.atr_normalized_t_minus_1, 6),
        "vol_breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
        "vol_breakout_52w_pre_open_gap_pct": inputs.pre_open_gap_pct,
        "vol_breakout_52w_stop_pct": stop_pct,
        "vol_breakout_52w_target_pct": target_pct,
        "vol_breakout_52w_max_holding_days": max_holding_days,
    }

    return Candidate(
        event_id=event_id,
        symbol=inputs.symbol,
        source_symbol=inputs.symbol,
        score=score,
        sector="UNKNOWN",
        event_type=VOL_BREAKOUT_52W_EVENT_TYPE,
        event_timestamp=inputs.last_bar_timestamp,
        event_date=inputs.decision_date,
        filing_time_bucket="post_market",
        timing_class="after_close",
        reaction_date=inputs.decision_date,
        execution_date=inputs.next_trading_date,
        entry_price_est=inputs.last_close,
        avg_dollar_volume=inputs.avg_dollar_volume_20d,
        atr_14=synthetic_atr,
        score_bucket=score_bucket,
        engine_id=engine.engine_id,
        entry_timing_policy="next_open",
        trade_direction="long",
        engine_max_holding_days=max_holding_days,
        engine_risk_budget_pct=engine.engine_risk_budget_pct,
        engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
        engine_target_1_r=target_r,
        engine_target_1_fraction=1.0,
        engine_trailing_model=engine.trailing_model_override,
        engine_trailing_warmup_days=engine.trailing_warmup_days_override,
        engine_stop_atr_multiplier=stop_mult,
        engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
        engine_use_reaction_day_low_stop=False,
        engine_early_failure_close_below_entry_and_reaction_close=False,
        engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
        engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
        engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
        shadow_only=engine.shadow_only,
        features=features,
    )


# ---------------------------------------------------------------------------
# Adapters: bridge BacktestRunner state to the Protocols above.
# ---------------------------------------------------------------------------


@dataclass
class _SnapshotStoreBarAdapter:
    """Adapt SnapshotStore (or any bars-by-symbol-by-date dict) to BarHistoryProvider.

    Caches the sorted (date, bar) list per symbol so the per-day loop does not
    re-sort O(B) bars on each call. This is the hot path for VolBreakout52w
    because the universe is scanned daily, unlike event-triggered engines.
    """

    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
    _sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict)

    def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]:
        sym_upper = symbol.upper()
        cached = self._sorted_cache.get(sym_upper)
        if cached is not None:
            return cached
        sym_bars = self.bars_by_symbol.get(sym_upper)
        if not sym_bars:
            self._sorted_cache[sym_upper] = []
            return self._sorted_cache[sym_upper]
        ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
        self._sorted_cache[sym_upper] = ordered
        return ordered

    def get_bars_before(
        self,
        symbol: str,
        as_of_date: dt.date,
        lookback_days: int,
    ) -> list[tuple[dt.date, dict[str, Any]]]:
        ordered = self._sorted_for(symbol)
        if not ordered:
            return []
        # Binary scan would be faster but we cap lookback small, so linear-from-end is fine.
        # Strictly before as_of_date.
        eligible: list[tuple[dt.date, dict[str, Any]]] = []
        for d, b in ordered:
            if d >= as_of_date:
                break
            eligible.append((d, b))
        return eligible[-lookback_days:]


__all__ = [
    "VOL_BREAKOUT_52W_EVENT_TYPE",
    "BarHistoryProvider",
    "FrozenT1Features",
    "PreOpenGapProvider",
    "VolBreakout52wTriggerInputs",
    "_SnapshotStoreBarAdapter",
    "_assert_features_strictly_before_decision_open",
    "build_candidates",
    "compute_52w_high_breakout",
    "compute_atr_normalized",
    "compute_avg_dollar_volume_20d",
    "compute_volume_ratio",
    "evaluate_trigger",
]
