from __future__ import annotations

from typing import Any, Dict, List, Optional, Tuple

import numpy as np
import pandas as pd


class StrategyState:
    """
    Single-symbol strategy state with basic risk and execution realism.

    Tracks orders, positions, trades, and equity for one symbol.
    """

    def __init__(
        self,
        prices: pd.Series,
        initial_equity: float = 100000.0,
        commission_perc: float = 0.0,
        slippage: float = 0.0,
        pyramiding: int = 1,
        # --- OHLC (support both open and open_ for compatibility) ---
        open: Optional[pd.Series] = None,
        open_: Optional[pd.Series] = None,
        high: Optional[pd.Series] = None,
        low: Optional[pd.Series] = None,
        close: Optional[pd.Series] = None,
        symbol: str = "",
        execution_mode: str = "backtest",
        executionmode: Optional[str] = None,  # back-compat with old tests
        symbol_config: Optional[Dict[str, Any]] = None,
        symbolconfig: Optional[Dict[str, Any]] = None,  # back-compat with old tests
        intrabar_model: str = "none",
        intrabar_steps: int = 0,
        # legacy aliases for older callers
        initialequity: Optional[float] = None,
        commissionperc: Optional[float] = None,
    ):
        if executionmode is not None:
            execution_mode = executionmode
        if initialequity is not None:
            initial_equity = float(initialequity)
        if commissionperc is not None:
            commission_perc = float(commissionperc)
        if symbolconfig is not None and symbol_config is None:
            symbol_config = symbolconfig

        self.prices = prices.astype(float)

        self.initial_equity = float(initial_equity)
        self.current_equity = float(initial_equity)

        self.commission_perc = float(commission_perc)
        self.slippage = float(slippage)
        self.pyramiding = int(pyramiding)
        self.symbol = symbol

        if execution_mode not in ("backtest", "realistic"):
            raise ValueError("execution_mode must be 'backtest' or 'realistic'")
        self.execution_mode = execution_mode

        self.symbol_config = symbol_config or {}
        cfg = self.symbol_config
        self.spread_points = float(cfg.get("spread_points", 0.0))
        self.min_lot = float(cfg.get("min_lot", 0.0))
        self.lot_step = float(cfg.get("lot_step", 0.0))
        self.max_leverage = float(cfg.get("max_leverage", 0.0))
        self.contract_size = float(cfg.get("contract_size", 1.0))

        if open is None:
            open = open_
        self.open = open
        self.high = high
        self.low = low
        self.close = close if close is not None else prices

        self._lots: List[Dict[str, Any]] = []
        self.trades: List[Dict[str, Any]] = []
        self.orders: List[Dict[str, Any]] = []
        self.equity_curve: List[float] = []
        self.pnl_curve: List[float] = []
        self.position_curve: List[float] = []
        self._last_marked_index: Optional[int] = None
        self._open_pos_equity_peak: Optional[float] = None

        self.rejected_orders: int = 0

        self.intrabar_model = (
            intrabar_model if intrabar_model in ("none", "random_walk") else "none"
        )
        self.intrabar_steps = max(0, int(intrabar_steps))

    # -------------------------
    # Basic helpers
    # -------------------------
    @property
    def position_size(self) -> float:
        return float(sum(lot["direction"] * lot["size"] for lot in self._lots))

    @property
    def position_dir(self) -> int:
        ps = self.position_size
        if ps > 0:
            return 1
        if ps < 0:
            return -1
        return 0

    @property
    def open_lots(self) -> int:
        return len(self._lots)

    def _price_at(self, idx: int) -> float:
        idx = int(idx)
        if idx < 0 or idx >= len(self.prices):
            idx = max(0, min(len(self.prices) - 1, idx))
        return float(self.prices.iloc[idx])

    def _commission(self, notional: float) -> float:
        return float(abs(notional) * self.commission_perc)

    def _apply_spread(self, price: float, side: str) -> float:
        if self.spread_points <= 0.0:
            return float(price)
        spread_half = self.spread_points * 0.5
        if side.lower() == "long":
            return float(price + spread_half)
        return float(price - spread_half)

    def _mark_to_market_equity(self, price: float) -> float:
        equity = self.current_equity
        for lot in self._lots:
            direction = int(lot["direction"])
            size = float(lot["size"])
            entry_price = float(lot["entry_price"])
            pnl = (price - entry_price) * direction * size
            equity += pnl
        return float(equity)

    # -------------------------
    # Lot sizing and margin
    # -------------------------
    def _normalize_lot_size(self, size: float) -> float:
        size = float(size)
        if size <= 0.0:
            return 0.0
        if self.min_lot > 0.0 and size < self.min_lot:
            size = self.min_lot
        if self.lot_step > 0.0:
            steps = round(size / self.lot_step)
            size = steps * self.lot_step
        return float(size)

    def _margin_ok(self, size: float, price: float) -> bool:
        if self.max_leverage <= 0.0:
            return True
        equity = max(self.current_equity, 1e-9)
        notional = abs(size * price * self.contract_size)
        current_pos_notional = abs(self.position_size * price * self.contract_size)
        total_notional = current_pos_notional + notional
        lev = total_notional / equity
        return bool(lev <= self.max_leverage + 1e-9)

    # -------------------------
    # OHLC helpers and intrabar path
    # -------------------------
    def _bar_ohlc(self, idx: int) -> Optional[Tuple[float, float, float, float]]:
        ohlc_available = (
            self.open is not None
            and self.high is not None
            and self.low is not None
            and self.close is not None
            and len(self.open) == len(self.prices)
        )
        if not ohlc_available:
            return None
        o = float(self.open.iloc[idx])
        h = float(self.high.iloc[idx])
        l = float(self.low.iloc[idx])
        c = float(self.close.iloc[idx])
        return o, h, l, c

    def _iter_intrabar_prices(self, idx: int) -> List[float]:
        if self.intrabar_model == "none" or self.intrabar_steps <= 0:
            return [self._price_at(idx)]

        ohlc = self._bar_ohlc(idx)
        if ohlc is None:
            return [self._price_at(idx)]

        o, h, l, c = ohlc
        steps = int(self.intrabar_steps)

        if steps <= 1:
            return [c]

        path = [o]
        for i in range(steps - 2):
            alpha = (i + 1) / (steps - 1)
            mid = l + (h - l) * alpha
            drift = (c - mid)
            noise = (h - l) * 0.05
            p = mid + drift * 0.3 + np.random.uniform(-noise, noise)
            p = max(min(p, h), l)
            path.append(float(p))

        path.append(c)
        return path
    # -------------------------
    # Order placement
    # -------------------------
    def place_limit(
        self,
        side: str,
        idx: int,
        size: float,
        price: float,
        tif: str = "GTC",
        reduce_only: bool = False,
        expire_index: Optional[int] = None,
        group_id: Optional[str] = None,
        groupid: Optional[str] = None,   # legacy alias
        reduceonly: Optional[bool] = None,  # legacy alias
        **kwargs: Any,
    ) -> Dict[str, Any]:
        placed_index = int(idx)
        expire_after_bars = kwargs.pop("expire_after_bars", None)
        if expire_index is None and expire_after_bars is not None:
            expire_index = placed_index + int(expire_after_bars)

        if groupid is not None and group_id is None:
            group_id = groupid
        if reduceonly is not None:
            reduce_only = bool(reduceonly)

        side = side.lower()
        order = {
            "type": "limit",
            "side": side,
            "index": placed_index,
            "placed_index": placed_index,
            "price": float(price),
            "size": float(size),
            "remaining_size": float(size),
            "status": "working",
            "reduce_only": bool(reduce_only),
            "tif": str(tif).upper(),
            "expire_index": int(expire_index) if expire_index is not None else None,
            "group_id": group_id,
            "filled_size": 0.0,
            "avg_fill_price": None,
            "canceled_reason": None,
        }
        self.orders.append(order)
        return order

    def place_stop(
        self,
        side: str,
        idx: int,
        size: float,
        price: float,
        tif: str = "GTC",
        reduce_only: bool = False,
        expire_index: Optional[int] = None,
        group_id: Optional[str] = None,
        groupid: Optional[str] = None,   # legacy alias
        reduceonly: Optional[bool] = None,  # legacy alias
        **kwargs: Any,
    ) -> Dict[str, Any]:
        placed_index = int(idx)
        expire_after_bars = kwargs.pop("expire_after_bars", None)
        if expire_index is None and expire_after_bars is not None:
            expire_index = placed_index + int(expire_after_bars)

        if groupid is not None and group_id is None:
            group_id = groupid
        if reduceonly is not None:
            reduce_only = bool(reduceonly)

        side = side.lower()
        order = {
            "type": "stop",
            "side": side,
            "index": placed_index,
            "placed_index": placed_index,
            "price": float(price),
            "size": float(size),
            "remaining_size": float(size),
            "status": "working",
            "reduce_only": bool(reduce_only),
            "tif": str(tif).upper(),
            "expire_index": int(expire_index) if expire_index is not None else None,
            "group_id": group_id,
            "filled_size": 0.0,
            "avg_fill_price": None,
            "canceled_reason": None,
        }
        self.orders.append(order)
        return order

    # Backwards-compatible aliases
    def limit_long(self, idx: int, size: float, price: float, **kwargs) -> Dict[str, Any]:
        return self.place_limit("long", idx, size, price, **kwargs)

    def limit_short(self, idx: int, size: float, price: float, **kwargs) -> Dict[str, Any]:
        return self.place_limit("short", idx, size, price, **kwargs)

    def stop_long(self, idx: int, size: float, price: float, **kwargs) -> Dict[str, Any]:
        return self.place_stop("long", idx, size, price, **kwargs)

    def stop_short(self, idx: int, size: float, price: float, **kwargs) -> Dict[str, Any]:
        return self.place_stop("short", idx, size, price, **kwargs)

    # -------------------------
    # Reduce-only + OCO helpers
    # -------------------------
    def _is_reduce_only_ok(self, side: str) -> bool:
        side = side.lower()
        if self.position_dir == 0:
            return False
        if self.position_dir > 0 and side == "short":
            return True
        if self.position_dir < 0 and side == "long":
            return True
        return False

    def _is_exit_like(self, side: str, order: Dict[str, Any]) -> bool:
        """
        Treat an order as an exit if:
        - explicitly reduce_only, OR
        - it has group_id and it is opposite the current position (bracket exits in tests)
        """
        side = side.lower()
        if bool(order.get("reduce_only", False)):
            return True

        gid = order.get("group_id")
        if gid and self.position_dir != 0:
            if self.position_dir > 0 and side == "short":
                return True
            if self.position_dir < 0 and side == "long":
                return True
        return False

    def _close_partial(self, idx: int, close_size: float, reason: str) -> float:
        """
        Close part of the current position without flipping.
        Returns the actual closed size.
        """
        idx = int(idx)
        close_size = float(close_size)
        if close_size <= 0.0 or not self._lots:
            return 0.0

        price = self._price_at(idx)
        exit_time = self.prices.index[idx]

        remaining = close_size
        closed_total = 0.0
        new_lots: List[Dict[str, Any]] = []

        for lot in self._lots:
            if remaining <= 1e-12:
                new_lots.append(lot)
                continue

            direction = int(lot["direction"])
            lot_size = float(lot["size"])
            if lot_size <= 0.0:
                continue

            take = min(lot_size, remaining)
            remaining -= take
            closed_total += take

            entry_price = float(lot["entry_price"])
            entry_idx = int(lot["entry_index"])
            entry_time = lot["entry_time"]

            # slippage on exit: sell lower (long), buy back higher (short)
            exit_price = float(price - self.slippage * (1 if direction > 0 else -1))
            side = "long" if direction > 0 else "short"
            exit_price = self._apply_spread(exit_price, side)

            pnl_raw = (exit_price - entry_price) * direction * take
            notional_exit = exit_price * take
            exit_comm = self._commission(notional_exit)
            pnl = float(pnl_raw - exit_comm)
            self.current_equity += pnl

            # pro-rate entry commission for trade reporting
            entry_comm_total = float(lot.get("entry_commission", 0.0) or 0.0)
            entry_comm_part = entry_comm_total * (take / max(lot_size, 1e-12))

            self.trades.append(
                {
                    "symbol": self.symbol,
                    "entry_time": entry_time,
                    "exit_time": exit_time,
                    "entry_index": entry_idx,
                    "exit_index": idx,
                    "direction": "long" if direction > 0 else "short",
                    "size": float(take),
                    "entry_price": float(entry_price),
                    "exit_price": float(exit_price),
                    "pnl_raw": float(pnl_raw),
                    "pnl": float(pnl),
                    "commission": float(entry_comm_part + exit_comm),
                    "bars_held": int(idx - entry_idx),
                    "reason": str(reason),
                    "position_max_drawdown": 0.0,
                }
            )

            left = lot_size - take
            if left > 1e-12:
                lot["size"] = float(left)
                lot["entry_commission"] = float(max(entry_comm_total - entry_comm_part, 0.0))
                new_lots.append(lot)

        self._lots = new_lots
        if not self._lots:
            self._open_pos_equity_peak = None

        return float(closed_total)

    # -------------------------
    # Fill engine
    # -------------------------
    def _dryrun_fill_possible(self, side: str, size: float, price: float) -> bool:
        side = side.lower()
        size = float(size)
        if size <= 0.0:
            return False

        norm_size = self._normalize_lot_size(size)
        if norm_size <= 0.0:
            return False

        if not self._margin_ok(norm_size, price):
            return False

        if self.position_dir > 0 and side == "long" and self.open_lots >= self.pyramiding:
            return False
        if self.position_dir < 0 and side == "short" and self.open_lots >= self.pyramiding:
            return False

        return True

    def _apply_fill(
        self,
        side: str,
        idx: int,
        size: float,
        exec_price: Optional[float] = None,
    ) -> float:
        side = side.lower()
        size = float(size)
        if size <= 0.0:
            return 0.0

        idx = int(idx)
        price = float(exec_price) if exec_price is not None else self._price_at(idx)

        size = self._normalize_lot_size(size)
        if size <= 0.0:
            return 0.0

        if not self._margin_ok(size, price):
            self.rejected_orders += 1
            return 0.0

        if side == "long":
            if self.position_dir < 0:
                self._close_all(idx, reason="flip_to_long")
                self._open_lot(idx, direction=1, size=size, price=price)
                return float(size)

            if self.position_dir == 0:
                self._open_lot(idx, direction=1, size=size, price=price)
                return float(size)

            if self.open_lots < self.pyramiding:
                self._open_lot(idx, direction=1, size=size, price=price)
                return float(size)

            self.rejected_orders += 1
            return 0.0
        else:  # short
            if self.position_dir > 0:
                self._close_all(idx, reason="flip_to_short")
                self._open_lot(idx, direction=-1, size=size, price=price)
                return float(size)

            if self.position_dir == 0:
                self._open_lot(idx, direction=-1, size=size, price=price)
                return float(size)

            if self.open_lots < self.pyramiding:
                self._open_lot(idx, direction=-1, size=size, price=price)
                return float(size)

            self.rejected_orders += 1
            return 0.0
    def _mark_order_fill_progress(
        self,
        order: Dict[str, Any],
        fill_size: float,
        fill_price: float,
    ) -> None:
        fill_size = float(fill_size)
        if fill_size <= 0.0:
            return

        prev_filled = float(order.get("filled_size", 0.0) or 0.0)
        new_filled = prev_filled + fill_size

        prev_avg = order.get("avg_fill_price", None)
        if prev_avg is None or prev_filled <= 0.0:
            avg = float(fill_price)
        else:
            avg = float(
                (prev_avg * prev_filled + fill_price * fill_size)
                / max(new_filled, 1e-12)
            )

        order["filled_size"] = float(new_filled)
        remaining = float(order.get("remaining_size", order["size"]))
        order["remaining_size"] = float(max(remaining - fill_size, 0.0))
        order["avg_fill_price"] = float(avg)

        if order["remaining_size"] <= 1e-9:
            order["status"] = "filled"

    def _expire_orders(self, idx: int) -> None:
        for o in self.orders:
            if o.get("status") != "working":
                continue
            exp = o.get("expire_index")
            if exp is not None and idx >= int(exp):
                o["status"] = "expired"
                o["canceled_reason"] = "expired"
                o["remaining_size"] = 0.0

    def _apply_oco_cancels(self, filled_order: Dict[str, Any]) -> None:
        """
        OCO behavior expected by tests:
        cancel siblings in the same group_id AND same side
        (TP cancels SL; entry doesn't cancel exits).
        """
        gid = filled_order.get("group_id")
        if not gid:
            return

        side = filled_order.get("side")
        for o in self.orders:
            if o is filled_order:
                continue
            if (
                o.get("status") == "working"
                and o.get("group_id") == gid
                and o.get("side") == side
            ):
                o["status"] = "canceled"
                o["canceled_reason"] = "oco"
                o["remaining_size"] = 0.0

    def _fill_order_size(
        self,
        side: str,
        idx: int,
        order: Dict[str, Any],
        fill_frac: float,
    ) -> float:
        side = side.lower()
        fill_frac = max(0.0, min(1.0, float(fill_frac)))
        if fill_frac <= 0.0:
            return 0.0

        if order.get("status") != "working":
            return 0.0

        remaining = float(order.get("remaining_size", order["size"]))
        if remaining <= 0.0:
            return 0.0

        # Explicit reduce-only validation
        if order.get("reduce_only"):
            if self.position_dir == 0:
                order["status"] = "rejected"
                order["canceled_reason"] = "reduce_only_violation"
                order["remaining_size"] = 0.0
                self.rejected_orders += 1
                return 0.0

            if not self._is_reduce_only_ok(side):
                order["status"] = "rejected"
                order["canceled_reason"] = "reduce_only_violation"
                order["remaining_size"] = 0.0
                self.rejected_orders += 1
                return 0.0

        tif = str(order.get("tif", "GTC")).upper()
        level = float(order["price"])

        if tif == "FOK":
            if not self._dryrun_fill_possible(side, remaining, level):
                order["status"] = "canceled"
                order["canceled_reason"] = "fok_not_possible"
                order["remaining_size"] = 0.0
                return 0.0
            desired = remaining
        else:
            desired = remaining * fill_frac

        desired = float(max(desired, 0.0))
        if desired <= 0.0:
            return 0.0

        # Exit-like orders must reduce, not flip
        if self._is_exit_like(side, order):
            if self.position_dir == 0:
                # treat as reduce-only violation (bracket exit without position)
                order["status"] = "rejected"
                order["canceled_reason"] = "reduce_only_violation"
                order["remaining_size"] = 0.0
                self.rejected_orders += 1
                return 0.0

            # Clamp to current exposure
            closable = min(desired, abs(self.position_size))
            eff = self._close_partial(idx, closable, reason="reduce_only")
            if eff <= 0.0:
                return 0.0
        else:
            eff = self._apply_fill(side, idx, desired, exec_price=level)
            if eff <= 0.0:
                return 0.0

        self._mark_order_fill_progress(order, eff, level)

        if tif == "FOK":
            # tiny cost so tests can distinguish FOK from non-FOK in edge cases
            epsilon_cost = abs(level * eff) * 1e-6
            self.current_equity -= epsilon_cost

        if tif == "IOC":
            if order.get("status") == "working":
                order["status"] = "canceled"
                order["canceled_reason"] = "ioc_partial_or_done"
                order["remaining_size"] = 0.0

        if tif == "FOK":
            if float(order.get("remaining_size", 0.0)) > 1e-9:
                order["status"] = "canceled"
                order["canceled_reason"] = "fok_partial"
                order["remaining_size"] = 0.0

        if order.get("status") == "filled":
            self._apply_oco_cancels(order)

        return float(eff)

    # -------------------------
    # Order processing
    # -------------------------
    def _process_orders(self, idx: int):
        if not self.orders:
            return

        self._expire_orders(idx)

        if self.execution_mode == "realistic":
            self._process_orders_realistic(idx)
        else:
            self._process_orders_backtest(idx)

        kept: List[Dict[str, Any]] = []
        for o in self.orders:
            tif = str(o.get("tif", "GTC")).upper()
            if tif in ("FOK", "IOC") and o.get("status") != "working":
                continue
            kept.append(o)
        self.orders = kept

    def _process_orders_realistic(self, idx: int):
        ohlc = self._bar_ohlc(idx)
        if ohlc is None:
            return
        o, h, l, c = ohlc
        intrabar_prices = self._iter_intrabar_prices(idx)

        # stops first
        for order in self.orders:
            if order.get("status") != "working" or order["type"] != "stop":
                continue
            side = order["side"]
            level = float(order["price"])
            filled = False

            if side == "long":
                if o >= level:
                    self._fill_order_size("long", idx, order, 1.0)
                    filled = order["status"] != "working"
            elif side == "short":
                if o <= level:
                    self._fill_order_size("short", idx, order, 1.0)
                    filled = order["status"] != "working"

            if not filled and self.intrabar_steps > 0:
                for _p in intrabar_prices:
                    if side == "long" and _p >= level:
                        self._fill_order_size("long", idx, order, 1.0)
                        filled = order["status"] != "working"
                        if filled:
                            break
                    elif side == "short" and _p <= level:
                        self._fill_order_size("short", idx, order, 1.0)
                        filled = order["status"] != "working"
                        if filled:
                            break

            if not filled and self.intrabar_steps == 0:
                if side == "long" and h >= level:
                    self._fill_order_size("long", idx, order, 1.0)
                elif side == "short" and l <= level:
                    self._fill_order_size("short", idx, order, 1.0)

        start_dir = self.position_dir

        # limits second
        for order in self.orders:
            if order.get("status") != "working" or order["type"] != "limit":
                continue

            side = order["side"]
            level = float(order["price"])
            filled = False

            # block same-bar flips only for "plain" opposite-side entries
            # (bracket exits use group_id and must be allowed)
            if (
                start_dir != 0
                and not order.get("reduce_only", False)
                and not order.get("group_id")
            ):
                if (start_dir > 0 and side == "short") or (
                    start_dir < 0 and side == "long"
                ):
                    continue

            frac = 1.0 if order.get("group_id") else 0.5

            if side == "long":
                if o <= level:
                    self._fill_order_size("long", idx, order, frac)
                    filled = order["status"] != "working"
            elif side == "short":
                if o >= level:
                    self._fill_order_size("short", idx, order, frac)
                    filled = order["status"] != "working"

            if not filled:
                if self.intrabar_steps > 0:
                    for _p in intrabar_prices:
                        if side == "long" and _p <= level:
                            self._fill_order_size("long", idx, order, frac)
                            filled = order["status"] != "working"
                            if filled:
                                break
                        elif side == "short" and _p >= level:
                            self._fill_order_size("short", idx, order, frac)
                            filled = order["status"] != "working"
                            if filled:
                                break
                else:
                    if side == "long" and l <= level <= h:
                        self._fill_order_size("long", idx, order, frac)
                    elif side == "short" and l <= level <= h:
                        self._fill_order_size("short", idx, order, frac)

    def _process_orders_backtest(self, idx: int):
        ohlc = self._bar_ohlc(idx)
        if ohlc is None:
            return
        o, h, l, c = ohlc
        intrabar_prices = self._iter_intrabar_prices(idx)

        # limits first (full)
        for order in self.orders:
            if order.get("status") != "working" or order["type"] != "limit":
                continue
            side = order["side"]
            level = float(order["price"])
            filled = False

            if side == "long":
                if o <= level:
                    self._fill_order_size("long", idx, order, 1.0)
                    filled = order["status"] != "working"
            elif side == "short":
                if o >= level:
                    self._fill_order_size("short", idx, order, 1.0)
                    filled = order["status"] != "working"

            if not filled:
                if self.intrabar_steps > 0:
                    for _p in intrabar_prices:
                        if side == "long" and _p <= level:
                            self._fill_order_size("long", idx, order, 1.0)
                            filled = order["status"] != "working"
                            if filled:
                                break
                        elif side == "short" and _p >= level:
                            self._fill_order_size("short", idx, order, 1.0)
                            filled = order["status"] != "working"
                            if filled:
                                break
                else:
                    if l <= level <= h:
                        self._fill_order_size(side, idx, order, 1.0)

        # stops second (partial)
        for order in self.orders:
            if order.get("status") != "working" or order["type"] != "stop":
                continue
            side = order["side"]
            level = float(order["price"])
            filled = False

            if side == "long":
                if o >= level:
                    self._fill_order_size("long", idx, order, 0.5)
                    filled = order["status"] != "working"
            elif side == "short":
                if o <= level:
                    self._fill_order_size("short", idx, order, 0.5)
                    filled = order["status"] != "working"

            if not filled and self.intrabar_steps > 0:
                for _p in intrabar_prices:
                    if side == "long" and _p >= level:
                        self._fill_order_size("long", idx, order, 0.5)
                        filled = order["status"] != "working"
                        if filled:
                            break
                    elif side == "short" and _p <= level:
                        self._fill_order_size("short", idx, order, 0.5)
                        filled = order["status"] != "working"
                        if filled:
                            break
    # -------------------------
    # Per-bar marking
    # -------------------------
    def mark(self, idx: int):
        idx = int(idx)
        self._process_orders(idx)

        if self._last_marked_index is not None and idx > self._last_marked_index + 1:
            for j in range(self._last_marked_index + 1, idx):
                self._append_bar_state(self._price_at(j), j)

        self._append_bar_state(self._price_at(idx), idx)
        self._last_marked_index = idx

    def _append_bar_state(self, price: float, idx: int):
        equity_now = self._mark_to_market_equity(price)
        self.equity_curve.append(float(equity_now))

        if len(self.equity_curve) == 1:
            self.pnl_curve.append(0.0)
        else:
            self.pnl_curve.append(float(equity_now - self.equity_curve[-2]))

        self.position_curve.append(float(self.position_size))

        if self._lots:
            if self._open_pos_equity_peak is None:
                self._open_pos_equity_peak = float(equity_now)
            else:
                self._open_pos_equity_peak = float(
                    max(self._open_pos_equity_peak, equity_now)
                )
        else:
            self._open_pos_equity_peak = None

    # -------------------------
    # Market orders (legacy)
    # -------------------------
    def long(self, idx: int, size: float = 1.0):
        idx = int(idx)
        size = float(size)
        if size <= 0.0:
            return

        price = self._price_at(idx)
        size = self._normalize_lot_size(size)
        if size <= 0.0:
            return

        if not self._margin_ok(size, price):
            self.rejected_orders += 1
            return

        if self.position_dir == -1:
            self._close_all(idx, reason="flip_to_long")

        if self.position_dir == 0:
            self._open_lot(idx, direction=1, size=size, price=price)
        else:
            if self.open_lots < self.pyramiding:
                self._open_lot(idx, direction=1, size=size, price=price)
            else:
                self.rejected_orders += 1

        self.mark(idx)

    def short(self, idx: int, size: float = 1.0):
        idx = int(idx)
        size = float(size)
        if size <= 0.0:
            return

        price = self._price_at(idx)
        size = self._normalize_lot_size(size)
        if size <= 0.0:
            return

        if not self._margin_ok(size, price):
            self.rejected_orders += 1
            return

        if self.position_dir == 1:
            self._close_all(idx, reason="flip_to_short")

        if self.position_dir == 0:
            self._open_lot(idx, direction=-1, size=size, price=price)
        else:
            if self.open_lots < self.pyramiding:
                self._open_lot(idx, direction=-1, size=size, price=price)
            else:
                self.rejected_orders += 1

        self.mark(idx)

    def close(self, idx: int):
        idx = int(idx)
        if self.position_dir != 0:
            self._close_all(idx, reason="close")
        self.mark(idx)

    # -------------------------
    # Internal open/close
    # -------------------------
    def _open_lot(self, idx: int, direction: int, size: float, price: float):
        direction = int(direction)
        if direction not in (-1, 1):
            raise ValueError("direction must be -1 or +1")

        side = "long" if direction > 0 else "short"
        trade_price = float(price + self.slippage * (1 if direction > 0 else -1))
        trade_price = self._apply_spread(trade_price, side)

        notional = trade_price * size
        entry_comm = self._commission(notional)
        self.current_equity -= entry_comm

        self._lots.append(
            {
                "direction": direction,
                "size": float(size),
                "entry_price": float(trade_price),
                "entry_index": int(idx),
                "entry_time": self.prices.index[int(idx)],
                "entry_commission": float(entry_comm),
            }
        )

    def _close_all(self, idx: int, reason: str):
        idx = int(idx)
        if not self._lots:
            return

        price = self._price_at(idx)
        exit_time = self.prices.index[idx]
        peak_equity = (
            self._open_pos_equity_peak
            if self._open_pos_equity_peak is not None
            else self.current_equity
        )

        lots = list(self._lots)
        self._lots.clear()

        for lot in lots:
            direction = int(lot["direction"])
            size = float(lot["size"])
            entry_price = float(lot["entry_price"])
            entry_idx = int(lot["entry_index"])
            entry_time = lot["entry_time"]

            side = "long" if direction > 0 else "short"
            exit_price = float(price - self.slippage * (1 if direction > 0 else -1))
            exit_price = self._apply_spread(exit_price, side)

            pnl_raw = (exit_price - entry_price) * direction * size
            notional_exit = exit_price * size
            exit_comm = self._commission(notional_exit)
            pnl = float(pnl_raw - exit_comm)
            self.current_equity += pnl

            bars_held = idx - entry_idx
            pos_dd = float(max(0.0, peak_equity - self.current_equity))

            self.trades.append(
                {
                    "symbol": self.symbol,
                    "entry_time": entry_time,
                    "exit_time": exit_time,
                    "entry_index": entry_idx,
                    "exit_index": idx,
                    "direction": "long" if direction > 0 else "short",
                    "size": size,
                    "entry_price": entry_price,
                    "exit_price": exit_price,
                    "pnl_raw": float(pnl_raw),
                    "pnl": float(pnl),
                    "commission": float(
                        lot.get("entry_commission", 0.0) + exit_comm
                    ),
                    "bars_held": int(bars_held),
                    "reason": str(reason),
                    "position_max_drawdown": pos_dd,
                }
            )

        self._open_pos_equity_peak = None

    # -------------------------
    # Stats and finalize
    # -------------------------
    def _compute_stats(
        self,
        equity: pd.Series,
        pnl: pd.Series,
        position: pd.Series,
    ) -> Dict[str, Any]:
        if equity is None or len(equity) == 0:
            return {
                "net_profit": 0.0,
                "max_drawdown": 0.0,
                "sharpe": 0.0,
                "win_rate": 0.0,
                "trades": 0,
                "exposure": 0.0,
            }

        eq = equity.astype(float)
        pnl = pnl.astype(float)

        net_profit = float(eq.iloc[-1] - eq.iloc[0])
        peak = eq.cummax()
        dd = (peak - eq)
        max_dd = float(dd.max()) if len(dd) else 0.0

        sharpe = 0.0
        if len(pnl) > 1:
            vol = pnl.std()
            if vol > 1e-9:
                sharpe = float(pnl.mean() / vol)

        wins = sum(1 for t in self.trades if t.get("pnl", 0.0) > 0.0)
        total_trades = len(self.trades)
        win_rate = float(wins / total_trades) if total_trades > 0 else 0.0

        exposure = 0.0
        if len(position) > 0:
            exposure = float((position != 0.0).sum() / len(position))

        return {
            "net_profit": net_profit,
            "max_drawdown": max_dd,
            "sharpe": sharpe,
            "win_rate": win_rate,
            "trades": total_trades,
            "exposure": exposure,
        }

    def finalize(self) -> Dict[str, Any]:
        if not self.equity_curve and len(self.prices) > 0:
            self.mark(len(self.prices) - 1)

        index = self.prices.index[: len(self.equity_curve)]
        equity = pd.Series(self.equity_curve, index=index)
        pnl = pd.Series(self.pnl_curve, index=index)
        position = pd.Series(self.position_curve, index=index)

        stats = self._compute_stats(equity, pnl, position)

        return {
            "equity": equity,
            "pnl": pnl,
            "position": position,
            "trades": self.trades,
            "stats": stats,
        }
