"""PeerSympathy engine.

When a sector leader fires a qualifying PEAD trigger (earnings/guidance/material
contract) with a strong same-day reaction, buy the top-correlated peers at the
next open. Catches sympathy rallies (e.g., AVGO/AMD/MU on NVDA's print) that the
core PEAD universe-filtered engines architecturally miss because they only fire
on the symbol that filed.

This module is the *pure* logic — `BacktestRunner` calls into
``build_peer_sympathy_candidates`` from a thin scheduling hook. Provider
Protocols allow stubbed unit tests.

Architectural choice: synthetic Candidate emission into the existing
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`
and `_schedule_earnings_runup_candidates`.

Look-ahead defenses (NON-NEGOTIABLE):
  1. Correlation window is `[T-window_start, T-window_end_skip)`. The last
     ``window_end_skip`` trading days are skipped so peer co-movement during the
     leader's own pre-event drift cannot leak into the correlation.
  2. Peer T+0 (leader event day) reaction is NEVER consulted in selection. Only
     leader's print and the peer's bar history strictly before T are used.
  3. ``next_trading_date > leader.event_date`` (peer entry strictly after leader
     publication). Enforced via ``LookaheadViolationError``.
"""
from __future__ import annotations

import datetime as dt
import math
import statistics
from dataclasses import dataclass
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__)

PEER_SYMPATHY_EVENT_TYPE = "peer_sympathy"

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


# ---------------------------------------------------------------------------
# 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 UpcomingEarningsProvider(Protocol):
    """Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``.

    Used here to enforce the peer-earnings blackout: don't buy a peer whose own
    print is within ``blackout_days_to_peer_event`` trading days.
    """

    def get_next_reaction_date(
        self,
        symbol: str,
        as_of_date: dt.date,
        max_lookahead_calendar_days: int,
    ) -> dt.date | None: ...


# ---------------------------------------------------------------------------
# Lightweight info struct: leader print as evaluated against engine filters.
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class LeaderPrint:
    """Subset of leader candidate / event row consumed by PeerSympathy.

    The runner adapts ``Candidate`` rows to this struct so the pure logic does
    not depend on the heavyweight ``Candidate`` model and is trivially fakeable
    in unit tests.
    """

    symbol: str
    sector: str
    event_id: str
    event_type: str
    event_date: dt.date
    event_timestamp: dt.datetime  # tz-aware
    reaction_day_return: float
    score: float = 0.5
    # ``filing_time_bucket`` lets the salvage variant (entry_timing_policy=
    # "reaction_close") restrict to BMO / regular-hours leader prints so
    # post-market filings — which can't be sympathy-traded same-day — are
    # excluded. Defaults to "post_market" so existing call-sites that haven't
    # been migrated still match the prior behaviour (no filter applied).
    filing_time_bucket: str = "post_market"


# ---------------------------------------------------------------------------
# Trigger
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class PeerSympathyTriggerInputs:
    """Bundle of inputs for one (peer, decision_date=leader.event_date) trigger evaluation."""

    leader_symbol: str
    leader_sector: str
    leader_event_type: str
    leader_reaction: float
    peer_symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    correlation: float
    peer_last_close: float
    peer_avg_dollar_volume_20d: float
    peer_last_bar_date: dt.date
    peer_last_bar_timestamp: dt.datetime  # tz-aware
    peer_upcoming_earnings_reaction_date: dt.date | None
    peer_trading_days_to_own_earnings: int | None


