"""Unit tests for the EarningsRunup pre-event drift engine."""
from __future__ import annotations

import datetime as dt
from typing import Any

import pytest

from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.earnings_calendar import (
    EarningsCalendarEntry,
    PointInTimeEarningsCalendar,
)
from libs.backtest.earnings_runup import (
    EARNINGS_RUNUP_EVENT_TYPE,
    EarningsRunupTriggerInputs,
    _PitCalendarUpcomingEarningsAdapter,
    _SnapshotStoreBarAdapter,
    build_earnings_runup_candidates,
    evaluate_trigger,
)


# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------


class _FakeAttention:
    def __init__(self, by_symbol_date: dict[tuple[str, dt.date], float | None]) -> None:
        self.by_symbol_date = by_symbol_date

    def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None:
        return self.by_symbol_date.get((symbol.upper(), as_of_date))


def _make_engine(**overrides: Any) -> StrategyEngineConfig:
    base: dict[str, Any] = dict(
        engine_id="earnings_runup_preevent_long",
        event_types=[EARNINGS_RUNUP_EVENT_TYPE],
        direction="long_only",
        timing_class="after_close",
        entry_timing_policy="next_open",
        max_holding_days=7,
        earnings_runup_enabled=True,
        earnings_runup_days_to_earnings_min=3,
        earnings_runup_days_to_earnings_max=7,
        earnings_runup_attention_zscore_20d_min=1.5,
        earnings_runup_dollar_volume_zscore_20d_min=1.0,
        earnings_runup_calendar_buffer_days=1,
    )
    base.update(overrides)
    return StrategyEngineConfig(**base)


def _generate_business_days(start: dt.date, count: int) -> list[dt.date]:
    out: list[dt.date] = []
    cursor = start
    while len(out) < count:
        if cursor.weekday() < 5:
            out.append(cursor)
        cursor = cursor + dt.timedelta(days=1)
    return out


