"""
Full-coverage execution engine test suite.

Runs against synthetic OHLCV data across multiple configurations:
  1. Single-asset  — all order types, all TIF variants, bracket exits
  2. Multi-asset 2 — concurrent positions, shared equity, per-ticker independence
  3. Multi-asset 4 — four tickers simultaneously
  4. High leverage — 10x, position sizing and margin checks
  5. Commission modes — RoundTrip / OpenOnly / CloseOnly
  6. Spread / OhlcvType — Bid / Ask / Midpoint fill-price adjustments
  7. Overnight swap — equity reduced by swap charges across date boundaries
  8. Alignment union vs intersection — mixed-schedule assets

All data is generated locally (see synth_bars below) — no external files, no
network access, nothing beyond what's in this repo, so the suite is fully
reproducible by anyone who downloads it.

Usage:
    python tests/test_execution_engine.py
"""

import sys, os, random, tempfile
from datetime import datetime, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../build_py"))

import reamer_py
from strategy.examples.execution_test_suite   import ExecutionTestSuite
from strategy.examples.multi_asset_test_suite  import MultiAssetTestSuite

# ── synthetic data generator ────────────────────────────────────────────────

def synth_bars(n, start, interval_minutes, base_price, vol_scale, seed,
                market_hours_only=False):
    """Deterministic synthetic OHLCV bars — a seeded random walk, not fabricated
    from scratch, so strategies see realistic price action rather than something
    degenerate. Same seed always produces the same series.

    market_hours_only restricts bars to weekday 09:30-16:00, used to create a
    genuine schedule gap against a 24/7 series (scenarios 14-15 need NDX and
    ETH to actually disagree on which timestamps exist, not just carry
    different prices)."""
    rng = random.Random(seed)
    price = base_price
    ts = start
    step = timedelta(minutes=interval_minutes)
    bars = []
    while len(bars) < n:
        drift = rng.gauss(0, vol_scale * 0.15)
        price = max(vol_scale * 0.05, price + drift)
        high  = price + abs(rng.gauss(0, vol_scale * 0.05))
        low   = max(vol_scale * 0.01, price - abs(rng.gauss(0, vol_scale * 0.05)))
        b = reamer_py.OhlcvBar()
        b.timestamp = ts.strftime("%Y-%m-%d %H:%M:%S")
        b.open, b.high, b.low, b.close = price, high, low, price
        b.volume = abs(rng.gauss(10_000, 2_000))
        bars.append(b)

        ts += step
        if market_hours_only and (ts.hour >= 16 or ts.weekday() >= 5):
            ts = ts.replace(hour=9, minute=30, second=0, microsecond=0) + timedelta(days=1)
            while ts.weekday() >= 5:
                ts += timedelta(days=1)
    return bars

# ── invariant checker ─────────────────────────────────────────────────────────