def evaluate_trigger(
    inputs: PeerSympathyTriggerInputs,
    engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
    """Pure trigger check. Returns (passes, reject_reason)."""
    allowed_event_types = {
        str(e).strip().lower()
        for e in (engine.peer_sympathy_leader_event_types or [])
        if str(e).strip()
    }
    if allowed_event_types and inputs.leader_event_type.lower() not in allowed_event_types:
        return False, f"leader_event_type {inputs.leader_event_type!r} not in {sorted(allowed_event_types)}"

    if inputs.leader_reaction < engine.peer_sympathy_leader_reaction_min:
        return False, (
            f"leader_reaction {inputs.leader_reaction:.4f} < "
            f"min {engine.peer_sympathy_leader_reaction_min}"
        )

    if inputs.correlation < engine.peer_sympathy_correlation_min:
        return False, (
            f"correlation {inputs.correlation:.4f} < min {engine.peer_sympathy_correlation_min}"
        )

    blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))
    if blackout > 0 and inputs.peer_trading_days_to_own_earnings is not None:
        if inputs.peer_trading_days_to_own_earnings <= blackout:
            return False, (
                f"peer_trading_days_to_own_earnings "
                f"{inputs.peer_trading_days_to_own_earnings} <= blackout {blackout}"
            )
    return True, None


# ---------------------------------------------------------------------------
# Look-ahead helpers
# ---------------------------------------------------------------------------


def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
    """09:30 ET on decision_date, expressed as a UTC-aware timestamp.

    Any feature timestamp >= this instant carries information from inside the
    entry day and constitutes a look-ahead violation. The decision day for
    peer entry is ``next_trading_date``, NOT ``decision_date`` (=leader.event_date),
    so the cutoff for peer features is ``next_trading_date``'s 09:30 ET.
    """
    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_no_lookahead(
    symbol: str,
    decision_date: dt.date,
    feature_timestamps: Iterable[dt.datetime],
) -> None:
    cutoff = _decision_cutoff_utc(decision_date)
    for ts in feature_timestamps:
        if ts is None:
            continue
        if ts.tzinfo is None:
            raise LookaheadViolationError(
                f"PeerSympathy feature timestamp for {symbol} is naive ({ts.isoformat()}); "
                "all timestamps must be timezone-aware"
            )
        if ts >= cutoff:
            raise LookaheadViolationError(
                f"PeerSympathy feature timestamp {ts.isoformat()} for {symbol} is "
                f">= decision cutoff {cutoff.isoformat()}; this is a look-ahead violation"
            )


def _assert_correlation_window_safe(
    *,
    leader_symbol: str,
    peer_symbol: str,
    decision_date: dt.date,
    window_end_skip: int,
    used_dates: list[dt.date],
    trading_days: list[dt.date] | None,
) -> None:
    """Assert that NO date used in the correlation series is within
    ``window_end_skip`` trading days of ``decision_date``.

    This is the canonical 'last N days skipped' invariant. We compute the
    forbidden boundary as the trading day exactly ``window_end_skip`` days
    BEFORE ``decision_date`` (or, if the trading-day list is missing, fall back
    to a calendar-day approximation that is strictly conservative).
    """
    if not used_dates:
        return

    if trading_days:
        try:
            d_idx = trading_days.index(decision_date)
        except ValueError:
            # decision_date not in the calendar — fall back to calendar-day check.
            forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)
        else:
            cut = max(0, d_idx - window_end_skip)
            forbidden_floor = trading_days[cut] if cut < len(trading_days) else trading_days[0]
    else:
        # Calendar-day fallback: ``window_end_skip`` calendar days. Conservative.
        forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)

    most_recent_used = max(used_dates)
    if most_recent_used >= forbidden_floor:
        raise LookaheadViolationError(
            f"PeerSympathy correlation window for ({leader_symbol},{peer_symbol}) "
            f"included {most_recent_used.isoformat()} which is within {window_end_skip} "
            f"trading days of decision_date {decision_date.isoformat()} "
            f"(forbidden floor {forbidden_floor.isoformat()}); "
            "the last N days MUST be skipped to avoid co-movement leakage"
        )


# ---------------------------------------------------------------------------
# Correlation: shared-date log-return Pearson on bars strictly before T-skip
# ---------------------------------------------------------------------------


