"""Unit tests for the pure `simulate_book` engine (paper_sim.py).

The load-bearing guarantee: with `controller="killswitch", kelly_fraction=1.0, cost_bps=0` the engine
reproduces the ESTABLISHED LIVE book math — the per-date overlay path the live backfill/snapshot use
(`paper_risk_overlay`/`IncrementalRiskOverlay` called on the strictly-prior window, eff_lev=0 on kill,
applied to that date) — so the sim never diverges from the live/replay book. The other tests cover
capital scaling, the date window, sleeve selection, the cost_bps turnover model, and hand-checked
metrics.

NOTE on the golden reference: the spec phrases the golden as "equal the existing `paper_book_returns`
(+killswitch)". In the actual code, `paper_book_returns` is the INTERNAL un-throttled regime signal
that feeds the killswitch — it is NOT what the live book compounds. The live book
(paper_backfill.py / paper_snapshot.py) compounds the OVERLAY's `(weights, eff_lev)` applied to each
date's sleeve returns. Those two differ at the vol-target warmup boundary (the overlay's leverage is
computed from the full trailing window via paper_risk_overlay, whereas paper_book_returns sizes off
its own incrementally-built throttled series). The engine is built against — and golden-tested
against — the live OVERLAY path, since that is the book the cockpit + live wiring must reproduce. See
the report.
"""
import json
import math
import pathlib
import random
import statistics as st
import time

import pytest

from fxhnt.application.paper_book import paper_risk_overlay
from fxhnt.application.paper_sim import (
    apply_knobs,
    overlay_pass,
    simulate_book,
    simulate_naive_eqwt,
)

_GOLDEN_PATH = pathlib.Path(__file__).parent / "data" / "golden_sim.json"


def _big_fixture() -> dict[str, dict[int, float]]:
    """A full-history-shaped fixture (~1473 days, 3 staggered sleeves) for the perf benchmark — mirrors
    the production combined-book history that the interactive /paper/sim page recomputes per knob change."""
    rng = random.Random(42)
    return {
        "crypto_tstrend": {1 + i: rng.gauss(0.0012, 0.012) for i in range(1473)},
        "unlock": {30 + i: rng.gauss(-0.001, 0.022) for i in range(1400)},
        "stablecoin_rotation": {10 + i: rng.gauss(0.0004, 0.004) for i in range(1450)},
    }

# --- fixtures ------------------------------------------------------------------------------------

def _two_edge_fixture() -> dict[str, dict[int, float]]:
    """A 2-edge-shaped fixture (tstrend + unlock) with different vols + staggered starts + a
    negative-drift sleeve so the killswitch actually fires (exercises the killed path). Deterministic."""
    rng = random.Random(20260623)
    return {
        "crypto_tstrend": {1 + i: rng.gauss(0.0012, 0.012) for i in range(220)},
        # Strong negative drift → the un-throttled book series breaches the −25% kill threshold.
        "unlock": {16 + i: rng.gauss(-0.010, 0.022) for i in range(200)},
    }


def _three_edge_fixture() -> dict[str, dict[int, float]]:
    fx = _two_edge_fixture()
    rng = random.Random(99)
    fx["stablecoin_rotation"] = {6 + i: rng.gauss(0.0004, 0.004) for i in range(210)}
    return fx


def _overlay_book_returns(returns_by_sleeve: dict[str, dict[int, float]]) -> dict[int, float]:
    """Faithful per-date THROTTLED book return via the LIVE overlay path (mirrors paper_backfill.py):
    for each date d, call the stateless `paper_risk_overlay` on the strictly-prior window (keys < d),
    take eff_lev = 0 if killed else lev, and book `eff_lev · Σ weights·return[d]`. This is exactly the
    gross return the live book compounds → the ground-truth golden the engine must reproduce."""
    all_days = sorted({d for r in returns_by_sleeve.values() for d in r})
    out: dict[int, float] = {}
    for d in all_days:
        before = {s: {k: v for k, v in r.items() if k < d} for s, r in returns_by_sleeve.items()}
        active = [s for s, r in before.items() if r]
        w, lev, killed = paper_risk_overlay(before, active)
        eff = 0.0 if killed else lev
        out[d] = eff * sum(w.get(s, 0.0) * returns_by_sleeve.get(s, {}).get(d, 0.0) for s in w)
    return out


# --- golden vs the established book math ----------------------------------------------------------

