"""Reference strategy: SMA cross on MNQ with a tick-based OCO bracket.

Two seams matter here, and both are the point of this file:

* The parity seam: the class reaches the venue ONLY through protocol
  surfaces — the ``buy``/``close`` sugar delegates to ``self.ctx.orders`` /
  ``self.ctx.positions``, which both the ``SimBroker`` and the live
  ``AsyncTopstepClient`` satisfy — so the SAME class runs unchanged in a
  backtest or against the real gateway. The hooks stay async and the orders
  stay awaited: that is the live contract, not boilerplate.
* The chassis guarantees: ``SymbolStrategy`` carries the bookkeeping the
  hand-rolled version of this file used to. ``use()``-registered indicators
  are auto-updated once per matching bar (registration order == update
  order), ``on_bar`` is ready-gated until every registered indicator has
  seen its lookback, and ``position``/``working_orders`` are folded from the
  same SDK events live emits — with each sugar-placed order id latched into
  ``working_orders`` at submit time, so the ``position.flat and not
  self.working_orders`` entry guard holds even when live hub confirmations
  lag the REST return past the next bar.

``Sma(n)`` is TA-Lib's ``SMA`` driven bar by bar (every indicator in this
framework is TA-Lib — see ``indicators/talib_adapter.py``). Two consequences
worth knowing before you read a value:

* Warmup is unchanged and comes from TA-Lib itself: ``Sma(20)`` needs 20 bars,
  and ``use()`` gates ``on_bar`` until the slowest registered indicator says
  ``ready``. Causality is structural — the adapter appends the just-closed bar
  to its buffer and reads the LAST output element, so it cannot see bar t+1.
* Values are float64 crossed into ``Decimal`` at the library boundary and are
  NOT snapped to the tick grid: a moving average is not a tradeable price and
  rounding it to the grid would be a lie. Prices stay exact Decimal; indicator
  levels are float-precise (deterministic, so reruns stay byte-equal).

Rejections (position cap, no-trade window) are normal combine control flow:
the sugar returns ``None`` and routes the ``APIError`` to ``on_reject``,
identical in sim and live.

    uv run python examples/sma_cross.py
"""

from __future__ import annotations

from topstep_backtest.indicators import Cross, Sma
from topstep_backtest.protocols import Bar
from topstep_backtest.strategy import SymbolStrategy


class SmaCross(SymbolStrategy):
    """Go long on a fast/slow SMA cross-up; exit via the bracket or cross-down."""

    def __init__(
        self,
        contract_id: str,
        *,
        fast: int = 20,
        slow: int = 50,
        size: int = 2,
        stop_loss_ticks: int = 40,
        take_profit_ticks: int = 80,
    ) -> None:
        super().__init__(contract_id)
        self.fast = self.use(Sma(fast))  # TA-Lib SMA; registered == auto-updated
        self.slow = self.use(Sma(slow))  # per bar and counted toward the gate
        self.cross = self.use(Cross(self.fast, self.slow))  # inputs use()d first
        # Every indicator keeps a bounded window (`fast.history_bars`) rather
        # than the whole series since inception. The policy exists for the
        # recursive functions (EMA, RSI, ATR, ...), where an unbounded buffer
        # would make the value depend on where the series happened to start and
        # no live session warming up from a finite history fetch could
        # reproduce it. SMA is nearly indifferent to it — TA-Lib carries a
        # RUNNING sum, so where the sum began still moves the last bit or two,
        # which is why SMA sits within an ulp of an unbounded run rather than
        # provably on it. Neither case weakens PARITY: sim and live run the
        # same window over the same bars.
        #
        # Preload `history_bars` bars live and sim and live agree bit for bit.
        # That is `warm`, not `ready`: `ready` (the use() gate) means a value
        # exists at `lookback` bars, while `warm` means the window is full and
        # the value no longer depends on where the run started. Everything in
        # between is a real number that a live session warm-started from a full
        # window would not reproduce.
        self.size = size
        self.stop_loss_ticks = stop_loss_ticks
        self.take_profit_ticks = take_profit_ticks

    async def on_bar(self, bar: Bar) -> None:
        # Called only for self.contract_id, only once every use()d indicator
        # is ready, with every indicator already updated for this bar. Orders
        # placed here are eligible from the NEXT bar (the accepted_ts
        # firewall); market orders fill at its open.
        if self.cross.up and self.position.flat and not self.working_orders:
            await self.buy(
                self.size,
                stop_loss_ticks=self.stop_loss_ticks,
                take_profit_ticks=self.take_profit_ticks,
            )
        elif self.cross.down and self.position.is_long:
            await self.close()


if __name__ == "__main__":
    from datetime import date
    from decimal import Decimal

    from topstep_backtest import AccountSize, Backtest
    from topstep_backtest.core.instruments import spec_for_symbol
    from topstep_backtest.data.synthetic import synthetic_bars

    contract = "CON.F.US.MNQ.U26"
    bars = synthetic_bars(
        contract_id=contract,
        spec=spec_for_symbol("MNQ"),
        start_day=date(2026, 5, 4),
        days=6,
        seed=7,
        start_price=Decimal("18000.00"),
        bars_per_day=120,
        vol_ticks=12,
    )
    report = Backtest(bars, SmaCross(contract), account=AccountSize.S50K).run()
    print(report)