def run_invariants(result, label, bars_by_ticker,
                   expected_tickers=None,
                   min_fills=1,
                   commission_mode=None,
                   commission_per_unit=0.0,
                   spread=0.0,
                   slippage=0.0,
                   ohlcv_type=None,
                   leverage=1.0,
                   expect_margin_rejection=False,
                   expect_swap=False,
                   expect_roll_events=False,
                   roll_configured_tickers=None):

    checks = []

    def chk(name, cond, detail=""):
        verdict = "PASS" if cond else "FAIL"
        checks.append((label, name, verdict, detail))
        return cond

    orders  = result.order_log
    trades  = result.closed_trades
    filled  = [o for o in orders if o.status.name == "Filled"]
    # Fill prices can exceed raw bar range by up to spread + slippage (applied on
    # top of tick_price which is clamped to [bar.low, bar.high]).
    # 3× spread headroom: sampled_spread has sigma=0.3*spread, so 3σ ≈ 0.9*spread above cfg.spread
    eps     = spread * 3 + slippage + 0.02

    # per-ticker bar lookup: (ticker_lower, timestamp) → OhlcvBar
    # also a merged fallback for single-asset scenarios
    ticker_bar_map = {t.lower(): {b.timestamp: b for b in blist}
                      for t, blist in bars_by_ticker.items()}
    bar_by_ts = {}  # merged fallback (last write wins)
    for blist in bars_by_ticker.values():
        for b in blist:
            bar_by_ts[b.timestamp] = b

    def bar_for(ticker, ts):
        # Per-ticker lookup first — avoids false positives when multiple tickers
        # share a timestamp but have different OHLCV values (e.g. ETH vs XRP gaps).
        tmap = ticker_bar_map.get(ticker.lower())
        if tmap is not None:
            return tmap.get(ts)
        # Fallback for single-asset runs where ticker_bar_map only has one key.
        return bar_by_ts.get(ts)

    # INV-1  no order stuck Pending
    stuck = [o for o in orders if o.status.name == "Pending"]
    chk("No Pending orders at end", len(stuck) == 0,
        f"stuck ids={[o.id for o in stuck]}")

    # INV-2  every order has closed_timestamp
    chk("All orders have closed_timestamp",
        all(o.closed_timestamp for o in orders))

    # INV-3  rejected orders have reason
    chk("Rejected orders have reject_reason",
        all(o.reject_reason for o in orders if o.status.name == "Rejected"))

    # INV-4  fill prices > 0
    chk("All fill prices > 0",
        all(o.fill_price > 0 for o in filled))

    # INV-5  limit fill prices respect stated limit
    bad = []
    for o in filled:
        if o.order_type_str == "buy_limit"  and o.fill_price > o.limit_price + eps:
            bad.append(o)
        if o.order_type_str == "sell_limit" and o.fill_price < o.limit_price - eps:
            bad.append(o)
    chk("Limit fills respect limit price", len(bad) == 0,
        " | ".join(f"id={o.id} lim={o.limit_price:.5f} fill={o.fill_price:.5f}" for o in bad[:3]))

    # INV-6  stop fills within triggering bar's range (per-ticker lookup)
    bad = []
    for o in filled:
        if o.order_type_str not in ("buy_stop", "sell_stop"):
            continue
        bar = bar_for(o.ticker, o.closed_timestamp)
        if not bar:
            continue
        if not (bar.low - eps <= o.fill_price <= bar.high + eps):
            bad.append(o)
    chk("Stop fills within bar range", len(bad) == 0,
        " | ".join(f"id={o.id} ticker={o.ticker} fill={o.fill_price:.5f}" for o in bad[:3]))

    # INV-7  all exits within closing bar's range (per-ticker lookup)
    bad = []
    for ct in trades:
        bar = bar_for(ct.ticker, ct.close_timestamp)
        if not bar:
            continue
        if not (bar.low - eps <= ct.exit_price <= bar.high + eps):
            bad.append(ct)
    chk("All exits within closing bar range", len(bad) == 0,
        " | ".join(f"{ct.ticker} exit={ct.exit_price:.5f} bar=[{b.low:.5f},{b.high:.5f}]"
                   for ct in bad[:3]
                   for b in [bar_for(ct.ticker, ct.close_timestamp)] if b))

    # INV-8  slippage_cost >= 0
    chk("slippage_cost >= 0",
        all(o.slippage_cost >= -1e-9 for o in filled))

    # INV-9  gross_pnl formula exact
    bad = []
    for ct in trades:
        sign = 1.0 if ct.side == reamer_py.Side.Buy else -1.0
        expected = (ct.exit_price - ct.entry_price) * ct.qty * sign
        if abs(expected - ct.gross_pnl) > 1e-4 * max(abs(expected), 1.0):
            bad.append((ct, expected))
    chk("gross_pnl = (exit-entry)*qty*sign", len(bad) == 0,
        " | ".join(f"exp={e:.4f} got={ct.gross_pnl:.4f}" for ct, e in bad[:3]))

    # INV-10  net_pnl = gross_pnl - fees (total fees from all legs)
    bad = []
    for ct in trades:
        expected = ct.gross_pnl - ct.fees
        if abs(expected - ct.net_pnl) > 1e-4 * max(abs(expected), 1.0):
            bad.append((ct, expected))
    chk("net_pnl = gross_pnl - fees", len(bad) == 0,
        " | ".join(f"exp={e:.4f} got={ct.net_pnl:.4f}" for ct, e in bad[:3]))

    # INV-11  total_fees == sum of closed-trade fees
    # (bracket exits charge fees that never appear as LiveOrder entries)
    trade_fee_sum = sum(ct.fees for ct in trades)
    chk("total_fees matches closed-trade fee sum",
        abs(result.total_fees - trade_fee_sum) < 1e-4,
        f"total={result.total_fees:.4f} trade_sum={trade_fee_sum:.4f}")

    # INV-12  total_slippage == sum of closed-trade slippage
    trade_slip_sum = sum(ct.slippage for ct in trades)
    chk("total_slippage matches closed-trade slippage sum",
        abs(result.total_slippage_cost - trade_slip_sum) < 1e-4,
        f"total={result.total_slippage_cost:.4f} trade_sum={trade_slip_sum:.4f}")

    # INV-13  cancelled orders have fill_price == 0
    chk("Cancelled orders have fill_price=0",
        all(o.fill_price == 0.0 for o in orders if o.status.name == "Cancelled"))

    # INV-14  at least min_fills orders filled
    chk(f"At least {min_fills} fills", len(filled) >= min_fills,
        f"got {len(filled)}")

    # INV-15  IOC orders never stay Pending
    chk("IOC orders never stay Pending",
        all(o.status.name != "Pending" for o in orders if o.tif_str == "IOC"))

    # INV-16  expected tickers all appear in closed trades
    if expected_tickers:
        seen = {ct.ticker for ct in trades}
        missing = set(t.upper() for t in expected_tickers) - seen
        chk("All expected tickers produced trades", len(missing) == 0,
            f"missing={missing}")

    # INV-17  margin rejection exists when expected
    if expect_margin_rejection:
        rejected = [o for o in orders
                    if o.status.name == "Rejected" and "margin" in o.reject_reason.lower()]
        chk("Margin rejection fires when expected", len(rejected) >= 1,
            f"no margin rejections found among {len([o for o in orders if o.status.name=='Rejected'])} rejections")

    # INV-18  commission mode: RoundTrip → fees on every filled order
    if commission_mode == "RoundTrip" and commission_per_unit > 0:
        no_fee = [o for o in filled if o.fees < 1e-9]
        chk("RoundTrip: every fill has fees", len(no_fee) == 0,
            f"{len(no_fee)} fills with zero fees")

    # INV-19  commission mode: OpenOnly → total trade fees ≤ open-leg only
    # Bracket exits are implicit (no LiveOrder entry), so we can't filter by
    # order_type_str.  Instead compare ct.fees against the open-only expected
    # amount: commission_per_unit * ct.qty.  Any close-leg charge would push
    # ct.fees above this bound.
    if commission_mode == "OpenOnly" and commission_per_unit > 0:
        bad = []
        for ct in trades:
            expected = commission_per_unit * ct.qty
            if ct.fees > expected + 1e-9:
                bad.append((ct, expected))
        chk("OpenOnly: close legs have no fees", len(bad) == 0,
            " | ".join(f"expected_fees<={e:.6f} got={ct.fees:.6f}" for ct, e in bad[:3]))

    # INV-20  Bid type: buy limit fill ≤ limit + slippage
    # trigger fires when tick_ask <= limit_price, so fill = tick_ask + slippage ≤ limit + slippage
    if ohlcv_type == "Bid" and spread > 0:
        bad = [o for o in filled
               if o.order_type_str == "buy_limit" and
               o.fill_price > o.limit_price + slippage + 1e-3]
        chk("Bid type: limit buy fill ≤ limit + slippage", len(bad) == 0,
            " | ".join(f"id={o.id} limit={o.limit_price:.5f} fill={o.fill_price:.5f} max_allowed={o.limit_price+slippage:.5f}"
                       for o in bad[:3]))

    # INV-21  Ask type: sell limit fill ≥ limit - slippage
    # trigger fires when tick_bid >= limit_price, so fill = tick_bid - slippage ≥ limit - slippage
    if ohlcv_type == "Ask" and spread > 0:
        bad = [o for o in filled
               if o.order_type_str == "sell_limit" and
               o.fill_price < o.limit_price - slippage - 1e-3]
        chk("Ask type: limit sell fill ≥ limit - slippage", len(bad) == 0,
            " | ".join(f"id={o.id} limit={o.limit_price:.5f} fill={o.fill_price:.5f} min_allowed={o.limit_price-slippage:.5f}"
                       for o in bad[:3]))

    # INV-22  overnight swap: total_swap_cost > 0 when swap configured and positions held overnight
    if expect_swap:
        chk("Overnight swap applied (total_swap_cost > 0)",
            result.total_swap_cost > 0,
            f"total_swap_cost={result.total_swap_cost:.6f}")

    # INV-23  multi-asset: closed trades match their ticker's price range
    if expected_tickers and len(expected_tickers) > 1:
        ticker_bars = {t: {b.timestamp: b for b in blist}
                       for t, blist in bars_by_ticker.items()}
        bad = []
        for ct in trades:
            t_bars = ticker_bars.get(ct.ticker.lower()) or ticker_bars.get(ct.ticker)
            if not t_bars:
                continue
            close_bar = t_bars.get(ct.close_timestamp)
            if not close_bar:
                continue
            if not (close_bar.low - eps <= ct.exit_price <= close_bar.high + eps):
                bad.append(ct)
        chk("Multi-asset exits within per-ticker bar range", len(bad) == 0,
            " | ".join(f"{ct.ticker} exit={ct.exit_price:.5f}" for ct in bad[:3]))

    # INV-24  futures roll: rebasing actually fired when a roll-configured ticker's
    # data spans a cycle boundary — a roll_cycle_months config that never crosses a
    # boundary would trivially pass INV-25/26 with an empty roll_log, silently
    # proving nothing. See EXECUTION_SPEC.md §12.
    if expect_roll_events:
        chk("Roll events recorded (roll_cycle_months crossed a boundary)",
            len(result.roll_log) >= 1,
            f"roll_log empty — scenario's date range never reached a configured boundary")

    # INV-25  every roll ratio is a finite, strictly positive multiplier — curr_bar.open
    # and prev_bar.close are always > 0 in valid OHLCV data, so ratio = open/close can
    # never be zero, negative, or non-finite. See EXECUTION_SPEC.md §12.
    if result.roll_log:
        bad = [ev for ev in result.roll_log if not (ev.ratio > 0.0 and ev.ratio < float("inf"))]
        chk("All roll ratios are finite and > 0", len(bad) == 0,
            " | ".join(f"{ev.ticker}@{ev.timestamp} ratio={ev.ratio}" for ev in bad[:3]))

    # INV-26  roll events are scoped only to tickers whose *resolved* per-ticker config
    # (Section 0) actually has roll_cycle_months set — a roll on ticker A must never
    # leak into ticker B's bookkeeping in the same run. See EXECUTION_SPEC.md §12 and
    # the equivalent isolation check in tests/python/test_futures_roll.py.
    if roll_configured_tickers is not None:
        leaked = {ev.ticker for ev in result.roll_log} - set(t.upper() for t in roll_configured_tickers)
        chk("Roll events scoped to roll-configured tickers only", len(leaked) == 0,
            f"unexpected roll_log tickers={leaked}")

    return checks