def _log_returns_by_date(
    bars: list[tuple[dt.date, dict[str, Any]]],
) -> list[tuple[dt.date, float]]:
    """Pairwise log-returns ln(close_t / close_{t-1}); date is the close date of t."""
    out: list[tuple[dt.date, float]] = []
    prev_close: float | None = None
    for d, bar in bars:
        close = float(bar.get("close", 0.0))
        if close <= 0:
            prev_close = None
            continue
        if prev_close is not None and prev_close > 0:
            out.append((d, math.log(close / prev_close)))
        prev_close = close
    return out


def compute_correlation(
    leader_bars: list[tuple[dt.date, dict[str, Any]]],
    peer_bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    decision_date: dt.date,
    window_start: int,
    window_end_skip: int,
    trading_days: list[dt.date] | None = None,
) -> tuple[float | None, list[dt.date]]:
    """Pearson correlation of log-returns over the [T-window_start, T-window_end_skip) window.

    Returns ``(correlation, used_dates)``. ``correlation`` is ``None`` if there
    are insufficient overlapping observations (< 5 paired returns).

    The function intentionally never reads bars dated >= decision_date — that
    would be a look-ahead — and always strips the last ``window_end_skip``
    trading days from the eligible-date set.
    """
    if window_start <= 0 or window_end_skip < 0 or window_start <= window_end_skip:
        return None, []

    # Determine the latest allowable date in the window (strictly before T-skip).
    if trading_days:
        try:
            d_idx = trading_days.index(decision_date)
        except ValueError:
            d_idx = None
        if d_idx is not None:
            top_idx = d_idx - window_end_skip  # exclusive upper bound on dates
            bot_idx = max(0, d_idx - window_start)
            if top_idx <= bot_idx:
                return None, []
            allowed_dates = set(trading_days[bot_idx:top_idx])
        else:
            allowed_dates = None
    else:
        allowed_dates = None

    leader_returns = _log_returns_by_date(leader_bars)
    peer_returns = _log_returns_by_date(peer_bars)

    leader_by_date = dict(leader_returns)
    peer_by_date = dict(peer_returns)
    shared = sorted(set(leader_by_date) & set(peer_by_date))

    # Apply allowed-date filter when we know the trading calendar.
    if allowed_dates is not None:
        shared = [d for d in shared if d in allowed_dates]
    else:
        # Calendar-day fallback: drop dates within ``window_end_skip`` calendar days
        # of decision_date AND keep only dates within ``window_start`` calendar days.
        skip_floor = decision_date - dt.timedelta(days=window_end_skip)
        start_floor = decision_date - dt.timedelta(days=window_start * 2)  # generous
        shared = [d for d in shared if d < skip_floor and d >= start_floor]

    # Strict ceiling: all dates must be < decision_date (defence in depth).
    shared = [d for d in shared if d < decision_date]

    if len(shared) < 5:
        return None, shared

    leader_xs = [leader_by_date[d] for d in shared]
    peer_xs = [peer_by_date[d] for d in shared]
    n = len(leader_xs)
    mean_l = statistics.fmean(leader_xs)
    mean_p = statistics.fmean(peer_xs)
    cov = sum((leader_xs[i] - mean_l) * (peer_xs[i] - mean_p) for i in range(n)) / n
    var_l = sum((x - mean_l) ** 2 for x in leader_xs) / n
    var_p = sum((x - mean_p) ** 2 for x in peer_xs) / n
    if var_l <= 0 or var_p <= 0:
        return None, shared
    rho = cov / math.sqrt(var_l * var_p)
    if math.isnan(rho) or math.isinf(rho):
        return None, shared
    return float(rho), shared


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