def test_golden_matches_overlay_book_returns_with_killswitch():
    fx = _two_edge_fixture()
    res = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend", "unlock"],
                        controller="killswitch", kelly_fraction=1.0, cost_bps=0.0)

    ref = _overlay_book_returns(fx)   # the live overlay path's per-date throttled book return
    # The sim emits an entry for every union day (the first one is flat: overlay on empty → no weights).
    assert res.dates == sorted(ref)

    # Reconstruct the sim's per-day net return from its compounding equity curve and compare to ref.
    eq = res.equity
    dates = res.dates
    cap = 100_000.0
    for i, d in enumerate(dates):
        prev = cap if i == 0 else eq[i - 1]
        sim_ret = eq[i] / prev - 1.0
        assert math.isclose(sim_ret, ref[d], rel_tol=0, abs_tol=1e-12), \
            f"day {d}: sim {sim_ret!r} != book {ref[d]!r}"

    # And the equity compounding itself matches a direct compound of the reference book returns.
    expected_eq = cap
    for d in dates:
        expected_eq *= (1.0 + ref[d])
    assert math.isclose(res.equity[-1], expected_eq, rel_tol=0, abs_tol=1e-6)


def test_killswitch_actually_fires_in_fixture():
    # Guard: the golden test is only meaningful if the kill path is exercised.
    fx = _two_edge_fixture()
    res = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend", "unlock"])
    assert any(res.killed), "fixture never killed — golden test not exercising the killed path"
    assert all((lev == 0.0) for lev, k in zip(res.leverage, res.killed, strict=True) if k), \
        "effective leverage must be 0 on killed days"


# --- capital scaling --------------------------------------------------------------------------------

def test_capital_scales_curve_linearly():
    fx = _two_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock"]
    a = simulate_book(fx, capital=10_000.0, sleeves=sleeves)
    b = simulate_book(fx, capital=40_000.0, sleeves=sleeves)
    assert a.dates == b.dates
    for ea, eb in zip(a.equity, b.equity, strict=True):
        assert math.isclose(eb, ea * 4.0, rel_tol=1e-12, abs_tol=1e-6)
    # Shape-invariant stats are identical.
    assert math.isclose(a.metrics["sharpe"], b.metrics["sharpe"], rel_tol=0, abs_tol=1e-12)
    assert math.isclose(a.metrics["max_dd"], b.metrics["max_dd"], rel_tol=0, abs_tol=1e-12)
    assert math.isclose(a.metrics["cagr"], b.metrics["cagr"], rel_tol=0, abs_tol=1e-12)


# --- date window ------------------------------------------------------------------------------------

def test_date_window_restricts_curve():
    fx = _two_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock"]
    full = simulate_book(fx, capital=100_000.0, sleeves=sleeves)
    lo, hi = 50, 120
    win = simulate_book(fx, capital=100_000.0, sleeves=sleeves, start=lo, end=hi)

    assert win.dates == [d for d in full.dates if lo <= d <= hi]
    assert all(lo <= d <= hi for d in win.dates)
    # Equity restarts at `capital` on the first in-window date (it compounds from capital, not from the
    # full-curve level at that date) — but the overlay state is still the full prior history (causal).
    first = win.dates[0]
    first_net = win.equity[0] / 100_000.0 - 1.0
    # The first in-window day applies the THROTTLED overlay book return of that day to `capital`.
    ref = _overlay_book_returns(fx)
    assert math.isclose(first_net, ref[first], rel_tol=0, abs_tol=1e-12)


# --- sleeve selection -------------------------------------------------------------------------------

def test_sleeve_selection_distinct_curves():
    fx = _three_edge_fixture()
    two = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend", "unlock"])
    three = simulate_book(fx, capital=100_000.0,
                          sleeves=["crypto_tstrend", "unlock", "stablecoin_rotation"])
    assert two.equity[-1] != three.equity[-1], "2-edge and 3-edge curves should differ"


def test_single_sleeve_is_that_sleeve_overlaid_return():
    fx = _three_edge_fixture()
    res = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend"])
    # A single sleeve gets weight 1.0; its net return each day = eff_lev · 1.0 · r[d] (killswitch +
    # vol-target leverage still apply). Reproduce via the same single-sleeve overlay book math.
    ref = _overlay_book_returns({"crypto_tstrend": fx["crypto_tstrend"]})
    cap = 100_000.0
    assert res.dates == sorted(ref)
    for i, d in enumerate(res.dates):
        prev = cap if i == 0 else res.equity[i - 1]
        sim_ret = res.equity[i] / prev - 1.0
        assert math.isclose(sim_ret, ref[d], rel_tol=0, abs_tol=1e-12), f"day {d}"
    # Single active sleeve → its weight is 1.0 on days it is active.
    for w in res.weights:
        if w:
            assert math.isclose(sum(w.values()), 1.0, rel_tol=0, abs_tol=1e-9)