def _build_bars(
    symbol: str,
    trading_days: list[dt.date],
    *,
    base_volume: float,
    spike_factor: float,
    base_close: float = 100.0,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
    """Build a bars-by-symbol-date dict with the LAST bar's $-volume = base*spike_factor.

    Prior bars include small deterministic variance so sigma > 0 in the z-score.
    """
    inner: dict[dt.date, dict[str, Any]] = {}
    for i, d in enumerate(trading_days):
        is_last = (i == len(trading_days) - 1)
        if is_last:
            volume = base_volume * spike_factor
        else:
            # +/- 10% sinusoidal perturbation, rounded so sigma > 0.
            jitter = 1.0 + 0.1 * ((i % 5) - 2) / 2.0
            volume = base_volume * jitter
        inner[d] = {
            "open": base_close,
            "high": base_close,
            "low": base_close,
            "close": base_close,
            "volume": volume,
        }
    return {symbol.upper(): inner}


# ---------------------------------------------------------------------------
# evaluate_trigger() — happy path + 3 negative cases
# ---------------------------------------------------------------------------


def _trigger_inputs(**overrides: Any) -> EarningsRunupTriggerInputs:
    base: dict[str, Any] = dict(
        symbol="AAPL",
        decision_date=dt.date(2026, 4, 13),  # Mon
        next_trading_date=dt.date(2026, 4, 14),
        upcoming_earnings_reaction_date=dt.date(2026, 4, 21),
        days_to_earnings=5,
        attention_zscore_20d=1.8,
        dollar_volume_zscore_20d=1.2,
        last_close_price=100.0,
        avg_dollar_volume_20d=200_000_000.0,
        last_bar_date=dt.date(2026, 4, 10),  # prior Fri
        last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
    )
    base.update(overrides)
    return EarningsRunupTriggerInputs(**base)


def test_trigger_fires_when_all_three_conditions_met():
    engine = _make_engine()
    passes, reason = evaluate_trigger(_trigger_inputs(), engine)
    assert passes is True
    assert reason is None


def test_trigger_blocks_when_days_to_earnings_below_min():
    engine = _make_engine()
    passes, reason = evaluate_trigger(_trigger_inputs(days_to_earnings=2), engine)
    assert passes is False
    assert "days_to_earnings" in (reason or "")


def test_trigger_blocks_when_attention_zscore_below_min():
    engine = _make_engine()
    passes, reason = evaluate_trigger(_trigger_inputs(attention_zscore_20d=1.4), engine)
    assert passes is False
    assert "attention_z" in (reason or "")


def test_trigger_blocks_when_dollar_volume_zscore_below_min():
    engine = _make_engine()
    passes, reason = evaluate_trigger(_trigger_inputs(dollar_volume_zscore_20d=0.99), engine)
    assert passes is False
    assert "dollar_volume_z" in (reason or "")


# ---------------------------------------------------------------------------
# build_earnings_runup_candidates() — end-to-end with fakes
# ---------------------------------------------------------------------------


def _build_full_setup(
    symbol: str = "AAPL",
    *,
    spike_factor: float = 5.0,
    attention_z: float | None = 2.0,
    earnings_offset_trading_days: int = 5,
    days_of_history: int = 30,
):
    # Decision day = the last day of generated trading days; bars go strictly before it.
    trading_days = _generate_business_days(dt.date(2026, 3, 2), days_of_history + earnings_offset_trading_days + 2)
    decision_date = trading_days[days_of_history]  # T-1 close
    next_trading_date = trading_days[days_of_history + 1]
    earnings_reaction = trading_days[days_of_history + earnings_offset_trading_days]

    # Bars for prior `days_of_history` days, ending on the day BEFORE decision_date.
    prior_days = trading_days[:days_of_history]
    bars = _build_bars(
        symbol,
        prior_days,
        base_volume=1_000_000.0,
        spike_factor=spike_factor,
    )
    bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)

    # PIT calendar: known earnings reaction date for the symbol.
    pit_calendar = PointInTimeEarningsCalendar(
        [
            EarningsCalendarEntry(
                symbol=symbol,
                as_of_date=trading_days[0],
                expected_reaction_date=earnings_reaction,
                expected_event_date=earnings_reaction,
                filing_time_bucket="post_market",
            )
        ]
    )
    upcoming_provider = _PitCalendarUpcomingEarningsAdapter(
        pit_calendar=pit_calendar,
        trading_days=trading_days,
    )

    attention_provider = _FakeAttention(
        {(symbol.upper(), decision_date): attention_z}
    )

    return {
        "symbol": symbol,
        "decision_date": decision_date,
        "next_trading_date": next_trading_date,
        "earnings_reaction": earnings_reaction,
        "trading_days": trading_days,
        "bar_provider": bar_provider,
        "upcoming_provider": upcoming_provider,
        "attention_provider": attention_provider,
        "bars": bars,
    }


def test_build_emits_candidate_for_eligible_symbol():
    setup = _build_full_setup()
    engine = _make_engine()
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert len(cands) == 1
    cand = cands[0]
    assert cand.event_type == EARNINGS_RUNUP_EVENT_TYPE
    assert cand.symbol == setup["symbol"]
    assert cand.engine_id == engine.engine_id
    assert cand.execution_date == setup["next_trading_date"]
    assert cand.engine_max_holding_days is not None
    # days_to_earnings was 5; calendar_buffer 1 → max_holding_days = 5 - 1 = 4
    assert cand.engine_max_holding_days == 4
    assert cand.features["earnings_runup_days_to_earnings"] == 5
    assert cand.features["earnings_runup_stop_pct"] == 0.04
    assert cand.features["earnings_runup_target_pct"] == 0.08


def test_build_does_not_fire_when_attention_below_min():
    setup = _build_full_setup(attention_z=0.5)
    engine = _make_engine()
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert cands == []


def test_build_does_not_fire_when_dollar_volume_zscore_below_min():
    # Spike factor of 1.0 (no spike) → z-score near 0
    setup = _build_full_setup(spike_factor=1.0)
    engine = _make_engine()
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert cands == []