def build_peer_sympathy_candidates(
    decision_date: dt.date,
    next_trading_date: dt.date,
    leaders: Iterable[LeaderPrint],
    peer_resolver: "PeerResolver",
    engine: StrategyEngineConfig,
    bar_provider: BarHistoryProvider,
    upcoming_earnings_provider: UpcomingEarningsProvider | None = None,
    trading_days: list[dt.date] | None = None,
) -> list[Candidate]:
    """Construct synthetic peer-sympathy candidates for ``next_trading_date`` execution.

    ``leaders`` are the qualifying leader prints from T (=decision_date). For
    each leader we:
      - resolve its peer set,
      - compute correlation on the [T-window_start, T-window_end_skip) window,
      - drop peers below ``correlation_min``,
      - keep top-N peers by correlation,
      - emit a synthetic Candidate per peer.
    """
    if not engine.peer_sympathy_enabled:
        return []

    entry_policy = (engine.peer_sympathy_entry_timing_policy or "next_open").strip().lower()
    if entry_policy not in ("next_open", "reaction_close"):
        raise ValueError(
            f"PeerSympathy unsupported entry_timing_policy {entry_policy!r}; "
            "expected 'next_open' or 'reaction_close'"
        )
    is_reaction_close = entry_policy == "reaction_close"

    # Lookahead invariant.
    #   next_open: peer entry is strictly after leader publication (T+1).
    #   reaction_close: peer entry is the SAME trading day's close (==T). The
    #     stronger guard is the per-leader leader.event_timestamp < T 16:00 ET
    #     check below.
    if not is_reaction_close and next_trading_date <= decision_date:
        raise LookaheadViolationError(
            f"PeerSympathy next_trading_date {next_trading_date.isoformat()} must be "
            f"strictly after leader event_date {decision_date.isoformat()} "
            f"(entry_timing_policy={entry_policy!r})"
        )
    if is_reaction_close and next_trading_date < decision_date:
        raise LookaheadViolationError(
            f"PeerSympathy next_trading_date {next_trading_date.isoformat()} must be "
            f">= decision_date {decision_date.isoformat()} when entry_timing_policy="
            f"'reaction_close'"
        )

    allowed_buckets = {
        str(b).strip().lower()
        for b in (engine.peer_sympathy_leader_filing_time_buckets or [])
        if str(b).strip()
    }

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

    allowed_event_types = {
        str(e).strip().lower()
        for e in (engine.peer_sympathy_leader_event_types or [])
        if str(e).strip()
    }
    leader_reaction_min = float(engine.peer_sympathy_leader_reaction_min)
    corr_min = float(engine.peer_sympathy_correlation_min)
    window_start = int(engine.peer_sympathy_correlation_window_start)
    window_end_skip = int(engine.peer_sympathy_correlation_window_end_skip)
    top_n = max(1, int(engine.peer_sympathy_top_n_peers or 1))
    blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))

    for leader in leaders:
        leader_symbol = str(leader.symbol or "").strip().upper()
        if not leader_symbol:
            continue
        # Cheap leader-side gates first to avoid unnecessary bar fetches.
        if allowed_event_types and leader.event_type.lower() not in allowed_event_types:
            continue
        if leader.reaction_day_return < leader_reaction_min:
            continue
        # Filing-time bucket allow-list (used by the salvage variant to skip
        # AMC prints that can't be sympathy-traded intra-session).
        if allowed_buckets:
            bucket = (leader.filing_time_bucket or "").strip().lower()
            if bucket not in allowed_buckets:
                logger.debug(
                    "peer_sympathy_skip_leader_filing_time_bucket",
                    leader=leader_symbol,
                    bucket=bucket,
                    allowed=sorted(allowed_buckets),
                )
                continue

        # Lookahead: leader event_timestamp must precede peer entry cutoff.
        # next_open path: cutoff is T+1 09:30 ET.
        # reaction_close path: cutoff is T 16:00 ET (peer's same-day close).
        if is_reaction_close:
            peer_decision_cutoff = _bar_close_timestamp(decision_date)
        else:
            peer_decision_cutoff = _decision_cutoff_utc(next_trading_date)
        if leader.event_timestamp.tzinfo is None:
            raise LookaheadViolationError(
                f"PeerSympathy leader {leader_symbol} has naive event_timestamp "
                f"{leader.event_timestamp.isoformat()}"
            )
        if leader.event_timestamp >= peer_decision_cutoff:
            raise LookaheadViolationError(
                f"PeerSympathy leader {leader_symbol} event_timestamp "
                f"{leader.event_timestamp.isoformat()} is at-or-after peer entry cutoff "
                f"{peer_decision_cutoff.isoformat()} (entry_timing_policy={entry_policy!r})"
            )

        # Pull leader bars once per leader.
        leader_bars = bar_provider.get_bars_before(
            leader_symbol, decision_date, lookback_days=window_start + 5
        )
        if len(leader_bars) < window_start - window_end_skip:
            logger.debug(
                "peer_sympathy_skip_leader_insufficient_bars",
                leader=leader_symbol,
                bars=len(leader_bars),
                decision_date=decision_date.isoformat(),
            )
            continue

        peers = peer_resolver.peers_for_leader(engine, leader_symbol, leader.sector)
        if not peers:
            continue

        # Compute correlation per peer; collect (peer, corr, last_bar_meta).
        scored_peers: list[tuple[str, float, list[tuple[dt.date, dict[str, Any]]]]] = []
        for peer_symbol in peers:
            peer_symbol = str(peer_symbol).strip().upper()
            if not peer_symbol or peer_symbol == leader_symbol:
                continue
            peer_bars = bar_provider.get_bars_before(
                peer_symbol, decision_date, lookback_days=window_start + 5
            )
            if len(peer_bars) < window_start - window_end_skip:
                continue
            corr, used_dates = compute_correlation(
                leader_bars,
                peer_bars,
                decision_date=decision_date,
                window_start=window_start,
                window_end_skip=window_end_skip,
                trading_days=trading_days,
            )
            if corr is None:
                continue
            # Hot-path lookahead assertion on the dates actually used.
            _assert_correlation_window_safe(
                leader_symbol=leader_symbol,
                peer_symbol=peer_symbol,
                decision_date=decision_date,
                window_end_skip=window_end_skip,
                used_dates=used_dates,
                trading_days=trading_days,
            )
            if corr < corr_min:
                continue
            scored_peers.append((peer_symbol, corr, peer_bars))

        # Top-N peers by correlation.
        scored_peers.sort(key=lambda x: x[1], reverse=True)
        scored_peers = scored_peers[:top_n]

        for peer_symbol, corr, peer_bars in scored_peers:
            if peer_symbol in seen_peer_for_decision:
                continue

            last_bar_date, last_bar = peer_bars[-1]
            if last_bar_date >= decision_date:
                raise LookaheadViolationError(
                    f"PeerSympathy peer bar for {peer_symbol} on {last_bar_date.isoformat()} "
                    f"is not strictly before decision_date {decision_date.isoformat()}"
                )

            last_close = float(last_bar.get("close", 0.0))
            if last_close <= 0:
                continue
            volumes = [float(b.get("volume", 0.0)) for _, b in peer_bars[-20:]]
            closes = [float(b.get("close", 0.0)) for _, b in peer_bars[-20:]]
            if len(volumes) < 5:
                continue
            adv_20d = statistics.fmean(c * v for c, v in zip(closes, volumes))

            peer_last_bar_ts = _bar_close_timestamp(last_bar_date)
            # Cutoff is the FIRST instant at which we could read peer
            # quantities for the entry:
            #   next_open:      T+1 09:30 ET (use _decision_cutoff_utc)
            #   reaction_close: T   16:00 ET (use _bar_close_timestamp(T))
            # Both feature timestamps (peer last bar, leader event_timestamp)
            # must be strictly before this cutoff.
            if is_reaction_close:
                cutoff = _bar_close_timestamp(decision_date)
                for ts in (peer_last_bar_ts, leader.event_timestamp):
                    if ts is None:
                        continue
                    if ts.tzinfo is None:
                        raise LookaheadViolationError(
                            f"PeerSympathy feature timestamp for {peer_symbol} is naive "
                            f"({ts.isoformat()}); all timestamps must be timezone-aware"
                        )
                    if ts >= cutoff:
                        raise LookaheadViolationError(
                            f"PeerSympathy feature timestamp {ts.isoformat()} for "
                            f"{peer_symbol} is >= reaction_close cutoff "
                            f"{cutoff.isoformat()} (decision_date={decision_date.isoformat()})"
                        )
            else:
                _assert_no_lookahead(
                    peer_symbol, next_trading_date, [peer_last_bar_ts, leader.event_timestamp]
                )

            # Peer-earnings blackout — uses an UpcomingEarningsProvider if available.
            peer_upcoming = None
            peer_days_to_own = None
            if upcoming_earnings_provider is not None and blackout > 0:
                peer_upcoming = upcoming_earnings_provider.get_next_reaction_date(
                    symbol=peer_symbol,
                    as_of_date=decision_date,
                    max_lookahead_calendar_days=blackout * 3 + 7,
                )
                if peer_upcoming is not None:
                    peer_days_to_own = _trading_days_between(
                        next_trading_date, peer_upcoming, trading_days
                    )

            inputs = PeerSympathyTriggerInputs(
                leader_symbol=leader_symbol,
                leader_sector=leader.sector,
                leader_event_type=leader.event_type,
                leader_reaction=leader.reaction_day_return,
                peer_symbol=peer_symbol,
                decision_date=decision_date,
                next_trading_date=next_trading_date,
                correlation=corr,
                peer_last_close=last_close,
                peer_avg_dollar_volume_20d=adv_20d,
                peer_last_bar_date=last_bar_date,
                peer_last_bar_timestamp=peer_last_bar_ts,
                peer_upcoming_earnings_reaction_date=peer_upcoming,
                peer_trading_days_to_own_earnings=peer_days_to_own,
            )
            passes, reason = evaluate_trigger(inputs, engine)
            if not passes:
                logger.debug(
                    "peer_sympathy_trigger_skipped",
                    leader=leader_symbol,
                    peer=peer_symbol,
                    decision_date=decision_date.isoformat(),
                    reason=reason,
                )
                continue

            candidate = _build_candidate_from_inputs(
                inputs, leader, engine, is_reaction_close=is_reaction_close
            )
            candidates.append(candidate)
            seen_peer_for_decision.add(peer_symbol)

    return candidates