def test_intersection_with_unknown_sleeve():
    fx = _two_edge_fixture()
    a = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend", "unlock"])
    # Adding a sleeve absent from the data must not change the result (intersection).
    b = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend", "unlock", "does_not_exist"])
    assert a.dates == b.dates
    assert a.equity == b.equity


# --- cost_bps ---------------------------------------------------------------------------------------

def test_cost_bps_zero_is_identical_to_gross():
    fx = _two_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock"]
    gross = simulate_book(fx, capital=100_000.0, sleeves=sleeves, cost_bps=0.0)
    # Bit-identical: cost_bps=0 must take the no-cost branch.
    again = simulate_book(fx, capital=100_000.0, sleeves=sleeves, cost_bps=0.0)
    assert gross.equity == again.equity


def test_cost_bps_lowers_curve_by_turnover_charge():
    fx = _two_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock"]
    gross = simulate_book(fx, capital=100_000.0, sleeves=sleeves, cost_bps=0.0)
    costed = simulate_book(fx, capital=100_000.0, sleeves=sleeves, cost_bps=10.0)
    # There IS turnover (weights/leverage move day to day), so the costed curve ends lower.
    assert costed.equity[-1] < gross.equity[-1]
    # Every day, the costed net return <= gross net return (cost only subtracts).
    cap = 100_000.0
    for i in range(len(gross.dates)):
        gp = cap if i == 0 else gross.equity[i - 1]
        cp = cap if i == 0 else costed.equity[i - 1]
        g_ret = gross.equity[i] / gp - 1.0
        c_ret = costed.equity[i] / cp - 1.0
        assert c_ret <= g_ret + 1e-15


def test_cost_bps_exact_turnover_on_two_day_fixture():
    """Hand-checked turnover charge on a tiny single-sleeve fixture where the math is traceable.

    Single sleeve, within warmup so leverage is 1.0 throughout. Day 1 is flat (overlay over empty prior
    view → no weights; eff_w = {}). Day 2: the sleeve is active with weight 1.0, eff_lev 1.0, so
    eff_w = {sleeve: 1.0}; turnover vs prev_eff ({}) = |1.0 − 0| = 1.0 → cost = 1.0 · 25/1e4 = 0.0025.
    Day 3: eff_w unchanged at {sleeve: 1.0} → turnover 0 → no cost."""
    fx = {"crypto_tstrend": {1: 0.01, 2: 0.02, 3: -0.01}}
    gross = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend"], cost_bps=0.0)
    costed = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend"], cost_bps=25.0)
    assert gross.dates == [1, 2, 3]
    cap = 100_000.0
    g_rets, c_rets = [], []
    for i in range(3):
        gp = cap if i == 0 else gross.equity[i - 1]
        cp = cap if i == 0 else costed.equity[i - 1]
        g_rets.append(gross.equity[i] / gp - 1.0)
        c_rets.append(costed.equity[i] / cp - 1.0)
    # Day 1: flat, no turnover → no cost; identical.
    assert math.isclose(c_rets[0], g_rets[0], abs_tol=1e-15)
    # Day 2: exactly 0.0025 of turnover cost (turnover 1.0 × 25bp).
    assert math.isclose(g_rets[1] - c_rets[1], 0.0025, rel_tol=0, abs_tol=1e-12)
    # Day 3: eff weights unchanged → zero turnover → no incremental cost.
    assert math.isclose(c_rets[2], g_rets[2], rel_tol=0, abs_tol=1e-12)


# --- metrics ----------------------------------------------------------------------------------------