def test_build_does_not_fire_when_days_to_earnings_outside_window():
    # earnings_offset = 10 trading days → > max 7
    setup = _build_full_setup(earnings_offset_trading_days=10)
    engine = _make_engine()
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert cands == []


# ---------------------------------------------------------------------------
# Lookahead defenses
# ---------------------------------------------------------------------------


def test_build_raises_lookahead_when_bar_date_equals_decision_date():
    """A bar dated on or after decision_date must trigger LookaheadViolationError."""
    setup = _build_full_setup()
    symbol = setup["symbol"]
    decision_date = setup["decision_date"]
    bars = setup["bars"]
    # Inject a bar dated ON decision_date — this is the look-ahead violation.
    bars[symbol.upper()][decision_date] = {
        "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0,
        "volume": 5_000_000.0,
    }
    bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)

    # Add a sentinel bar AFTER decision_date too, so the adapter's `< as_of_date` filter
    # is the only thing keeping us safe. Then we manually subvert it.
    class LeakyAdapter:
        def get_bars_before(self, sym, as_of, lookback_days):
            inner = bars[sym.upper()]
            # Deliberately include the bar dated == decision_date.
            return sorted(
                [(d, b) for d, b in inner.items() if d <= as_of]
            )[-lookback_days:]

    engine = _make_engine()
    with pytest.raises(LookaheadViolationError):
        build_earnings_runup_candidates(
            decision_date=decision_date,
            next_trading_date=setup["next_trading_date"],
            universe_symbols=[symbol],
            engine=engine,
            upcoming_earnings_provider=setup["upcoming_provider"],
            attention_provider=setup["attention_provider"],
            bar_provider=LeakyAdapter(),
        )


def test_build_raises_lookahead_when_explicit_assertion_violated():
    """Direct assertion path — feature timestamp >= cutoff must raise."""
    from libs.backtest.earnings_runup import _assert_no_lookahead

    decision_date = dt.date(2026, 4, 13)
    # 09:30 ET on the decision day (= 13:30 UTC under EST; 13:30 UTC == 09:30 EST)
    leaky_ts = dt.datetime(2026, 4, 13, 14, 30, tzinfo=dt.timezone.utc)  # 10:30 ET
    with pytest.raises(LookaheadViolationError):
        _assert_no_lookahead("AAPL", decision_date, [leaky_ts])


def test_assert_no_lookahead_accepts_strictly_prior_timestamp():
    from libs.backtest.earnings_runup import _assert_no_lookahead

    decision_date = dt.date(2026, 4, 13)
    safe_ts = dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc)  # prior day close
    # Should not raise
    _assert_no_lookahead("AAPL", decision_date, [safe_ts])


def test_assert_no_lookahead_rejects_naive_timestamp():
    from libs.backtest.earnings_runup import _assert_no_lookahead

    decision_date = dt.date(2026, 4, 13)
    naive_ts = dt.datetime(2026, 4, 10, 21, 0)
    with pytest.raises(LookaheadViolationError):
        _assert_no_lookahead("AAPL", decision_date, [naive_ts])


# ---------------------------------------------------------------------------
# PIT earnings calendar respects as_of_date
# ---------------------------------------------------------------------------


def test_pit_calendar_does_not_reveal_unannounced_future_earnings():
    """Earnings dates whose as_of_date is AFTER decision_date must not be visible."""
    trading_days = _generate_business_days(dt.date(2026, 3, 2), 30)
    decision_date = trading_days[10]
    earnings_reaction_date = trading_days[15]

    # Calendar entry was published AFTER decision_date — must be invisible.
    pit_calendar = PointInTimeEarningsCalendar(
        [
            EarningsCalendarEntry(
                symbol="AAPL",
                as_of_date=trading_days[12],  # > decision_date
                expected_reaction_date=earnings_reaction_date,
                expected_event_date=earnings_reaction_date,
                filing_time_bucket="post_market",
            )
        ]
    )
    adapter = _PitCalendarUpcomingEarningsAdapter(
        pit_calendar=pit_calendar,
        trading_days=trading_days,
    )
    result = adapter.get_next_reaction_date(
        symbol="AAPL",
        as_of_date=decision_date,
        max_lookahead_calendar_days=14,
    )
    assert result is None