# ── run one scenario ──────────────────────────────────────────────────────────

all_checks = []

def run(label, bars_by_ticker, strategy_factory, cfg, leverage=1.0,
        alignment="union", ticker_overrides=None, **inv_kwargs):
    strategy = strategy_factory()
    with tempfile.TemporaryDirectory() as d:
        data = {}
        for ticker, bars in bars_by_ticker.items():
            path = os.path.join(d, f"{ticker}.bin")
            reamer_py.write_bin(bars, path)
            data[ticker] = path
        result = reamer_py.run_backtest(
            data, strategy, alignment, cfg,
            initial_capital=100_000.0, leverage=leverage,
            ticker_overrides=ticker_overrides or {},
        )
    checks = run_invariants(result, label, bars_by_ticker,
                            leverage=leverage, **inv_kwargs)
    all_checks.extend(checks)

    filled = [o for o in result.order_log if o.status.name == "Filled"]
    tickers_str = "+".join(sorted(bars_by_ticker.keys()))
    n_bars = max(len(v) for v in bars_by_ticker.values())
    print(f"  {label:<52} bars={n_bars}  fills={len(filled)}  trades={len(result.closed_trades)}")
    return result

# ── generate data ─────────────────────────────────────────────────────────────
# Bar counts are sized generously above the highest phase-slot each scenario
# reaches (see the two strategy files) — SPX additionally needs to span a full
# calendar quarter, since scenario 16 tests roll_cycle_months=[3,6,9,12] and a
# range that never crosses a quarter-end would trivially pass with an empty
# roll_log, proving nothing.