# ---------------------------------------------------------------------------
# Peer resolver (Protocol so the runner adapter and the test fake share an API)
# ---------------------------------------------------------------------------


class PeerResolver(Protocol):
    """Resolves a leader's peer set, filtered by engine config.

    Reuses the existing leader-follower infra
    (``leader_follower_extra_peer_symbols_by_sector``,
    ``leader_follower_extra_peer_symbols_by_leader``,
    ``leader_follower_allowed_peer_symbols``) and the proxies module's
    ``peer_candidates_for_symbol`` (sector-ETF holdings + per-leader curated set).
    """

    def peers_for_leader(
        self,
        engine: StrategyEngineConfig,
        leader_symbol: str,
        leader_sector: str,
    ) -> list[str]: ...


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _trading_days_between(
    start_date: dt.date,
    target_date: dt.date,
    trading_days: list[dt.date] | None,
) -> int | None:
    if trading_days:
        try:
            i0 = trading_days.index(start_date)
        except ValueError:
            return None
        try:
            i1 = trading_days.index(target_date)
        except ValueError:
            return None
        return i1 - i0
    if target_date <= start_date:
        return 0
    count = 0
    cursor = start_date
    while cursor < target_date:
        cursor = cursor + dt.timedelta(days=1)
        if cursor.weekday() < 5:
            count += 1
    return count