def test_pit_calendar_reveals_announced_future_earnings():
    trading_days = _generate_business_days(dt.date(2026, 3, 2), 30)
    decision_date = trading_days[10]
    earnings_reaction_date = trading_days[15]

    pit_calendar = PointInTimeEarningsCalendar(
        [
            EarningsCalendarEntry(
                symbol="AAPL",
                as_of_date=trading_days[5],  # known well before decision_date
                expected_reaction_date=earnings_reaction_date,
                expected_event_date=earnings_reaction_date,
                filing_time_bucket="post_market",
            )
        ]
    )
    adapter = _PitCalendarUpcomingEarningsAdapter(
        pit_calendar=pit_calendar,
        trading_days=trading_days,
    )
    result = adapter.get_next_reaction_date(
        symbol="AAPL",
        as_of_date=decision_date,
        max_lookahead_calendar_days=14,
    )
    assert result == earnings_reaction_date


# ---------------------------------------------------------------------------
# Exit policy stub tests — verify candidate carries the exit configuration
# ---------------------------------------------------------------------------


def test_candidate_carries_stop_target_trailing_config_in_features():
    setup = _build_full_setup()
    engine = _make_engine(
        earnings_runup_stop_pct=0.05,
        earnings_runup_target_pct=0.10,
        earnings_runup_trailing_activate_pct=0.06,
        earnings_runup_trailing_giveback_pct=0.025,
    )
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert len(cands) == 1
    feats = cands[0].features
    assert feats["earnings_runup_stop_pct"] == 0.05
    assert feats["earnings_runup_target_pct"] == 0.10
    assert feats["earnings_runup_trailing_activate_pct"] == 0.06
    assert feats["earnings_runup_trailing_giveback_pct"] == 0.025


def test_candidate_max_holding_days_forces_flat_before_print():
    """Hard exit: max_holding_days = days_to_earnings - calendar_buffer_days (>=1)."""
    # 4 trading days to earnings, buffer 1 → max_hold = 3
    setup = _build_full_setup(earnings_offset_trading_days=4)
    engine = _make_engine(earnings_runup_calendar_buffer_days=1)
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert len(cands) == 1
    assert cands[0].engine_max_holding_days == 3


def test_candidate_max_holding_days_never_below_one():
    setup = _build_full_setup(earnings_offset_trading_days=3)
    engine = _make_engine(earnings_runup_calendar_buffer_days=5)  # absurd buffer
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert len(cands) == 1
    assert cands[0].engine_max_holding_days >= 1


# ---------------------------------------------------------------------------
# Behavioral exit tests — drive a synthetic position through simulate_exit and
# verify pct exits map correctly to STOP / TARGET / TIME outcomes.
# ---------------------------------------------------------------------------