print("Generating synthetic data...")
JAN1 = datetime(2024, 1, 1, 0, 0, 0)

spx_h1     = synth_bars(2200, JAN1,                          60, 100.0, 2.0,  seed=1)
ndx_m15    = synth_bars(300,  datetime(2024, 1, 2, 9, 30, 0), 15, 100.0, 2.0,  seed=2, market_hours_only=True)
# AUD/GBP span ~99 days (not just 300 bars) specifically so scenario 17's
# roll_cycle_months=[3,6,9,12] override on AUDUSD actually crosses the Q1
# boundary — both must stay the same length, since MultiAssetTestSuite only
# advances its phase counter on steps where both tickers are valid.
aud_m15    = synth_bars(9500, JAN1,                           15, 0.65,  0.010, seed=3)
gbp_m15    = synth_bars(9500, JAN1,                           15, 1.27,  0.012, seed=4)
nzd_m15    = synth_bars(300,  JAN1,                           15, 0.61,  0.009, seed=5)
usdcad_m15 = synth_bars(300,  JAN1,                           15, 1.35,  0.011, seed=6)
# ETH must outlast NDX's full date range (~16-18 calendar days for 300
# market-hours bars), not just match its bar count — scenarios 14/15 pair
# them on a union timeline, and MultiAssetTestSuite only advances once both
# tickers are valid at the same step, so a shorter ETH series would strand
# the phase counter once ETH runs out mid-way through NDX's range.
eth_m15    = synth_bars(2000, JAN1,                           15, 100.0, 3.0,  seed=7)
xrp_m15    = synth_bars(300,  JAN1,                           15, 100.0, 2.5,  seed=8)
print(f"  SPX H1={len(spx_h1)}  NDX M15={len(ndx_m15)}  AUD M15={len(aud_m15)}  "
      f"GBP M15={len(gbp_m15)}  ETH M15={len(eth_m15)}  XRP M15={len(xrp_m15)}\n")

