from __future__ import annotations

import enum
import logging
from typing import NamedTuple, cast

import numpy as np
import pandas as pd

from signalab.units import Lot, Units

logger = logging.getLogger(__name__)

# Column indices in the positions matrix
_COL_IS_BUY = 0
_COL_OPEN_ASK = 1
_COL_OPEN_BID = 2
_COL_SIZE = 3
_COL_VALUE = 4
_COL_MARGIN = 5
_N_COLS = 6

NUMPY_THRESHOLD = 20


class Action(enum.Enum):
    """Order side for position operations.

    Attributes
    ----------
    BUY : Action
        Long-side order.
    SELL : Action
        Short-side order.
    """

    BUY = "BUY"
    SELL = "SELL"


class SimpleMarketData(NamedTuple):
    """Minimal market snapshot used by account calculations.

    Attributes
    ----------
    Index : pd.Timestamp
        Market timestamp.
    bid : float
        Bid price.
    ask : float
        Ask price.
    """

    Index: pd.Timestamp
    bid: float
    ask: float


class OpenPosition(NamedTuple):
    """Read-only position view returned by account query APIs.

    Attributes
    ----------
    id : int
        Position identifier.
    is_buy : bool
        ``True`` for buy/long positions.
    open_ask : float
        Ask price at open.
    open_bid : float
        Bid price at open.
    size : float
        Position size in lots.
    value : float
        Notional value in base currency units.
    margin : float
        Margin reserved for this position.
    """

    id: int
    is_buy: bool
    open_ask: float
    open_bid: float
    size: float
    value: float
    margin: float

    @property
    def is_long(self) -> bool:
        """Return whether the position is long.

        Returns
        -------
        bool
            ``True`` when this is a buy position.
        """
        return self.is_buy

    @property
    def is_short(self) -> bool:
        """Return whether the position is short.

        Returns
        -------
        bool
            ``True`` when this is a sell position.
        """
        return not self.is_buy

    @property
    def is_sell(self) -> bool:
        """Alias for :attr:`is_short`.

        Returns
        -------
        bool
            ``True`` when this is a sell position.
        """
        return not self.is_buy


class InssufficientMarginError(RuntimeError):
    """Raised when a new position cannot be opened due to margin limits."""

    pass