def test_metrics_hand_checked_on_deterministic_fixture():
    """Single sleeve, no killswitch action, leverage stays in warmup (1.0) so the book return == the
    raw sleeve return for each day → metrics are hand-computable from those returns.

    Direct-application semantics (mirroring the live backfill overlay path): the weights applied to day
    d come from the overlay over data strictly before d. The first union day is FLAT (overlay over an
    empty prior view → no weights). From the second day on, the single sleeve carries weight 1.0."""
    # 5 days of a single sleeve.
    rets = {1: 0.05, 2: -0.02, 3: 0.03, 4: 0.01, 5: 0.04}
    fx = {"crypto_tstrend": rets}
    res = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend"])

    assert res.dates == [1, 2, 3, 4, 5]
    # day 1 flat (overlay over empty prior view); days 2-5 carry r[d] (weight 1.0, leverage 1.0 warmup).
    net = [0.0, rets[2], rets[3], rets[4], rets[5]]
    cap = 100_000.0
    eq = cap
    peak = cap
    dds = []
    for r in net:
        eq *= (1 + r)
        peak = max(peak, eq)
        dds.append((eq - peak) / peak)
    assert math.isclose(res.equity[-1], eq, rel_tol=0, abs_tol=1e-6)
    assert math.isclose(res.metrics["end_value"], eq, rel_tol=0, abs_tol=1e-6)
    assert math.isclose(res.metrics["total_return"], eq / cap - 1.0, rel_tol=0, abs_tol=1e-12)

    # max_dd hand-checked.
    assert math.isclose(res.metrics["max_dd"], min(dds), rel_tol=0, abs_tol=1e-12)

    # CAGR over the window span n_days = dates[-1] - dates[0] = 5 - 1 = 4.
    years = (5 - 1) / 365
    expected_cagr = (eq / cap) ** (1.0 / years) - 1.0
    assert math.isclose(res.metrics["cagr"], expected_cagr, rel_tol=0, abs_tol=1e-9)

    # Sharpe over the net-return series.
    sd = st.pstdev(net)
    expected_sharpe = st.mean(net) / sd * math.sqrt(365)
    assert math.isclose(res.metrics["sharpe"], expected_sharpe, rel_tol=0, abs_tol=1e-9)

    # All days fall in 1970 (epoch days 1-5) → worst==best==total compounded return.
    assert math.isclose(res.metrics["worst_year"], eq / cap - 1.0, rel_tol=0, abs_tol=1e-12)
    assert math.isclose(res.metrics["best_year"], eq / cap - 1.0, rel_tol=0, abs_tol=1e-12)


def test_worst_and_best_year_split_across_calendar_years():
    """Epoch days spanning two calendar years → distinct worst/best year returns.

    Direct-application semantics: a single sleeve carries weight 1.0 from its SECOND observation on
    (day 1 is flat: overlay over empty prior view). Lay out obs in each year so real returns land in each."""
    from fxhnt.application.paper_sim import _epoch_day_to_year
    # epoch day 1 = 1970-01-02 ... day 366 = 1971-01-02.
    fx = {"crypto_tstrend": {1: -0.05, 2: -0.05, 3: -0.05,        # 1970 (day 1 flat; days 2,3 live)
                             366: 0.10, 367: 0.10}}                # 1971 (days 366,367 live)
    assert _epoch_day_to_year(3) == 1970
    assert _epoch_day_to_year(366) == 1971
    res = simulate_book(fx, capital=100_000.0, sleeves=["crypto_tstrend"])
    # 1970 live returns: day1=0 (flat), day2=−0.05, day3=−0.05 → (1)(0.95)(0.95)−1 = −0.0975.
    # 1971 live returns: day366=+0.10, day367=+0.10 → (1.10)(1.10)−1 = +0.21.
    assert res.metrics["worst_year"] < 0.0 < res.metrics["best_year"]
    assert math.isclose(res.metrics["worst_year"], 0.95 * 0.95 - 1.0, rel_tol=0, abs_tol=1e-9)
    assert math.isclose(res.metrics["best_year"], 1.10 * 1.10 - 1.0, rel_tol=0, abs_tol=1e-9)


def test_empty_inputs_return_seeded_metrics():
    res = simulate_book({}, capital=100_000.0, sleeves=["crypto_tstrend"])
    assert res.dates == []
    assert res.equity == []
    assert res.metrics["end_value"] == 100_000.0
    assert res.metrics["total_return"] == 0.0
    assert res.metrics["max_dd"] == 0.0


# --- bit-identical golden vs the PRE-OPTIMIZATION capture --------------------------------------------

def _golden_fixture() -> dict[str, dict[int, float]]:
    """The exact fixture the golden snapshot in tests/unit/data/golden_sim.json was captured from
    (pre-optimization `simulate_book` output). Must reproduce these values bit-identically (abs_tol 1e-9)."""
    rng = random.Random(20260623)
    return {
        "crypto_tstrend": {1 + i: rng.gauss(0.0012, 0.012) for i in range(220)},
        "unlock": {16 + i: rng.gauss(-0.010, 0.022) for i in range(200)},
        "stablecoin_rotation": {6 + i: rng.gauss(0.0004, 0.004) for i in range(210)},
    }