def _build_position_for_runup(
    *,
    entry_price: float = 100.0,
    stop_pct: float = 0.04,
    target_pct: float = 0.08,
    days_held: int = 0,
) -> Any:
    from libs.backtest.domain import (
        Candidate,
        ExitReason,  # noqa: F401  re-exported for downstream tests
        OpenPosition,
        PlannedOrder,
    )
    # Mirror the candidate the production builder constructs.
    stop_mult = stop_pct / 0.02
    target_r = target_pct / stop_pct
    synthetic_atr = entry_price * 0.02
    cand = Candidate(
        event_id="evt_runup_exit",
        symbol="AAPL",
        score=0.75,
        sector="UNKNOWN",
        event_type=EARNINGS_RUNUP_EVENT_TYPE,
        event_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
        event_date=dt.date(2026, 4, 13),
        filing_time_bucket="post_market",
        reaction_date=dt.date(2026, 4, 13),
        execution_date=dt.date(2026, 4, 14),
        entry_price_est=entry_price,
        avg_dollar_volume=200_000_000.0,
        atr_14=synthetic_atr,
        score_bucket="medium_high",
        engine_id="earnings_runup_preevent_long",
        entry_timing_policy="next_open",
        trade_direction="long",
        engine_stop_atr_multiplier=stop_mult,
        engine_target_1_r=target_r,
        engine_target_1_fraction=1.0,
        engine_max_holding_days=4,
    )
    stop_price = entry_price * (1.0 - stop_pct)
    target_price = entry_price * (1.0 + target_pct)
    plan = PlannedOrder(
        candidate=cand,
        shares=100,
        entry_price_limit=entry_price,
        stop_price=stop_price,
        target_price=target_price,
        risk_dollars=stop_pct * entry_price * 100,
        event_date=cand.event_date,
        timing_class="after_close",
        engine_id=cand.engine_id,
        entry_timing_policy="next_open",
        shadow_only=False,
    )
    return OpenPosition(
        position_id="pos_runup",
        plan=plan,
        entry_date=cand.execution_date,
        entry_price=entry_price,
        entry_fill_slippage_bps=10.0,
        current_stop=stop_price,
        target_price=target_price,
        peak_price=entry_price,
        shares_open=100,
        shares_total=100,
        days_held=days_held,
    )


def _exec_config_for_exit_test() -> Any:
    from libs.backtest.domain import ExecutionConfig
    return ExecutionConfig(
        entry_fill_model="next_open",
        exit_fill_model="daily_bar_approximation",
        slippage_bps_base=10.0,
        commission_per_share=0.005,
        same_bar_priority="stop_first_conservative",
        max_holding_days=4,
    )


def test_exit_stop_at_minus_4pct():
    """Long position with -4% stop must STOP-exit when bar.low <= 96.0."""
    from libs.backtest.domain import ExitReason
    from libs.backtest.execution import simulate_exit

    pos = _build_position_for_runup(entry_price=100.0, stop_pct=0.04)
    # Bar drops to 95.5 → below the 96.0 stop → STOP exit.
    bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 95.5, "close": 96.5, "volume": 1_000_000}
    trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
    assert trade is not None
    assert trade.exit_reason == ExitReason.STOP


def test_exit_target_at_plus_8pct():
    """Long position with +8% target must TARGET-exit when bar.high >= 108.0."""
    from libs.backtest.domain import ExitReason
    from libs.backtest.execution import simulate_exit

    pos = _build_position_for_runup(entry_price=100.0, target_pct=0.08)
    bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 108.5, "low": 101.0, "close": 107.0, "volume": 1_000_000}
    trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
    assert trade is not None
    assert trade.exit_reason == ExitReason.TARGET


def test_exit_forced_max_hold_before_print():
    """When days_held >= max_holding_days and no stop/target, exit reason is TIME."""
    from libs.backtest.domain import ExitReason
    from libs.backtest.execution import simulate_exit

    # max_holding_days = 2; position already held 2 days.
    pos = _build_position_for_runup(entry_price=100.0, days_held=2)
    cfg = _exec_config_for_exit_test()
    cfg = cfg.model_copy(update={"max_holding_days": 2})
    bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 103.0, "low": 99.0, "close": 102.5, "volume": 1_000_000}
    trade = simulate_exit(pos, bar, cfg, dt.date(2026, 4, 15))
    assert trade is not None
    assert trade.exit_reason == ExitReason.TIME