# Keys must be uppercase — submitted ticker string must match the key in bars_by_ticker exactly.
SPX     = {"SPX":    spx_h1}
NDX     = {"NDX100": ndx_m15}
AUD_GBP = {"AUDUSD": aud_m15, "GBPUSD": gbp_m15}
FOUR_FX = {"AUDUSD": aud_m15, "GBPUSD": gbp_m15,
           "NZDUSD": nzd_m15, "USDCAD": usdcad_m15}
ETH_XRP = {"ETHUSD": eth_m15, "XRPUSD": xrp_m15}
NDX_ETH = {"NDX100": ndx_m15, "ETHUSD": eth_m15}

# ── base configs ──────────────────────────────────────────────────────────────

def cfg_base():
    c = reamer_py.DefaultExecutionModelConfig()
    c.commission_per_unit = 0.5
    c.slippage            = 0.1
    c.spread              = 0.2
    return c

def cfg_forex():
    c = reamer_py.DefaultExecutionModelConfig()
    c.commission_per_unit = 0.0001
    c.slippage            = 0.00005
    c.spread              = 0.0002
    return c

print("═" * 72)
print("  RUNNING TEST SCENARIOS")
print("═" * 72)

# ── 1. Single asset, all order types ─────────────────────────────────────────
run("1. Single-asset all order types (SPX H1)",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_base(), spread=0.2, slippage=0.1, min_fills=15)

# ── 2. Multi-asset 2 tickers (AUD + GBP forex) ───────────────────────────────
run("2. Multi-asset 2 tickers (AUDUSD + GBPUSD)",
    AUD_GBP, lambda: MultiAssetTestSuite(["AUDUSD", "GBPUSD"]), cfg_forex(),
    spread=0.0002, slippage=0.00005, expected_tickers=["AUDUSD", "GBPUSD"],
    expect_margin_rejection=True, min_fills=4)

# ── 3. Multi-asset 4 tickers ──────────────────────────────────────────────────
run("3. Multi-asset 4 tickers (AUD+GBP+NZD+USDCAD)",
    FOUR_FX, lambda: MultiAssetTestSuite(["AUDUSD", "GBPUSD"]), cfg_forex(),
    spread=0.0002, slippage=0.00005, expected_tickers=["AUDUSD", "GBPUSD"],
    expect_margin_rejection=True, min_fills=4)