class Account:
    """Trading account state and position management for backtests.

    Parameters
    ----------
    initial_balance : float
        Starting account balance.
    leverage : float, default=1.0
        Account leverage used for margin calculations.
    liquidation_level : float, default=0.5
        Margin-level threshold that triggers forced liquidation.
    label : str, default=""
        Account label used for outputs.
    """

    def __init__(
        self,
        initial_balance: float,
        leverage: float = 1.0,
        liquidation_level: float = 0.5,
        label: str = "",
    ):
        """Initialize account state, caches, and record buffers.

        Parameters
        ----------
        initial_balance : float
            Starting account balance.
        leverage : float, default=1.0
            Account leverage used for margin calculations.
        liquidation_level : float, default=0.5
            Margin-level threshold that triggers forced liquidation.
        label : str, default=""
            Account label used for outputs.
        """
        self.initial_balance = initial_balance
        self.balance = initial_balance
        self.leverage = leverage
        self.liquidation_level = liquidation_level
        self.label = label if label else f"Account_{id(self)}"

        self._pos_capacity: int = 16
        self._pos_size: int = 0
        self._pos: np.ndarray = np.empty(
            (self._pos_capacity, _N_COLS), dtype=np.float64
        )
        self._pos_is_buy: np.ndarray = np.empty(self._pos_capacity, dtype=np.bool_)
        self._idx_to_id: np.ndarray = np.empty(self._pos_capacity, dtype=np.int64)
        self._id_to_idx: dict[int, int] = {}
        self._next_id: int = 0

        self._cached_used_margin: float = 0.0

        # Logging statistics
        self._realized_pl: float = 0.0
        self.opened_buy_count: int = 0
        self.opened_sell_count: int = 0
        self.opening_net_impact: float = 0.0
        self.maintain_buy_net_impact: float = 0.0
        self.maintain_sell_net_impact: float = 0.0

        # self.record: dict[str, np.ndarray] = {}
        self._record_timestamps: np.ndarray
        self._record_balance: np.ndarray
        self._record_equity: np.ndarray
        self._record_used_margin: np.ndarray
        self._record_free_margin: np.ndarray
        self._record_floating_pl: np.ndarray
        self._record_realized_pl: np.ndarray
        self._record_open_buy_count: np.ndarray
        self._record_open_sell_count: np.ndarray
        self._record_opening_cost: np.ndarray
        self._record_maintain_buy_cost: np.ndarray
        self._record_maintain_sell_cost: np.ndarray
        self._record_real_profit: np.ndarray

        self._state_version: int = 0
        self._pl_cache_key: tuple[int, pd.Timestamp] | None = None
        self._pl_cache_value: float = 0.0
        self._current_market: SimpleMarketData | None = None

    def _set_current_market(self, market: SimpleMarketData) -> None:
        """Set the current market snapshot used by account operations."""
        self._current_market = market

    def _require_current_market(self) -> SimpleMarketData:
        """Return current market snapshot or raise when unset.

        Returns
        -------
        SimpleMarketData
            Current market snapshot bound to the account.

        Raises
        ------
        RuntimeError
            If no current market has been set.
        """
        if self._current_market is None:
            raise RuntimeError(
                "Current market is not set. The engine must call _set_current_market() before account operations."
            )
        return self._current_market

    def _invalidate_market_cache(self) -> None:
        """Invalidate cached floating P/L values after state changes."""
        self._state_version += 1

    def _compute_floating_pl_raw(self) -> float:
        """Compute floating P/L without using cache.

        Parameters
        ----------
        market : SimpleMarketData
            Current market snapshot.

        Returns
        -------
        float
            Total floating P/L across open positions.
        """
        if self._pos_size == 0:
            return 0.0
        market = self._require_current_market()
        bid = market.bid
        ask = market.ask
        pos = self._pos[: self._pos_size]
        is_buy = self._pos_is_buy[: self._pos_size]
        open_asks = pos[:, _COL_OPEN_ASK]
        open_bids = pos[:, _COL_OPEN_BID]
        values = pos[:, _COL_VALUE]
        pls = np.where(is_buy, (bid - open_asks) * values, (open_bids - ask) * values)
        return float(pls.sum())

    def _ensure_position_capacity(self, min_capacity: int) -> None:
        """Ensure internal position arrays can hold ``min_capacity`` entries.

        Parameters
        ----------
        min_capacity : int
            Required minimum capacity.
        """
        if min_capacity <= self._pos_capacity:
            return

        new_capacity = self._pos_capacity
        while new_capacity < min_capacity:
            new_capacity *= 2

        new_pos = np.empty((new_capacity, _N_COLS), dtype=np.float64)
        if self._pos_size > 0:
            new_pos[: self._pos_size] = self._pos[: self._pos_size]
        self._pos = new_pos

        new_pos_is_buy = np.empty(new_capacity, dtype=np.bool_)
        if self._pos_size > 0:
            new_pos_is_buy[: self._pos_size] = self._pos_is_buy[: self._pos_size]
        self._pos_is_buy = new_pos_is_buy

        new_idx_to_id = np.empty(new_capacity, dtype=np.int64)
        if self._pos_size > 0:
            new_idx_to_id[: self._pos_size] = self._idx_to_id[: self._pos_size]
        self._idx_to_id = new_idx_to_id
        self._pos_capacity = new_capacity

    def init_record(self, market_data: pd.DataFrame) -> None:
        """Initialize per-tick record arrays based on market length.

        Parameters
        ----------
        market_data : pd.DataFrame
            Backtest market dataframe used to size the record buffers.
        """
        length = len(market_data)
        self._record_timestamps = market_data.index.to_numpy()
        self._record_balance = np.zeros(length, dtype=np.float64)
        self._record_equity = np.zeros(length, dtype=np.float64)
        self._record_used_margin = np.zeros(length, dtype=np.float64)
        self._record_free_margin = np.zeros(length, dtype=np.float64)
        self._record_floating_pl = np.zeros(length, dtype=np.float64)
        self._record_realized_pl = np.zeros(length, dtype=np.float64)
        self._record_open_buy_count = np.zeros(length, dtype=np.float64)
        self._record_open_sell_count = np.zeros(length, dtype=np.float64)
        self._record_opening_cost = np.zeros(length, dtype=np.float64)
        self._record_maintain_buy_cost = np.zeros(length, dtype=np.float64)
        self._record_maintain_sell_cost = np.zeros(length, dtype=np.float64)
        self._record_real_profit = np.zeros(length, dtype=np.float64)

    def get_record_df(self) -> pd.DataFrame:
        """Materialize account records as a dataframe.

        Returns
        -------
        pd.DataFrame
            Account metrics indexed by timestamp.
        """
        record_df = pd.DataFrame(
            {
                "timestamp": self._record_timestamps,
                "balance": self._record_balance,
                "equity": self._record_equity,
                "used_margin": self._record_used_margin,
                "free_margin": self._record_free_margin,
                "floating_pl": self._record_floating_pl,
                "realized_pl": self._record_realized_pl,
                "open_buy_count": self._record_open_buy_count,
                "open_sell_count": self._record_open_sell_count,
                "opening_net_impact": self._record_opening_cost,
                "maintain_buy_net_impact": self._record_maintain_buy_cost,
                "maintain_sell_net_impact": self._record_maintain_sell_cost,
                "real_profit": self._record_real_profit,
            }
        ).set_index("timestamp")

        return record_df

    def floating_pl(self) -> float:
        """Return total floating P/L for open positions.

        Returns
        -------
        float
            Total floating P/L.
        """
        market = self._require_current_market()
        cache_key = (self._state_version, market.Index)
        if self._pl_cache_key == cache_key:
            return self._pl_cache_value

        value = self._compute_floating_pl_raw()
        self._pl_cache_key = cache_key
        self._pl_cache_value = value
        return value

    def positions_unified_size(self) -> float:
        """Return total open position size in lots.

        Returns
        -------
        float
            Sum of open position sizes.
        """
        if self._pos_size == 0:
            return 0.0
        sizes = self._pos[: self._pos_size, _COL_SIZE]
        return float(sizes.sum())

    def used_margin(self) -> float:
        """Return current used margin.

        Returns
        -------
        float
            Margin reserved by open positions.
        """
        return self._cached_used_margin

    def equity(self) -> float:
        """Return account equity at current market prices.

        Returns
        -------
        float
            ``balance + floating_pl``.
        """
        return self.balance + self.floating_pl()

    def free_margin(self) -> float:
        """Return free margin at current market prices.

        Returns
        -------
        float
            ``equity - used_margin``.
        """
        return self.equity() - self._cached_used_margin

    def realized_pl(self) -> float:
        """Return cumulative realized profit and loss.

        Returns
        -------
        float
            Realized P/L.
        """
        return self._realized_pl

    def open(
        self,
        action: Action,
        size: Lot,
        leverage: float | None = None,
    ) -> int:
        """Open a new position.

        Parameters
        ----------
        action : Action
            Position side.
        size : Lot
            Position size in lots.
        leverage : float | None, default=None
            Optional leverage override for this order.

        Returns
        -------
        int
            Newly assigned position id.

        Raises
        ------
        InssufficientMarginError
            If required margin exceeds current free margin.
        """
        market_at_open = self._require_current_market()
        lev = leverage or self.leverage
        value = size * Units.LOT_SIZE
        margin = value / lev

        if margin > self.free_margin():
            raise InssufficientMarginError(
                f"Cannot open position: required margin {margin:.2f} exceeds"
                f" free margin {self.free_margin():.2f}. You can"
                "check free margin with account.free_margin()."
            )

        is_buy = 1.0 if action == Action.BUY else 0.0
        row = np.array(
            [[is_buy, market_at_open.ask, market_at_open.bid, size, value, margin]]
        )
        self._ensure_position_capacity(self._pos_size + 1)

        self._pos[self._pos_size] = row[0]
        self._pos_is_buy[self._pos_size] = action == Action.BUY
        pos_id = self._next_id
        self._next_id += 1
        self._idx_to_id[self._pos_size] = pos_id
        self._id_to_idx[pos_id] = self._pos_size
        self._pos_size += 1

        # Update cached margin
        self._cached_used_margin += margin

        # Apply commission
        commission = Units.open_commission(size)
        self.balance += commission
        self.opening_net_impact += commission

        if action == Action.BUY:
            self.opened_buy_count += 1
        else:
            self.opened_sell_count += 1

        self._invalidate_market_cache()

        return pos_id

    def close(self, pos_id: int) -> bool:
        """Close one open position if it exists.

        Parameters
        ----------
        pos_id : int
            Position identifier.
        Returns
        -------
        bool
            ``True`` if the position existed and was closed, otherwise ``False``.
        """
        market_at_close = self._require_current_market()
        idx = self._id_to_idx.get(pos_id)
        if idx is None:
            return False

        row = self._pos[idx]

        is_buy = bool(self._pos_is_buy[idx])
        open_ask = row[_COL_OPEN_ASK]
        open_bid = row[_COL_OPEN_BID]
        value = row[_COL_VALUE]
        margin = row[_COL_MARGIN]

        if is_buy:
            pl = (market_at_close.bid - open_ask) * value
        else:
            pl = (open_bid - market_at_close.ask) * value

        self.balance += pl
        self._realized_pl += pl
        self._cached_used_margin -= margin

        # Remove in O(1): move last active row into removed slot.
        last_idx = self._pos_size - 1
        self._id_to_idx.pop(pos_id)
        if idx != last_idx:
            self._pos[idx] = self._pos[last_idx]
            self._pos_is_buy[idx] = self._pos_is_buy[last_idx]
            moved_id = int(self._idx_to_id[last_idx])
            self._idx_to_id[idx] = moved_id
            self._id_to_idx[moved_id] = idx
        self._pos_size -= 1
        self._invalidate_market_cache()
        return True

    def close_all_positions(self) -> None:
        """Close all open positions at the provided market snapshot."""
        market = self._require_current_market()
        # Vectorized P&L for all positions at once
        if self._pos_size == 0:
            return

        pos = self._pos[: self._pos_size]
        is_buy = self._pos_is_buy[: self._pos_size]
        open_asks = pos[:, _COL_OPEN_ASK]
        open_bids = pos[:, _COL_OPEN_BID]
        values = pos[:, _COL_VALUE]

        pls = np.where(
            is_buy,
            (market.bid - open_asks) * values,
            (open_bids - market.ask) * values,
        )

        total_pl = float(pls.sum())
        self.balance += total_pl
        self._realized_pl += total_pl
        self._cached_used_margin = 0.0
        self._pos_size = 0
        self._id_to_idx.clear()
        self._invalidate_market_cache()

    def check_liquidation(self) -> None:
        """Apply liquidation rule if margin level is below threshold."""
        if self._cached_used_margin == 0:
            return
        margin_level = self.equity() / self._cached_used_margin
        if margin_level < self.liquidation_level:
            self.close_all_positions()

    def day_change(self, delta: int) -> None:
        """Apply daily carry/swap impact for open positions.

        Parameters
        ----------
        delta : int
            Number of elapsed days to apply.
        """
        if self._pos_size == 0:
            return

        pos = self._pos[: self._pos_size]
        is_buy = self._pos_is_buy[: self._pos_size]
        sizes = pos[:, _COL_SIZE]

        buy_delta = Units.maintain_buy_commission(float((sizes[is_buy]).sum())) * delta
        sell_delta = (
            Units.maintain_sell_commission(float((sizes[~is_buy]).sum())) * delta
        )

        self.balance += buy_delta + sell_delta
        self.maintain_buy_net_impact += buy_delta
        self.maintain_sell_net_impact += sell_delta
        self._invalidate_market_cache()

    @property
    def open_position_count(self) -> int:
        """Return number of currently open positions.

        Returns
        -------
        int
            Count of active positions.
        """
        return self._pos_size

    def position_pls(self) -> dict[int, float]:
        """Return floating P/L per open position.

        Returns
        -------
        dict[int, float]
            Mapping from position id to current floating P/L value.
        """
        if self._pos_size == 0:
            return {}

        market = self._require_current_market()

        active_ids = self._idx_to_id[: self._pos_size]
        pos = self._pos[: self._pos_size]

        if self._pos_size >= NUMPY_THRESHOLD:
            is_buy = self._pos_is_buy[: self._pos_size]
            values = pos[:, _COL_VALUE]
            pls = np.where(
                is_buy,
                (market.bid - pos[:, _COL_OPEN_ASK]) * values,
                (pos[:, _COL_OPEN_BID] - market.ask) * values,
            )
            return dict(zip(active_ids.tolist(), pls.tolist(), strict=True))

        bid = market.bid
        ask = market.ask
        result = {}
        for i in range(self._pos_size):
            pos_id = int(active_ids[i])
            row = pos[i]
            if self._pos_is_buy[i]:
                result[pos_id] = (bid - row[_COL_OPEN_ASK]) * row[_COL_VALUE]
            else:
                result[pos_id] = (row[_COL_OPEN_BID] - ask) * row[_COL_VALUE]
        return result

    def position_size(self, pos_id: int) -> float:
        """Return size for one open position.

        Parameters
        ----------
        pos_id : int
            Position identifier.

        Returns
        -------
        float
            Position size in lots.

        Raises
        ------
        ValueError
            If ``pos_id`` is not found.
        """
        idx = self._id_to_idx.get(pos_id)
        if idx is None:
            raise ValueError(f"Position ID {pos_id} not found.")
        return cast(float, self._pos[idx, _COL_SIZE])

    def get_open_position(self, pos_id: int) -> OpenPosition:
        """Return full details for one open position.

        Parameters
        ----------
        pos_id : int
            Position identifier.

        Returns
        -------
        OpenPosition
            Position data snapshot.

        Raises
        ------
        ValueError
            If ``pos_id`` is not found.
        """
        idx = self._id_to_idx.get(pos_id)
        if idx is None:
            raise ValueError(f"Position ID {pos_id} not found.")
        row = self._pos[idx]
        return OpenPosition(
            id=pos_id,
            is_buy=bool(self._pos_is_buy[idx]),
            open_ask=cast(float, row[_COL_OPEN_ASK]),
            open_bid=cast(float, row[_COL_OPEN_BID]),
            size=cast(float, row[_COL_SIZE]),
            value=cast(float, row[_COL_VALUE]),
            margin=cast(float, row[_COL_MARGIN]),
        )

    def _normalize_position_ids(self, ids: int | list[int]) -> list[int]:
        """Normalize one-or-many position ids into a list.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        list[int]
            Normalized id list.

        Raises
        ------
        ValueError
            If an empty list is provided.
        """
        if isinstance(ids, int):
            return [ids]
        if len(ids) == 0:
            raise ValueError("ids must contain at least one position id")
        return ids

    def _indices_for_position_ids(
        self, ids: int | list[int]
    ) -> tuple[list[int], np.ndarray]:
        """Resolve position ids to internal row indices.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        tuple[list[int], np.ndarray]
            Normalized ids and corresponding row indices.

        Raises
        ------
        ValueError
            If at least one id is not found.
        """
        normalized_ids = self._normalize_position_ids(ids)
        n_ids = len(normalized_ids)
        indices = np.empty(n_ids, dtype=np.int64)
        missing: list[int] = []

        for i, pos_id in enumerate(normalized_ids):
            idx = self._id_to_idx.get(pos_id)
            if idx is None:
                missing.append(pos_id)
            else:
                indices[i] = idx

        if missing:
            raise ValueError(f"Position IDs not found: {missing}")

        return normalized_ids, indices

    def position_directions(self, ids: int | list[int]) -> np.ndarray:
        """Return buy/sell direction flags for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Boolean array where ``True`` means buy.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos_is_buy[indices])

    def position_open_asks(self, ids: int | list[int]) -> np.ndarray:
        """Return open ask prices for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Array of open ask prices.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos[indices, _COL_OPEN_ASK])

    def position_open_bids(self, ids: int | list[int]) -> np.ndarray:
        """Return open bid prices for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Array of open bid prices.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos[indices, _COL_OPEN_BID])

    def position_sizes(self, ids: int | list[int]) -> np.ndarray:
        """Return lot sizes for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Array of position sizes.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos[indices, _COL_SIZE])

    def position_values(self, ids: int | list[int]) -> np.ndarray:
        """Return notional values for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Array of position notionals.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos[indices, _COL_VALUE])

    def position_margins(self, ids: int | list[int]) -> np.ndarray:
        """Return margin values for selected positions.

        Parameters
        ----------
        ids : int | list[int]
            Position id input.

        Returns
        -------
        np.ndarray
            Array of position margins.
        """
        _, indices = self._indices_for_position_ids(ids)
        return cast(np.ndarray, self._pos[indices, _COL_MARGIN])

    def _batch_select_indices(
        self,
        ids: list[int] | None = None,
        is_buy: bool | None = None,
        min_size: float | None = None,
        max_size: float | None = None,
        min_margin: float | None = None,
        max_margin: float | None = None,
        min_value: float | None = None,
        max_value: float | None = None,
        limit: int | None = None,
    ) -> np.ndarray:
        """Select open-position row indices by filter criteria.

        Parameters
        ----------
        ids : list[int] | None, default=None
            Optional explicit id filter.
        is_buy : bool | None, default=None
            Optional side filter.
        min_size, max_size : float | None
            Optional size bounds.
        min_margin, max_margin : float | None
            Optional margin bounds.
        min_value, max_value : float | None
            Optional notional bounds.
        limit : int | None, default=None
            Optional maximum number of returned indices.

        Returns
        -------
        np.ndarray
            Matching row indices.

        Raises
        ------
        ValueError
            If ``limit`` is negative.
        """
        if self._pos_size == 0:
            return np.empty(0, dtype=np.int64)

        mask = np.ones(self._pos_size, dtype=np.bool_)
        pos = self._pos[: self._pos_size]

        if ids is not None:
            id_set = set(ids)
            active_ids = self._idx_to_id[: self._pos_size]
            id_mask = np.fromiter(
                (int(pid) in id_set for pid in active_ids), dtype=np.bool_
            )
            mask &= id_mask

        if is_buy is not None:
            mask &= self._pos_is_buy[: self._pos_size] == is_buy

        if min_size is not None:
            mask &= pos[:, _COL_SIZE] >= min_size
        if max_size is not None:
            mask &= pos[:, _COL_SIZE] <= max_size

        if min_margin is not None:
            mask &= pos[:, _COL_MARGIN] >= min_margin
        if max_margin is not None:
            mask &= pos[:, _COL_MARGIN] <= max_margin

        if min_value is not None:
            mask &= pos[:, _COL_VALUE] >= min_value
        if max_value is not None:
            mask &= pos[:, _COL_VALUE] <= max_value

        indices = np.flatnonzero(mask)
        if limit is not None:
            if limit < 0:
                raise ValueError("limit must be non-negative or None")
            indices = indices[:limit]
        return cast(np.ndarray, indices)

    def get_open_positions(
        self,
        *,
        ids: list[int] | None = None,
        is_buy: bool | None = None,
        min_size: float | None = None,
        max_size: float | None = None,
        min_margin: float | None = None,
        max_margin: float | None = None,
        min_value: float | None = None,
        max_value: float | None = None,
        limit: int | None = None,
    ) -> list[OpenPosition]:
        """Return open positions optionally filtered by query arguments.

        Parameters
        ----------
        ids : list[int] | None, default=None
            Optional explicit id filter.
        is_buy : bool | None, default=None
            Optional side filter.
        min_size, max_size : float | None
            Optional size bounds.
        min_margin, max_margin : float | None
            Optional margin bounds.
        min_value, max_value : float | None
            Optional notional bounds.
        limit : int | None, default=None
            Optional maximum number of returned positions.

        Returns
        -------
        list[OpenPosition]
            Position snapshots matching the filter.
        """
        indices = self._batch_select_indices(
            ids=ids,
            is_buy=is_buy,
            min_size=min_size,
            max_size=max_size,
            min_margin=min_margin,
            max_margin=max_margin,
            min_value=min_value,
            max_value=max_value,
            limit=limit,
        )
        if len(indices) == 0:
            return []

        active_ids = self._idx_to_id
        active_pos = self._pos
        active_is_buy = self._pos_is_buy

        return [
            OpenPosition(
                id=int(active_ids[i]),
                is_buy=bool(active_is_buy[i]),
                open_ask=cast(float, active_pos[i, _COL_OPEN_ASK]),
                open_bid=cast(float, active_pos[i, _COL_OPEN_BID]),
                size=cast(float, active_pos[i, _COL_SIZE]),
                value=cast(float, active_pos[i, _COL_VALUE]),
                margin=cast(float, active_pos[i, _COL_MARGIN]),
            )
            for i in indices
        ]

    def batch_get_open_positions(
        self,
        *,
        ids: list[int] | None = None,
        is_buy: bool | None = None,
        min_size: float | None = None,
        max_size: float | None = None,
        min_margin: float | None = None,
        max_margin: float | None = None,
        min_value: float | None = None,
        max_value: float | None = None,
        limit: int | None = None,
    ) -> list[OpenPosition]:
        """Compatibility alias for :meth:`get_open_positions`.

        Parameters
        ----------
        ids : list[int] | None, default=None
            Optional explicit id filter.
        is_buy : bool | None, default=None
            Optional side filter.
        min_size, max_size : float | None
            Optional size bounds.
        min_margin, max_margin : float | None
            Optional margin bounds.
        min_value, max_value : float | None
            Optional notional bounds.
        limit : int | None, default=None
            Optional maximum number of returned positions.

        Returns
        -------
        list[OpenPosition]
            Position snapshots matching the filter.
        """
        return self.get_open_positions(
            ids=ids,
            is_buy=is_buy,
            min_size=min_size,
            max_size=max_size,
            min_margin=min_margin,
            max_margin=max_margin,
            min_value=min_value,
            max_value=max_value,
            limit=limit,
        )

    def batch_close(
        self,
        *,
        ids: list[int] | None = None,
        is_buy: bool | None = None,
        min_size: float | None = None,
        max_size: float | None = None,
        min_margin: float | None = None,
        max_margin: float | None = None,
        min_value: float | None = None,
        max_value: float | None = None,
        limit: int | None = None,
    ) -> list[int]:
        """Close positions selected by filter criteria.

        Parameters
        ----------
        ids : list[int] | None, default=None
            Optional explicit id filter.
        is_buy : bool | None, default=None
            Optional side filter.
        min_size, max_size : float | None
            Optional size bounds.
        min_margin, max_margin : float | None
            Optional margin bounds.
        min_value, max_value : float | None
            Optional notional bounds.
        limit : int | None, default=None
            Optional maximum number of closes.

        Returns
        -------
        list[int]
            Closed position ids.
        """
        selected = self.get_open_positions(
            ids=ids,
            is_buy=is_buy,
            min_size=min_size,
            max_size=max_size,
            min_margin=min_margin,
            max_margin=max_margin,
            min_value=min_value,
            max_value=max_value,
            limit=limit,
        )

        closed_ids: list[int] = []
        for position in selected:
            if self.close(position.id):
                closed_ids.append(position.id)
        return closed_ids

    def get_open_position_ids(self) -> np.ndarray:
        """Return ids of currently open positions.

        Returns
        -------
        np.ndarray
            Open position ids.
        """
        return self._idx_to_id[: self._pos_size]

    def update_record(self, index: int) -> None:
        """Write current account metrics into record buffers.

        Parameters
        ----------
        index : int
            Record row index.
        Raises
        ------
        RuntimeError
            If record buffers were not initialized via :meth:`init_record`.
        """
        try:
            self._record_balance[index] = self.balance
            self._record_equity[index] = self.equity()
            self._record_used_margin[index] = self._cached_used_margin
            self._record_free_margin[index] = self.free_margin()
            self._record_floating_pl[index] = self.floating_pl()
            self._record_realized_pl[index] = self._realized_pl
            self._record_open_buy_count[index] = float(self.opened_buy_count)
            self._record_open_sell_count[index] = float(self.opened_sell_count)
            self._record_opening_cost[index] = self.opening_net_impact
            self._record_maintain_buy_cost[index] = self.maintain_buy_net_impact
            self._record_maintain_sell_cost[index] = self.maintain_sell_net_impact
            self._record_real_profit[index] = (
                self._realized_pl
                + self.opening_net_impact
                + self.maintain_buy_net_impact
                + self.maintain_sell_net_impact
            )
        except AttributeError as err:
            raise RuntimeError(
                "Record arrays not initialized. Call init_record() before updating record."
            ) from err

    def copy_empty(self) -> Account:
        """Create a new empty account with copied risk configuration.

        Returns
        -------
        Account
            New account instance with current balance and risk settings.
        """
        return Account(
            initial_balance=self.balance,
            leverage=self.leverage,
            liquidation_level=self.liquidation_level,
        )