def test_exit_trailing_giveback_after_activation():
    """Behavioral approximation of trailing exit: peak rises >+5%, then gives back >3%.

    The standard execution machinery does not natively implement the
    EarningsRunup pct-trailing model, so this test confirms the minimum
    invariant — when a trailing stop is RAISED to a level above the static stop
    and the bar's low touches it, the position exits via STOP. The trailing pct
    config is preserved on the candidate features for future engine wiring.
    """
    from libs.backtest.domain import ExitReason
    from libs.backtest.execution import simulate_exit

    pos = _build_position_for_runup(entry_price=100.0)
    # Manually move stop up to 105.0 (= activation at 105 with 0% giveback for the test).
    raised = pos.model_copy(update={"current_stop": 105.0, "peak_price": 106.0})
    bar = {"date": dt.date(2026, 4, 15), "open": 106.0, "high": 106.5, "low": 104.5, "close": 104.8, "volume": 1_000_000}
    trade = simulate_exit(raised, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
    assert trade is not None
    assert trade.exit_reason == ExitReason.STOP
    # Confirm exit price is above original entry — i.e. the trailing stop captured profit.
    assert trade.exit_price > pos.entry_price


# ---------------------------------------------------------------------------
# Pct-trailing wiring tests — verify update_trailing_stop honors activation
# threshold and entry-relative giveback (EarningsRunup-style trailing).
# ---------------------------------------------------------------------------


def _bar(*, low: float, high: float, open_: float | None = None, close: float | None = None) -> dict[str, Any]:
    return {
        "date": dt.date(2026, 4, 15),
        "open": open_ if open_ is not None else (low + high) / 2,
        "high": high,
        "low": low,
        "close": close if close is not None else (low + high) / 2,
        "volume": 1_000_000,
    }


def test_pct_trailing_does_not_arm_below_activation_threshold():
    """Position at +3% (activation=5%) → stop unchanged at -4% from entry."""
    from libs.backtest.execution import update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    # Bar with high=103 (+3% peak), low=101 → not yet at activation (+5%)
    update_trailing_stop(
        pos, _bar(low=101.0, high=103.0),
        trailing_model="pct_3",
        warmup_days=0,
        pct_activation=0.05,
        pct_giveback=0.03,
    )
    # peak should track high=103
    assert pos.peak_price == 103.0
    # stop should NOT have moved up — still at -4% = 96.0
    assert pos.current_stop == 96.0


def test_pct_trailing_arms_at_activation_threshold():
    """Peak hits +5% → stop raised to +2% (activation 5% - giveback 3% = 2%)."""
    from libs.backtest.execution import update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    update_trailing_stop(
        pos, _bar(low=102.0, high=105.0),
        trailing_model="pct_3",
        warmup_days=0,
        pct_activation=0.05,
        pct_giveback=0.03,
    )
    # peak = 105, stop = peak - giveback*entry = 105 - 0.03*100 = 102.0
    assert pos.peak_price == 105.0
    assert pos.current_stop == pytest.approx(102.0)


def test_pct_trailing_ratchets_up_with_continued_profit():
    """Peak rises to +7% → stop raised to +4% (peak - 3% of entry)."""
    from libs.backtest.execution import update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    # First armed at peak=105 → stop=102
    update_trailing_stop(
        pos, _bar(low=102.0, high=105.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    assert pos.current_stop == pytest.approx(102.0)
    # Continued profit: peak now 107
    update_trailing_stop(
        pos, _bar(low=104.0, high=107.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    assert pos.peak_price == 107.0
    assert pos.current_stop == pytest.approx(104.0)


def test_pct_trailing_does_not_ratchet_down_on_pullback():
    """Peak 107 (stop 104) then peak holds at 107 while bar drops to 105 → stop stays at 104."""
    from libs.backtest.execution import update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    # Establish peak=107, stop=104
    update_trailing_stop(
        pos, _bar(low=102.0, high=107.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    assert pos.current_stop == pytest.approx(104.0)
    assert pos.peak_price == 107.0
    # Pullback: bar high=106 (below previous peak), low=105
    update_trailing_stop(
        pos, _bar(low=105.0, high=106.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    # Peak unchanged; stop must stay (not ratchet down)
    assert pos.peak_price == 107.0
    assert pos.current_stop == pytest.approx(104.0)


def test_pct_trailing_reversal_triggers_stop_exit_with_profit():
    """Peak +7% → stop +4%; then bar low touches +3% → position exits via STOP at +4%."""
    from libs.backtest.domain import ExitReason
    from libs.backtest.execution import simulate_exit, update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    # Day 1: peak rises to 107, stop ratchets to 104
    update_trailing_stop(
        pos, _bar(low=102.0, high=107.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    assert pos.current_stop == pytest.approx(104.0)

    # Day 2: bar opens at 105.5, drops to 103.5 → trailing stop at 104.0 hit.
    pos.days_held = 1
    bar2 = _bar(low=103.5, high=105.5, open_=105.5, close=104.0)
    # First update peak (no new high)
    update_trailing_stop(
        pos, bar2,
        trailing_model="pct_3", warmup_days=0,
        pct_activation=0.05, pct_giveback=0.03,
    )
    # Stop still at 104 (peak unchanged)
    assert pos.current_stop == pytest.approx(104.0)
    # Now simulate exit: bar.low=103.5 < stop=104 → STOP exit at 104
    trade = simulate_exit(pos, bar2, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
    assert trade is not None
    assert trade.exit_reason == ExitReason.STOP
    # Exit price ~= 104.0 (the trailing stop, less small slippage), above entry → profit captured
    assert trade.exit_price == pytest.approx(104.0, rel=0.005)
    assert trade.exit_price > pos.entry_price


def test_pct_trailing_legacy_behavior_when_activation_none():
    """Regression: pct_3 with activation=None must trail unconditionally (legacy behavior)."""
    from libs.backtest.execution import update_trailing_stop

    pos = _build_position_for_runup(entry_price=100.0)
    # Even at +1% peak, legacy pct_3 trails to peak * 0.97 = 100*0.97 = 97 (only ratchets if > current_stop)
    # Note: position's current_stop starts at 96, and 101*0.97=97.97 > 96, so it should ratchet up.
    update_trailing_stop(
        pos, _bar(low=100.0, high=101.0),
        trailing_model="pct_3", warmup_days=0,
        pct_activation=None, pct_giveback=None,
    )
    assert pos.peak_price == 101.0
    # Legacy formula: peak * (1 - 0.03) = 97.97 (ratchets up from 96)
    assert pos.current_stop == pytest.approx(97.97)


def test_pct_trailing_via_engine_candidate_in_effective_exec():
    """End-to-end: candidate built from engine carries pct trailing fields into effective exec."""
    from libs.backtest.execution import build_effective_execution_config

    setup = _build_full_setup()
    engine = _make_engine(
        earnings_runup_stop_pct=0.04,
        earnings_runup_target_pct=0.08,
        earnings_runup_trailing_activate_pct=0.05,
        earnings_runup_trailing_giveback_pct=0.03,
    )
    cands = build_earnings_runup_candidates(
        decision_date=setup["decision_date"],
        next_trading_date=setup["next_trading_date"],
        universe_symbols=[setup["symbol"]],
        engine=engine,
        upcoming_earnings_provider=setup["upcoming_provider"],
        attention_provider=setup["attention_provider"],
        bar_provider=setup["bar_provider"],
    )
    assert len(cands) == 1
    cand = cands[0]
    # Candidate has the new pct trailing fields
    assert cand.engine_trailing_model == "pct_3"
    assert cand.engine_trailing_pct_activation == pytest.approx(0.05)
    assert cand.engine_trailing_pct_giveback == pytest.approx(0.03)

    # Build a minimal BacktestConfig and check effective_exec inherits these
    from libs.backtest.domain import BacktestConfig, ExecutionConfig
    base_cfg = BacktestConfig(
        strategy_name="test",
        dataset_snapshot_id="test",
        execution=ExecutionConfig(max_holding_days=4),
    )
    eff = build_effective_execution_config(cand, base_cfg)
    assert eff.trailing_model == "pct_3"
    assert eff.trailing_pct_activation == pytest.approx(0.05)
    assert eff.trailing_pct_giveback == pytest.approx(0.03)