# ── 4. High leverage 10x ──────────────────────────────────────────────────────
run("4. High leverage 10x (SPX H1)",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_base(), leverage=10.0, spread=0.2, slippage=0.1, min_fills=15)

# ── 5. Margin rejection at leverage=1 with tiny equity ───────────────────────
def run_margin_test():
    cfg = cfg_base()
    strategy = ExecutionTestSuite("SPX")
    with tempfile.TemporaryDirectory() as d:
        data = {}
        for ticker, bars in SPX.items():
            path = os.path.join(d, f"{ticker}.bin")
            reamer_py.write_bin(bars, path)
            data[ticker] = path
        result = reamer_py.run_backtest(
            data, strategy, "union", cfg,
            initial_capital=1.0, leverage=1.0,
        )
    checks = run_invariants(result, "5. Margin rejection (equity=$1)",
                            SPX, expect_margin_rejection=True, min_fills=0)
    all_checks.extend(checks)
    filled   = [o for o in result.order_log if o.status.name == "Filled"]
    rejected = [o for o in result.order_log if o.status.name == "Rejected"]
    print(f"  {'5. Margin rejection (equity=$1)':<52} fills={len(filled)}  rejected={len(rejected)}")

run_margin_test()

# ── 6. Commission mode: RoundTrip ─────────────────────────────────────────────
def cfg_rt():
    c = cfg_base(); c.commission_mode = reamer_py.CommissionMode.RoundTrip; c.commission_per_unit = 1.0; return c

run("6. Commission RoundTrip",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_rt(),
    spread=0.2, commission_mode="RoundTrip", commission_per_unit=1.0, min_fills=5)

# ── 7. Commission mode: OpenOnly ──────────────────────────────────────────────
def cfg_open():
    c = cfg_base(); c.commission_mode = reamer_py.CommissionMode.OpenOnly; c.commission_per_unit = 1.0; return c

run("7. Commission OpenOnly",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_open(),
    spread=0.2, commission_mode="OpenOnly", commission_per_unit=1.0, min_fills=5)

# ── 8. Commission mode: CloseOnly ─────────────────────────────────────────────
def cfg_close_only():
    c = cfg_base(); c.commission_mode = reamer_py.CommissionMode.CloseOnly; c.commission_per_unit = 1.0; return c

run("8. Commission CloseOnly", SPX, lambda: ExecutionTestSuite("SPX"), cfg_close_only(), spread=0.2, slippage=0.1, min_fills=5)

# ── 9. OhlcvType = Bid ────────────────────────────────────────────────────────
def cfg_bid():
    c = cfg_base(); c.ohlcv_type = reamer_py.OhlcvType.Bid; c.spread = 0.5; return c

run("9. OhlcvType Bid spread",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_bid(), spread=0.5, slippage=0.1, ohlcv_type="Bid", min_fills=5)

# ── 10. OhlcvType = Ask ───────────────────────────────────────────────────────
def cfg_ask():
    c = cfg_base(); c.ohlcv_type = reamer_py.OhlcvType.Ask; c.spread = 0.5; return c

run("10. OhlcvType Ask spread",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_ask(), spread=0.5, slippage=0.1, ohlcv_type="Ask", min_fills=5)

# ── 11. OhlcvType = Midpoint ──────────────────────────────────────────────────
def cfg_mid():
    c = cfg_base(); c.ohlcv_type = reamer_py.OhlcvType.Midpoint; c.spread = 0.5; return c

run("11. OhlcvType Midpoint spread", SPX, lambda: ExecutionTestSuite("SPX"), cfg_mid(), spread=0.5, slippage=0.1, min_fills=5)

# ── 12. Overnight swap ────────────────────────────────────────────────────────
def cfg_swap():
    c = cfg_base()
    for day in range(7): c.set_swap(day, 0.5)
    return c

run("12. Overnight swap (SPX H1)",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_swap(),
    spread=0.2, slippage=0.1, expect_swap=True, min_fills=5)

# ── 13. Crypto multi-asset (ETH + XRP, 24/7 overlapping) ─────────────────────
run("13. Crypto multi-asset (ETHUSD + XRPUSD)",
    ETH_XRP, lambda: MultiAssetTestSuite(["ETHUSD", "XRPUSD"]), cfg_base(),
    spread=0.2, slippage=0.1, expected_tickers=["ETHUSD", "XRPUSD"],
    expect_margin_rejection=True, min_fills=4)