def test_simulate_book_bit_identical_to_pre_optimization_golden():
    """The optimized engine reproduces the PRE-OPTIMIZATION `simulate_book` output (full SimResult —
    equity, drawdown, weights, leverage, killed, metrics) bit-identically (abs_tol 1e-9) across every
    controller / kelly / cost / capital combo captured in the frozen golden file."""
    golden = json.loads(_GOLDEN_PATH.read_text())
    fx = _golden_fixture()
    sleeves = ["crypto_tstrend", "unlock", "stablecoin_rotation"]
    assert golden, "golden snapshot is empty"
    for g in golden:
        res = simulate_book(fx, sleeves=sleeves, **g["cfg"])
        assert res.dates == g["dates"], f"date axis changed for {g['cfg']}"
        assert res.killed == g["killed"], f"killed changed for {g['cfg']}"
        for a, b in zip(res.equity, g["equity"], strict=True):
            assert math.isclose(a, b, rel_tol=0, abs_tol=1e-9), f"equity {g['cfg']}"
        for a, b in zip(res.drawdown, g["drawdown"], strict=True):
            assert math.isclose(a, b, rel_tol=0, abs_tol=1e-9), f"drawdown {g['cfg']}"
        for a, b in zip(res.leverage, g["leverage"], strict=True):
            assert math.isclose(a, b, rel_tol=0, abs_tol=1e-9), f"leverage {g['cfg']}"
        for wa, wb in zip(res.weights, g["weights"], strict=True):
            for k in set(wa) | set(wb):
                assert math.isclose(wa.get(k, 0.0), wb.get(k, 0.0), rel_tol=0, abs_tol=1e-9), \
                    f"weight[{k}] {g['cfg']}"
        for k, v in g["metrics"].items():
            assert math.isclose(res.metrics[k], v, rel_tol=0, abs_tol=1e-9), f"metric {k} {g['cfg']}"


# --- expensive/cheap split seam ---------------------------------------------------------------------

def test_cheap_reapply_equals_full_simulate_book():
    """`apply_knobs(overlay_pass(...), capital, cost_bps)` is bit-identical to a full `simulate_book` with
    the same capital/cost_bps — the seam Phase-2's page caches (expensive overlay once; cheap knobs per
    drag). capital + cost_bps are the genuinely-cheap knobs (kelly is NOT — it's in the overlay pass)."""
    fx = _three_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock", "stablecoin_rotation"]
    for kelly in (1.0, 0.5):
        op = overlay_pass(fx, sleeves=sleeves, kelly_fraction=kelly)
        for cap in (100_000.0, 37_000.0):
            for cost in (0.0, 10.0, 25.0):
                cheap = apply_knobs(op, capital=cap, cost_bps=cost)
                full = simulate_book(fx, capital=cap, sleeves=sleeves,
                                     kelly_fraction=kelly, cost_bps=cost)
                assert cheap.dates == full.dates
                assert cheap.killed == full.killed
                assert cheap.leverage == full.leverage
                assert cheap.equity == full.equity, f"k={kelly} cap={cap} cost={cost}"
                assert cheap.drawdown == full.drawdown
                assert cheap.weights == full.weights
                assert cheap.metrics == full.metrics


def test_overlay_pass_independent_of_capital_and_cost():
    """The expensive overlay pass depends ONLY on (returns, sleeves, controller, kelly, window) — NOT on
    capital/cost_bps. Re-using ONE pass for all capital/cost combos must give identical curves to passes
    that never knew those knobs (the property that makes caching it sound)."""
    fx = _three_edge_fixture()
    sleeves = ["crypto_tstrend", "unlock", "stablecoin_rotation"]
    op = overlay_pass(fx, sleeves=sleeves, controller="killswitch", kelly_fraction=1.0)
    # The pass has no capital/cost notion; the gross (killed-aware) returns are fixed.
    a = apply_knobs(op, capital=10_000.0, cost_bps=0.0)
    b = apply_knobs(op, capital=40_000.0, cost_bps=0.0)
    for ea, eb in zip(a.equity, b.equity, strict=True):
        assert math.isclose(eb, ea * 4.0, rel_tol=1e-12, abs_tol=1e-6)


# --- performance benchmark --------------------------------------------------------------------------