def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
    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)


def _build_candidate_from_inputs(
    inputs: PeerSympathyTriggerInputs,
    leader: LeaderPrint,
    engine: StrategyEngineConfig,
    *,
    is_reaction_close: bool = False,
) -> Candidate:
    # Map pct exits to the existing ATR-multiplier / R-multiple machinery.
    synthetic_atr = max(inputs.peer_last_close * 0.02, 0.01)
    stop_pct = float(engine.peer_sympathy_stop_pct)
    target_pct = float(engine.peer_sympathy_target_pct)
    stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.75
    target_r = target_pct / stop_pct if stop_pct > 0 else 1.71

    max_holding_days = max(1, int(engine.peer_sympathy_max_holding_days or 3))
    if (
        inputs.peer_trading_days_to_own_earnings is not None
        and engine.peer_sympathy_blackout_days_to_peer_event > 0
    ):
        # Forced-flat at most 1 day before the peer's own print.
        ceiling = max(
            1,
            int(inputs.peer_trading_days_to_own_earnings)
            - int(engine.peer_sympathy_blackout_days_to_peer_event),
        )
        max_holding_days = min(max_holding_days, ceiling)

    score = min(0.99, max(0.0, 0.5 + 0.5 * (inputs.correlation - engine.peer_sympathy_correlation_min)))
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

    event_id = (
        f"synth_peer_sympathy_{inputs.leader_symbol.lower()}_"
        f"{inputs.peer_symbol.lower()}_{inputs.decision_date.isoformat()}"
    )

    features = {
        "peer_sympathy_leader_symbol": inputs.leader_symbol,
        "peer_sympathy_leader_event_id": leader.event_id,
        "peer_sympathy_leader_event_type": leader.event_type,
        "peer_sympathy_leader_reaction_day_return": inputs.leader_reaction,
        "peer_sympathy_peer_symbol": inputs.peer_symbol,
        "peer_sympathy_correlation": round(inputs.correlation, 4),
        "peer_sympathy_correlation_window_start": engine.peer_sympathy_correlation_window_start,
        "peer_sympathy_correlation_window_end_skip": engine.peer_sympathy_correlation_window_end_skip,
        "peer_sympathy_stop_pct": engine.peer_sympathy_stop_pct,
        "peer_sympathy_target_pct": engine.peer_sympathy_target_pct,
        "peer_sympathy_max_holding_days": max_holding_days,
        "peer_sympathy_peer_upcoming_earnings": (
            inputs.peer_upcoming_earnings_reaction_date.isoformat()
            if inputs.peer_upcoming_earnings_reaction_date is not None
            else None
        ),
        "peer_sympathy_peer_trading_days_to_own_earnings": inputs.peer_trading_days_to_own_earnings,
    }

    if is_reaction_close:
        execution_date = inputs.decision_date
        entry_timing_policy = "reaction_close"
        timing_class = "same_day"
    else:
        execution_date = inputs.next_trading_date
        entry_timing_policy = "next_open"
        timing_class = "after_close"

    return Candidate(
        event_id=event_id,
        symbol=inputs.peer_symbol,
        source_symbol=inputs.leader_symbol,
        score=score,
        sector=inputs.leader_sector or "UNKNOWN",
        event_type=PEER_SYMPATHY_EVENT_TYPE,
        event_timestamp=inputs.peer_last_bar_timestamp,
        event_date=inputs.decision_date,
        filing_time_bucket="post_market",
        timing_class=timing_class,
        reaction_date=inputs.decision_date,
        execution_date=execution_date,
        entry_price_est=inputs.peer_last_close,
        avg_dollar_volume=inputs.peer_avg_dollar_volume_20d,
        atr_14=synthetic_atr,
        score_bucket=score_bucket,
        engine_id=engine.engine_id,
        entry_timing_policy=entry_timing_policy,
        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,
    )


__all__ = [
    "PEER_SYMPATHY_EVENT_TYPE",
    "BarHistoryProvider",
    "LeaderPrint",
    "PeerResolver",
    "PeerSympathyTriggerInputs",
    "UpcomingEarningsProvider",
    "build_peer_sympathy_candidates",
    "compute_correlation",
    "evaluate_trigger",
]