# ── 14. Mixed schedule union (NDX market hours + ETH 24/7) ───────────────────
run("14. Mixed schedule union (NDX100 + ETHUSD)",
    NDX_ETH, lambda: MultiAssetTestSuite(["ETHUSD", "NDX100"]), cfg_base(),
    alignment="union", spread=0.2, slippage=0.1,
    expected_tickers=["NDX100", "ETHUSD"],
    expect_margin_rejection=True, min_fills=2)

# ── 15. Mixed schedule intersection ──────────────────────────────────────────
# With intersection + mixed schedules (NDX=market hours, ETH=24/7), orders for
# NDX100 land at union-timeline ETH-only steps where NDX has no bar → rejected
# "no market data". Only ETH trades succeed. This is expected architectural
# behavior: intersection alignment controls what on_bar sees, not what the
# replay engine's union timeline presents for execution.
run("15. Mixed schedule intersection (NDX100 + ETHUSD)",
    NDX_ETH, lambda: MultiAssetTestSuite(["ETHUSD", "NDX100"]), cfg_base(),
    alignment="intersection", spread=0.2, slippage=0.1,
    min_fills=2)

# ── 16. Futures roll-cycle rebasing (SPX H1, quarterly, real multi-order-type flow) ──
# Exercises roll_cycle_months against real market data and the full ExecutionTestSuite
# (market/limit/stop/bracket orders), not the synthetic flat-bar isolation scenarios in
# tests/python/test_futures_roll.py — the point here is confirming rebasing coexists
# correctly with every other invariant already checked above (fills, PnL identities,
# margin, bracket exits), not re-proving the ratio math in isolation. See
# EXECUTION_SPEC.md §12.
def cfg_roll():
    c = cfg_base()
    c.roll_cycle_months = [3, 6, 9, 12]   # quarterly
    c.roll_days_before_monthend = 5
    return c

run("16. Futures roll-cycle rebasing (SPX H1, quarterly)",
    SPX, lambda: ExecutionTestSuite("SPX"), cfg_roll(),
    spread=0.2, slippage=0.1, min_fills=15, expect_roll_events=True,
    roll_configured_tickers=["SPX"])

# ── 17. Futures roll-cycle per-ticker isolation (AUDUSD rolled, GBPUSD not) ─────────
# AUDUSD gets a roll-cycle override via ticker_overrides; GBPUSD has no override and
# uses the global (roll-disabled) config. Confirms no roll_log entry leaks onto the
# unconfigured ticker in the same run. See EXECUTION_SPEC.md §12 and the equivalent
# isolation test in tests/python/test_futures_roll.py.
def cfg_forex_roll():
    c = cfg_forex()
    c.roll_cycle_months = [3, 6, 9, 12]
    c.roll_days_before_monthend = 5
    return c

run("17. Futures roll-cycle per-ticker isolation (AUDUSD rolled, GBPUSD not)",
    AUD_GBP, lambda: MultiAssetTestSuite(["AUDUSD", "GBPUSD"]), cfg_forex(),
    ticker_overrides={"AUDUSD": cfg_forex_roll()},
    spread=0.0002, slippage=0.00005, expected_tickers=["AUDUSD", "GBPUSD"],
    expect_margin_rejection=True, min_fills=4,
    expect_roll_events=True, roll_configured_tickers=["AUDUSD"])

# ── print results ─────────────────────────────────────────────────────────────

pass_n = sum(1 for _, _, v, _ in all_checks if v == "PASS")
fail_n = len(all_checks) - pass_n

print("\n" + "═" * 72)
print("  INVARIANT RESULTS")
print("═" * 72)

cur_label = None
for label, name, verdict, detail in all_checks:
    if label != cur_label:
        cur_label = label
        print(f"\n  ── {label}")
    flag = "✓" if verdict == "PASS" else "✗"
    line = f"    {flag} {name:<54} {verdict}"
    if verdict == "FAIL" and detail:
        line += f"\n        [{detail}]"
    print(line)

print("\n" + "─" * 72)
print(f"  {pass_n}/{len(all_checks)} checks passed   "
      f"{'ALL PASS' if fail_n == 0 else f'{fail_n} FAILURES'}")
print("─" * 72 + "\n")