@pytest.mark.slow
def test_full_history_simulate_book_under_bound():
    """A full ~1473-day run must not regress to the O(D²) recomputation (which would be MINUTES on this
    series). The bound is generous (10s) ON PURPOSE: it must survive `pytest -n auto` CPU contention +
    slower CI hardware while still catching the only regression that matters here — a quadratic blowup,
    which is orders of magnitude, not a constant factor."""
    fx = _big_fixture()
    sleeves = list(fx)
    simulate_book(fx, capital=100_000.0, sleeves=sleeves)   # warm imports/JIT-free, fair timing
    runs = [_time(lambda: simulate_book(fx, capital=100_000.0, sleeves=sleeves)) for _ in range(3)]
    best = min(runs)
    assert best < 10.0, f"full-history simulate_book too slow: {best:.3f}s (O(D²) regression?)"


@pytest.mark.slow
def test_cheap_knob_reapply_is_effectively_instant():
    """After the expensive overlay pass is computed once, re-applying capital/cost_bps must be FAR cheaper
    than a full simulate — so dragging the capital / cost sliders on /paper/sim doesn't recompute the whole
    overlay. Asserted RELATIVE to a full run (contention-robust: both times inflate together under load, the
    ratio holds), not an absolute ms bound that flakes under `-n auto`."""
    fx = _big_fixture()
    sleeves = list(fx)
    full = min(_time(lambda: simulate_book(fx, capital=100_000.0, sleeves=sleeves)) for _ in range(2))
    op = overlay_pass(fx, sleeves=sleeves)
    apply_knobs(op, capital=100_000.0, cost_bps=10.0)       # warm
    best = min(_time(lambda cap=cap: apply_knobs(op, capital=cap, cost_bps=10.0))
               for cap in (100_000.0, 50_000.0, 200_000.0))
    assert best < full / 10.0, f"cheap re-apply not cheap: {best*1000:.1f}ms vs full {full*1000:.1f}ms"


def _time(fn) -> float:
    t0 = time.perf_counter()
    fn()
    return time.perf_counter() - t0


# --- simulate_naive_eqwt: the Bybit book's naive equal-weight curve (no overlay) -----------------

def test_naive_eqwt_compounds_static_equal_weight():
    """Hand-checked: 2 sleeves, eq-wt → daily ret = (ra+rb)/2, equity compounds from capital, lev≡1."""
    rbs = {"a": {0: 0.10, 1: 0.20}, "b": {0: 0.30, 1: -0.10}}
    res = simulate_naive_eqwt(rbs, capital=1000.0, sleeves=["a", "b"], cost_bps=0.0)
    assert res.dates == [0, 1]
    assert math.isclose(res.equity[0], 1000.0 * 1.20)            # (0.1+0.3)/2 = 0.20
    assert math.isclose(res.equity[1], 1000.0 * 1.20 * 1.05)     # (0.2-0.1)/2 = 0.05
    assert res.leverage == [1.0, 1.0] and res.killed == [False, False]


def test_naive_eqwt_capital_scales_end_value_linearly():
    rbs = {"a": {0: 0.10, 1: 0.20}, "b": {0: 0.30, 1: -0.10}}
    one = simulate_naive_eqwt(rbs, capital=10_000.0, sleeves=["a", "b"])
    two = simulate_naive_eqwt(rbs, capital=20_000.0, sleeves=["a", "b"])
    assert math.isclose(two.metrics["end_value"], 2.0 * one.metrics["end_value"])
    # total_return is capital-invariant (shape only).
    assert math.isclose(two.metrics["total_return"], one.metrics["total_return"])


def test_naive_eqwt_cost_bps_reduces_return():
    rbs = {"a": {0: 0.01, 1: 0.01, 2: 0.01}, "b": {0: 0.01, 1: 0.01, 2: 0.01}}
    cheap = simulate_naive_eqwt(rbs, capital=1000.0, sleeves=["a", "b"], cost_bps=0.0)
    pricey = simulate_naive_eqwt(rbs, capital=1000.0, sleeves=["a", "b"], cost_bps=50.0)
    assert pricey.metrics["end_value"] < cheap.metrics["end_value"]


def test_naive_eqwt_start_window_restricts_dates():
    rbs = {"a": {0: 0.01, 1: 0.02, 2: 0.03, 3: 0.04}}
    res = simulate_naive_eqwt(rbs, capital=1000.0, sleeves=["a"], start=2)
    assert res.dates == [2, 3]


def test_naive_eqwt_empty_is_graceful():
    res = simulate_naive_eqwt({}, capital=1000.0, sleeves=["a"])
    assert res.dates == [] and res.metrics["end_value"] == 1000.0
