# Trading Module

## mt5cli.trading

Trading-capable MetaTrader 5 session helpers and operational utilities.

### ExecutionStatus `module-attribute`

    ExecutionStatus = Literal[
        "executed", "dry_run", "skipped", "failed"
    ]

### OrderFillingMode `module-attribute`

    OrderFillingMode = Literal['IOC', 'FOK', 'RETURN']

### OrderSide `module-attribute`

    OrderSide = Literal['BUY', 'SELL']

### OrderTimeMode `module-attribute`

    OrderTimeMode = Literal[
        "GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"
    ]

### POSITION_COLUMNS `module-attribute`

    POSITION_COLUMNS = (
        "ticket",
        "time",
        "symbol",
        "type",
        "volume",
        "price_open",
        "sl",
        "tp",
        "price_current",
        "profit",
        "swap",
        "comment",
    )

### PositionSide `module-attribute`

    PositionSide = Literal['long', 'short']

### ProjectionMode `module-attribute`

    ProjectionMode = Literal['add', 'replace_symbol']

### __all__ `module-attribute`

    __all__ = [
        "POSITION_COLUMNS",
        "ExecutionStatus",
        "MarginVolume",
        "OrderExecutionResult",
        "OrderFillingMode",
        "OrderLimits",
        "OrderSide",
        "OrderTimeMode",
        "PositionSide",
        "ProjectionMode",
        "calculate_account_projected_margin_ratio",
        "calculate_margin_and_volume",
        "calculate_new_position_margin_ratio",
        "calculate_positions_margin",
        "calculate_positions_margin_by_symbol",
        "calculate_positions_margin_safe",
        "calculate_projected_margin_ratio",
        "calculate_spread_ratio",
        "calculate_symbol_group_margin_ratio",
        "calculate_trailing_stop_updates",
        "calculate_volume_by_margin",
        "close_open_positions",
        "create_trading_client",
        "detect_position_side",
        "determine_order_limits",
        "ensure_symbol_selected",
        "estimate_order_margin",
        "extract_tick_price",
        "fetch_latest_closed_rates_for_trading_client",
        "fetch_latest_closed_rates_indexed",
        "get_account_snapshot",
        "get_positions_frame",
        "get_symbol_snapshot",
        "get_tick_snapshot",
        "mt5_trading_session",
        "normalize_order_volume",
        "place_market_order",
        "update_sltp_for_open_positions",
        "update_trailing_stop_loss_for_open_positions",
    ]

### MarginVolume

Bases: `TypedDict`

Affordable volume bounds derived from account margin and symbol constraints.

#### available_margin `instance-attribute`

    available_margin: float

#### buy_volume `instance-attribute`

    buy_volume: float

#### margin_free `instance-attribute`

    margin_free: float

#### sell_volume `instance-attribute`

    sell_volume: float

#### trade_margin `instance-attribute`

    trade_margin: float

#### volume_max `instance-attribute`

    volume_max: float

#### volume_min `instance-attribute`

    volume_min: float

#### volume_step `instance-attribute`

    volume_step: float

### OrderExecutionResult

Bases: `TypedDict`

Normalized result from market-order and position-management helpers.

#### comment `instance-attribute`

    comment: str | None

#### dry_run `instance-attribute`

    dry_run: bool

#### order_side `instance-attribute`

    order_side: OrderSide

#### request `instance-attribute`

    request: dict[str, object]

#### response `instance-attribute`

    response: dict[str, object] | None

#### retcode `instance-attribute`

    retcode: int | None

#### status `instance-attribute`

    status: ExecutionStatus

#### symbol `instance-attribute`

    symbol: str

#### volume `instance-attribute`

    volume: float

### OrderLimits

Bases: `TypedDict`

Protective order prices derived from current quotes and ratio parameters.

#### entry `instance-attribute`

    entry: float

#### stop_loss `instance-attribute`

    stop_loss: float | None

#### take_profit `instance-attribute`

    take_profit: float | None

### calculate_account_projected_margin_ratio

    calculate_account_projected_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str | None = None,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float

Return account-wide current plus optional new-position margin over equity.

Current exposure comes from the broker account snapshot `margin` field so unrelated open positions remain in the baseline. Optional projected exposure is added via :func:`estimate_order_margin` only when a symbol, side, and positive volume are all supplied.

Source code in `mt5cli/trading.py`

    931
    932
    933
    934
    935
    936
    937
    938
    939
    940
    941
    942
    943
    944
    945
    946
    947
    948
    949
    950
    951
    952
    953
    954
    955
    956
    def calculate_account_projected_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str | None = None,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float:
        """Return account-wide current plus optional new-position margin over equity.

        Current exposure comes from the broker account snapshot ``margin`` field so
        unrelated open positions remain in the baseline. Optional projected
        exposure is added via :func:`estimate_order_margin` only when a symbol, side,
        and positive volume are all supplied.

        """
        account = get_account_snapshot(client)
        equity = _required_account_number(account, "equity", allow_zero=False)
        margin = _required_account_number(account, "margin", allow_zero=True)
        if symbol is not None and new_position_side is not None and new_position_volume > 0:
            margin += estimate_order_margin(
                client,
                symbol,
                new_position_side,
                new_position_volume,
            )
        return margin / equity

### calculate_margin_and_volume

    calculate_margin_and_volume(
        client: _Mt5ClientProtocol,
        symbol: str,
        unit_margin_ratio: float,
        preserved_margin_ratio: float,
    ) -> MarginVolume

Calculate tradable margin and volumes from account free margin.

Applies `preserved_margin_ratio` to keep a reserve off `margin_free`, then allocates `unit_margin_ratio` of the remainder as the margin budget for proportional volume sizing on both buy and sell sides. A `unit_margin_ratio` of `0` requests exactly one minimum valid unit per side when the post-reserve margin can afford it.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbol` | `str` | Symbol used for minimum-lot margin and volume calculations. | required
`unit_margin_ratio` | `float` | Fraction of post-reserve margin to allocate per unit. | required
`preserved_margin_ratio` | `float` | Fraction of `margin_free` to preserve. | required

Returns:

Type | Description
--- | ---
`MarginVolume` | Dictionary with `margin_free`, `available_margin`, `trade_margin`,
`MarginVolume` | `buy_volume`, and `sell_volume`. Negative `margin_free` values are
`MarginVolume` | clamped to `0.0` before sizing.

Source code in `mt5cli/trading.py`

    1064
    1065
    1066
    1067
    1068
    1069
    1070
    1071
    1072
    1073
    1074
    1075
    1076
    1077
    1078
    1079
    1080
    1081
    1082
    1083
    1084
    1085
    1086
    1087
    1088
    1089
    1090
    1091
    1092
    1093
    1094
    1095
    1096
    1097
    1098
    1099
    1100
    1101
    1102
    1103
    1104
    1105
    1106
    1107
    1108
    1109
    1110
    1111
    1112
    1113
    1114
    1115
    1116
    1117
    1118
    1119
    1120
    1121
    1122
    1123
    1124
    1125
    1126
    1127
    1128
    def calculate_margin_and_volume(
        client: _Mt5ClientProtocol,
        symbol: str,
        unit_margin_ratio: float,
        preserved_margin_ratio: float,
    ) -> MarginVolume:
        """Calculate tradable margin and volumes from account free margin.

        Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
        then allocates ``unit_margin_ratio`` of the remainder as the margin budget
        for proportional volume sizing on both buy and sell sides. A
        ``unit_margin_ratio`` of ``0`` requests exactly one minimum valid unit per
        side when the post-reserve margin can afford it.

        Args:
            client: Connected MT5 client instance.
            symbol: Symbol used for minimum-lot margin and volume calculations.
            unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
            preserved_margin_ratio: Fraction of ``margin_free`` to preserve.

        Returns:
            Dictionary with ``margin_free``, ``available_margin``, ``trade_margin``,
            ``buy_volume``, and ``sell_volume``. Negative ``margin_free`` values are
            clamped to ``0.0`` before sizing.
        """
        _require_unit_ratio(unit_margin_ratio, "unit_margin_ratio")
        _require_unit_ratio(preserved_margin_ratio, "preserved_margin_ratio")

        account = client.account_info_as_dict()
        margin_free = max(0.0, float(account.get("margin_free") or 0.0))
        available_margin = margin_free * (1.0 - preserved_margin_ratio)
        trade_margin = available_margin * unit_margin_ratio
        if unit_margin_ratio == 0:
            buy_volume = _calculate_min_volume_if_affordable(
                client,
                symbol,
                available_margin,
                "BUY",
            )
            sell_volume = _calculate_min_volume_if_affordable(
                client,
                symbol,
                available_margin,
                "SELL",
            )
        else:
            buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
            sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
        try:
            symbol_info = get_symbol_snapshot(client, symbol)
            volume_min = float(symbol_info.get("volume_min") or 0.0)
            volume_max = float(symbol_info.get("volume_max") or 0.0)
            volume_step = float(symbol_info.get("volume_step") or 0.0)
        except AttributeError:
            volume_min = volume_max = volume_step = 0.0
        return {
            "margin_free": margin_free,
            "available_margin": available_margin,
            "trade_margin": trade_margin,
            "buy_volume": float(buy_volume),
            "sell_volume": float(sell_volume),
            "volume_min": volume_min,
            "volume_max": volume_max,
            "volume_step": volume_step,
        }

### calculate_new_position_margin_ratio

    calculate_new_position_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float

Return total margin/equity ratio after an optional hypothetical position.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If equity or required tick data is invalid.

Source code in `mt5cli/trading.py`

    866
    867
    868
    869
    870
    871
    872
    873
    874
    875
    876
    877
    878
    879
    880
    881
    882
    883
    884
    885
    886
    887
    888
    889
    890
    891
    892
    893
    894
    895
    896
    897
    898
    def calculate_new_position_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float:
        """Return total margin/equity ratio after an optional hypothetical position.

        Raises:
            Mt5OperationError: If equity or required tick data is invalid.
        """
        account = get_account_snapshot(client)
        equity = float(account.get("equity") or 0.0)
        if equity <= 0:
            msg = "Account equity must be positive to calculate margin ratio."
            raise Mt5OperationError(msg)
        margin = float(account.get("margin") or 0.0)
        if new_position_side is not None and new_position_volume > 0:
            side = _normalize_order_side(new_position_side)
            price = extract_tick_price(
                get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
            )
            if price is None:
                msg = f"Tick price is unavailable for {symbol!r}."
                raise Mt5OperationError(msg)
            order_type = (
                client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
            )
            margin += float(
                client.order_calc_margin(order_type, symbol, new_position_volume, price),
            )
        return margin / equity

### calculate_positions_margin

    calculate_positions_margin(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str] | None = None,
    ) -> float

Return the sum of estimated current margin for open positions.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbols` | `Sequence[str] | None` | Optional symbol filter. When omitted, all open positions are included. | `None`

Returns:

Type | Description
--- | ---
`float` | Total estimated margin, or `0.0` when no matching positions exist.

Source code in `mt5cli/trading.py`

    743
    744
    745
    746
    747
    748
    749
    750
    751
    752
    753
    754
    755
    756
    757
    758
    759
    760
    761
    762
    763
    764
    765
    766
    767
    768
    769
    770
    771
    772
    773
    774
    775
    776
    777
    778
    779
    780
    781
    782
    def calculate_positions_margin(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str] | None = None,
    ) -> float:
        """Return the sum of estimated current margin for open positions.

        Args:
            client: Connected MT5 client instance.
            symbols: Optional symbol filter. When omitted, all open positions are
                included.

        Returns:
            Total estimated margin, or ``0.0`` when no matching positions exist.
        """
        frame = get_positions_frame(client)
        if frame.empty or "symbol" not in frame.columns:
            return 0.0
        if symbols is not None:
            frame = frame[frame["symbol"].isin(list(symbols))]
        if frame.empty:
            return 0.0
        grouped_volumes: dict[tuple[str, OrderSide], float] = {}
        for _, row in frame.iterrows():
            symbol = row.get("symbol")
            if not isinstance(symbol, str) or not symbol:
                continue
            volume = row.get("volume")
            if not _is_positive_finite_number(volume):
                continue
            order_side = _order_side_from_position_type(client, row.get("type"))
            if order_side is None:
                continue
            key = (symbol, order_side)
            finite_volume = float(cast("float | int", volume))
            grouped_volumes[key] = grouped_volumes.get(key, 0.0) + finite_volume
        total = 0.0
        for (symbol, order_side), volume in grouped_volumes.items():
            total += estimate_order_margin(client, symbol, order_side, volume)
        return total

### calculate_positions_margin_by_symbol

    calculate_positions_margin_by_symbol(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str],
        suppress_errors: bool = True,
    ) -> dict[str, float]

Return per-symbol estimated margin for open positions.

Computes margin for each unique input symbol independently using the strict :func:`calculate_positions_margin` helper. Duplicates are deduplicated in first-seen order.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbols` | `Sequence[str]` | Symbols to compute margin for. | required
`suppress_errors` | `bool` | When `True`, log and skip symbols that raise `Mt5OperationError`, `Mt5RuntimeError`, or `AttributeError`. When `False`, re-raise the first failure. | `True`

Returns:

Type | Description
--- | ---
`dict[str, float]` | Mapping of symbol to margin total in first-seen unique-symbol order.
`dict[str, float]` | Returns an empty dict when `symbols` is empty or all symbols fail
`dict[str, float]` | with `suppress_errors=True`.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | When a symbol raises `Mt5OperationError` and `suppress_errors=False`.
`Mt5RuntimeError` | When a symbol raises `Mt5RuntimeError` and `suppress_errors=False`.
`AttributeError` | When a symbol raises `AttributeError` and `suppress_errors=False`.

Source code in `mt5cli/trading.py`

    785
    786
    787
    788
    789
    790
    791
    792
    793
    794
    795
    796
    797
    798
    799
    800
    801
    802
    803
    804
    805
    806
    807
    808
    809
    810
    811
    812
    813
    814
    815
    816
    817
    818
    819
    820
    821
    822
    823
    824
    825
    def calculate_positions_margin_by_symbol(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str],
        suppress_errors: bool = True,
    ) -> dict[str, float]:
        """Return per-symbol estimated margin for open positions.

        Computes margin for each unique input symbol independently using the strict
        :func:`calculate_positions_margin` helper. Duplicates are deduplicated in
        first-seen order.

        Args:
            client: Connected MT5 client instance.
            symbols: Symbols to compute margin for.
            suppress_errors: When ``True``, log and skip symbols that raise
                ``Mt5OperationError``, ``Mt5RuntimeError``, or ``AttributeError``.
                When ``False``, re-raise the first failure.

        Returns:
            Mapping of symbol to margin total in first-seen unique-symbol order.
            Returns an empty dict when ``symbols`` is empty or all symbols fail
            with ``suppress_errors=True``.

        Raises:
            Mt5OperationError: When a symbol raises ``Mt5OperationError`` and
                ``suppress_errors=False``.
            Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
                ``suppress_errors=False``.
            AttributeError: When a symbol raises ``AttributeError`` and
                ``suppress_errors=False``.
        """
        result: dict[str, float] = {}
        for symbol in dict.fromkeys(symbols):
            try:
                result[symbol] = calculate_positions_margin(client, symbols=[symbol])
            except (Mt5OperationError, Mt5RuntimeError, AttributeError) as exc:
                if not suppress_errors:
                    raise
                _logger.warning("Skipping margin for %r: %s", symbol, exc)
        return result

### calculate_positions_margin_safe

    calculate_positions_margin_safe(
        client: _Mt5ClientProtocol, *, symbols: Sequence[str]
    ) -> float

Return the total estimated margin for open positions across symbols.

Internally calls :func:`calculate_positions_margin_by_symbol` with `suppress_errors=True`. Failed symbols are silently skipped.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbols` | `Sequence[str]` | Symbols to include. | required

Returns:

Type | Description
--- | ---
`float` | Sum of per-symbol margins; `0.0` when no symbols or all fail.

Source code in `mt5cli/trading.py`

    828
    829
    830
    831
    832
    833
    834
    835
    836
    837
    838
    839
    840
    841
    842
    843
    844
    845
    846
    847
    848
    def calculate_positions_margin_safe(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str],
    ) -> float:
        """Return the total estimated margin for open positions across symbols.

        Internally calls :func:`calculate_positions_margin_by_symbol` with
        ``suppress_errors=True``. Failed symbols are silently skipped.

        Args:
            client: Connected MT5 client instance.
            symbols: Symbols to include.

        Returns:
            Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
        """
        return sum(
            calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
            0.0,
        )

### calculate_projected_margin_ratio

    calculate_projected_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float

Return estimated current plus optional new-position margin over equity.

Current exposure is estimated from open positions with :func:`calculate_positions_margin`. Optional projected exposure is added via :func:`estimate_order_margin`. Thresholds and guard actions are intentionally left to downstream applications.

Account equity, position margin, and optional projected margin errors from the composed MT5 helpers propagate to the caller.

Source code in `mt5cli/trading.py`

    959
    960
    961
    962
    963
    964
    965
    966
    967
    968
    969
    970
    971
    972
    973
    974
    975
    976
    977
    978
    979
    980
    981
    982
    983
    984
    985
    def calculate_projected_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
    ) -> float:
        """Return estimated current plus optional new-position margin over equity.

        Current exposure is estimated from open positions with
        :func:`calculate_positions_margin`. Optional projected exposure is added via
        :func:`estimate_order_margin`. Thresholds and guard actions are intentionally
        left to downstream applications.

        Account equity, position margin, and optional projected margin errors from
        the composed MT5 helpers propagate to the caller.
        """
        equity = _account_equity(client)
        margin = calculate_positions_margin(client, symbols=[symbol])
        if new_position_side is not None and new_position_volume > 0:
            margin += estimate_order_margin(
                client,
                symbol,
                new_position_side,
                new_position_volume,
            )
        return margin / equity

### calculate_spread_ratio

    calculate_spread_ratio(
        client: _Mt5ClientProtocol, symbol: str
    ) -> float

Return `(ask - bid) / ((ask + bid) / 2)` for the latest tick.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If bid or ask is unavailable.

Source code in `mt5cli/trading.py`

    851
    852
    853
    854
    855
    856
    857
    858
    859
    860
    861
    862
    863
    def calculate_spread_ratio(client: _Mt5ClientProtocol, symbol: str) -> float:
        """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.

        Raises:
            Mt5OperationError: If bid or ask is unavailable.
        """
        tick = get_tick_snapshot(client, symbol)
        bid = extract_tick_price(tick, "bid")
        ask = extract_tick_price(tick, "ask")
        if bid is None or ask is None:
            msg = f"Tick bid/ask is unavailable for {symbol!r}."
            raise Mt5OperationError(msg)
        return (ask - bid) / ((ask + bid) / 2.0)

### calculate_symbol_group_margin_ratio

    calculate_symbol_group_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str],
        new_symbol: str | None = None,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
        suppress_errors: bool = True,
        projection_mode: ProjectionMode = "add",
    ) -> float

Return estimated symbol-group margin over account equity.

Per-symbol current exposure is summed with :func:`calculate_positions_margin_by_symbol`. When `new_symbol` is inside the input symbol group and candidate side/volume are provided, projected order margin is applied according to `projection_mode`:

- `"add"` (default): adds candidate margin to the group total.
- `"replace_symbol"`: subtracts current margin for `new_symbol`, then adds candidate margin. Useful for reversal-style projections where the new order is intended to replace existing exposure for that symbol.

If the candidate margin estimation fails, the subtraction is also skipped so the operation is atomic. Invalid equity always raises to fail closed.

Raises:

Type | Description
--- | ---
`AttributeError` | When symbol margin lookup or projected margin lookup fails and `suppress_errors` is `False`.
`Mt5RuntimeError` | When symbol margin lookup or projected margin lookup fails and `suppress_errors` is `False`.
`Mt5OperationError` | When account equity is invalid, or when symbol margin lookup or projected margin lookup fails and `suppress_errors` is `False`.

Source code in `mt5cli/trading.py`

     998
     999
    1000
    1001
    1002
    1003
    1004
    1005
    1006
    1007
    1008
    1009
    1010
    1011
    1012
    1013
    1014
    1015
    1016
    1017
    1018
    1019
    1020
    1021
    1022
    1023
    1024
    1025
    1026
    1027
    1028
    1029
    1030
    1031
    1032
    1033
    1034
    1035
    1036
    1037
    1038
    1039
    1040
    1041
    1042
    1043
    1044
    1045
    1046
    1047
    1048
    1049
    1050
    1051
    1052
    1053
    1054
    1055
    1056
    1057
    1058
    1059
    1060
    1061
    def calculate_symbol_group_margin_ratio(
        client: _Mt5ClientProtocol,
        *,
        symbols: Sequence[str],
        new_symbol: str | None = None,
        new_position_side: OrderSide | None = None,
        new_position_volume: float = 0.0,
        suppress_errors: bool = True,
        projection_mode: ProjectionMode = "add",
    ) -> float:
        """Return estimated symbol-group margin over account equity.

        Per-symbol current exposure is summed with
        :func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
        the input symbol group and candidate side/volume are provided, projected order
        margin is applied according to ``projection_mode``:

        - ``"add"`` (default): adds candidate margin to the group total.
        - ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
          adds candidate margin. Useful for reversal-style projections where the new
          order is intended to replace existing exposure for that symbol.

        If the candidate margin estimation fails, the subtraction is also skipped so
        the operation is atomic. Invalid equity always raises to fail closed.

        Raises:
            AttributeError: When symbol margin lookup or projected margin lookup
                fails and ``suppress_errors`` is ``False``.
            Mt5RuntimeError: When symbol margin lookup or projected margin lookup
                fails and ``suppress_errors`` is ``False``.
            Mt5OperationError: When account equity is invalid, or when symbol margin
                lookup or projected margin lookup fails and ``suppress_errors`` is
                ``False``.
        """
        projection_mode = _validate_projection_mode(projection_mode)
        equity = _account_equity(client)
        unique_symbols = list(dict.fromkeys(symbols))
        per_symbol = calculate_positions_margin_by_symbol(
            client,
            symbols=unique_symbols,
            suppress_errors=suppress_errors,
        )
        margin = sum(per_symbol.values(), 0.0)
        if (
            new_symbol in unique_symbols
            and new_position_side is not None
            and new_position_volume > 0
        ):
            try:
                candidate_margin = estimate_order_margin(
                    client,
                    new_symbol,
                    new_position_side,
                    new_position_volume,
                )
            except (Mt5OperationError, Mt5RuntimeError, AttributeError):
                if not suppress_errors:
                    raise
                _logger.warning("Skipping projected margin for %r.", new_symbol)
            else:
                if projection_mode == "replace_symbol":
                    margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
                margin += candidate_margin
        return margin / equity

### calculate_trailing_stop_updates

    calculate_trailing_stop_updates(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        trailing_stop_ratio: float,
    ) -> dict[int, float]

Return per-ticket trailing stop-loss updates for open symbol positions.

Buy positions trail from bid using `bid * (1 - trailing_stop_ratio)`. Sell positions trail from ask using `ask * (1 + trailing_stop_ratio)`. Existing stop losses are preserved when they are already more favorable. Missing symbol metadata returns an empty update map. Positions with a missing side-specific tick price are skipped.

Source code in `mt5cli/trading.py`

    1467
    1468
    1469
    1470
    1471
    1472
    1473
    1474
    1475
    1476
    1477
    1478
    1479
    1480
    1481
    1482
    1483
    1484
    1485
    1486
    1487
    1488
    1489
    1490
    1491
    1492
    1493
    1494
    1495
    1496
    1497
    1498
    1499
    1500
    1501
    1502
    1503
    1504
    1505
    1506
    1507
    1508
    1509
    def calculate_trailing_stop_updates(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        trailing_stop_ratio: float,
    ) -> dict[int, float]:
        """Return per-ticket trailing stop-loss updates for open symbol positions.

        Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``.
        Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``.
        Existing stop losses are preserved when they are already more favorable.
        Missing symbol metadata returns an empty update map. Positions with a
        missing side-specific tick price are skipped.
        """
        _require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
        positions = get_positions_frame(client, symbol=symbol)
        if positions.empty:
            return {}
        tick = get_tick_snapshot(client, symbol)
        bid = extract_tick_price(tick, "bid")
        ask = extract_tick_price(tick, "ask")
        digits = _symbol_digits(client, symbol)
        if digits is None:
            return {}

        updates: dict[int, float] = {}
        for row in positions.to_dict("records"):
            ticket = _position_ticket(row.get("ticket"))
            if ticket is None:
                continue
            next_sl = _trailing_stop_loss(
                client,
                position_type=row.get("type"),
                current_sl=_current_stop_loss(row.get("sl")),
                bid=bid,
                ask=ask,
                digits=digits,
                trailing_stop_ratio=trailing_stop_ratio,
            )
            if next_sl is None:
                continue
            updates[ticket] = next_sl
        return updates

### calculate_volume_by_margin

    calculate_volume_by_margin(
        client: _Mt5ClientProtocol,
        symbol: str,
        available_margin: float,
        order_side: OrderSide,
    ) -> float

Calculate max normalized volume affordable for one side.

Returns:

Type | Description
--- | ---
`float` | Largest stepped volume whose actual margin (from `order_calc_margin`)
`float` | fits within `available_margin`, rounded down to symbol volume
`float` | constraints; `0.0` when no affordable step exists.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If symbol volume constraints or tick data are invalid.

Source code in `mt5cli/trading.py`

    1131
    1132
    1133
    1134
    1135
    1136
    1137
    1138
    1139
    1140
    1141
    1142
    1143
    1144
    1145
    1146
    1147
    1148
    1149
    1150
    1151
    1152
    1153
    1154
    1155
    1156
    1157
    1158
    1159
    1160
    1161
    1162
    1163
    1164
    1165
    1166
    1167
    1168
    1169
    1170
    1171
    1172
    1173
    1174
    1175
    1176
    1177
    1178
    1179
    1180
    1181
    1182
    1183
    1184
    1185
    1186
    1187
    1188
    1189
    1190
    1191
    1192
    1193
    1194
    1195
    1196
    1197
    1198
    1199
    1200
    def calculate_volume_by_margin(
        client: _Mt5ClientProtocol,
        symbol: str,
        available_margin: float,
        order_side: OrderSide,
    ) -> float:
        """Calculate max normalized volume affordable for one side.

        Returns:
            Largest stepped volume whose actual margin (from ``order_calc_margin``)
            fits within ``available_margin``, rounded down to symbol volume
            constraints; ``0.0`` when no affordable step exists.

        Raises:
            Mt5OperationError: If symbol volume constraints or tick data are invalid.
        """
        if available_margin <= 0:
            return 0.0
        symbol_info = get_symbol_snapshot(client, symbol)
        volume_min = float(symbol_info.get("volume_min") or 0.0)
        volume_max = float(symbol_info.get("volume_max") or 0.0)
        volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
        if volume_min <= 0 or volume_step <= 0:
            msg = f"Invalid volume constraints for {symbol!r}."
            raise Mt5OperationError(msg)
        side = _normalize_order_side(order_side)
        price = extract_tick_price(
            get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
        )
        if price is None:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5OperationError(msg)
        order_type = (
            client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
        )
        min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
        if min_margin <= 0 or min_margin > available_margin:
            return 0.0
        lo = 0
        hi = int(
            max(
                0,
                floor(
                    (
                        (
                            min(available_margin / min_margin * volume_min, volume_max)
                            if volume_max > 0
                            else available_margin / min_margin * volume_min
                        )
                        - volume_min
                    )
                    / volume_step
                    + 1e-12
                ),
            )
        )
        best = -1

        while lo <= hi:
            mid = (lo + hi) // 2
            normalized = round(volume_min + mid * volume_step, 10)
            actual = float(client.order_calc_margin(order_type, symbol, normalized, price))

            if actual > 0 and actual <= available_margin:
                best = mid
                lo = mid + 1
            else:
                hi = mid - 1

        return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0

### close_open_positions

    close_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbols: str | list[str] | None = None,
        tickets: list[int] | None = None,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]

Close matching open positions.

Returns:

Type | Description
--- | ---
`list[OrderExecutionResult]` | Normalized execution results for matching positions.

Source code in `mt5cli/trading.py`

    1391
    1392
    1393
    1394
    1395
    1396
    1397
    1398
    1399
    1400
    1401
    1402
    1403
    1404
    1405
    1406
    1407
    1408
    1409
    1410
    1411
    1412
    1413
    1414
    1415
    1416
    1417
    1418
    1419
    1420
    1421
    def close_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbols: str | list[str] | None = None,
        tickets: list[int] | None = None,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]:
        """Close matching open positions.

        Returns:
            Normalized execution results for matching positions.
        """
        positions = _filter_positions(
            get_positions_frame(client),
            symbols=symbols,
            tickets=tickets,
        )
        results: list[OrderExecutionResult] = []
        for row in positions.to_dict("records"):
            pos_type = row["type"]
            side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY"
            result = place_market_order(
                client,
                symbol=str(row["symbol"]),
                volume=float(row["volume"]),
                order_side=side,
                position=int(row["ticket"]),
                dry_run=dry_run,
            )
            results.append(result)
        return results

### create_trading_client

    create_trading_client(
        *,
        config: Mt5Config | None = None,
        login: int | str | None = None,
        password: str | None = None,
        server: str | None = None,
        path: str | None = None,
        timeout: int | None = None,
        retry_count: int = 0,
    ) -> _Mt5ClientProtocol

Return an initialized and logged-in trading client.

Returns:

Type | Description
--- | ---
`_Mt5ClientProtocol` | A client instance supporting the required MT5 trading methods.

Source code in `mt5cli/trading.py`

    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    def create_trading_client(
        *,
        config: Mt5Config | None = None,
        login: int | str | None = None,
        password: str | None = None,
        server: str | None = None,
        path: str | None = None,
        timeout: int | None = None,
        retry_count: int = 0,
    ) -> _Mt5ClientProtocol:
        """Return an initialized and logged-in trading client.

        Returns:
            A client instance supporting the required MT5 trading methods.
        """
        mt5_config = _resolve_config(
            config=config,
            login=login,
            password=password,
            server=server,
            path=path,
            timeout=timeout,
        )
        client = Mt5DataClient(config=mt5_config, retry_count=retry_count)
        try:
            client.initialize_and_login_mt5()
        except Exception:
            client.shutdown()
            raise
        return client

### detect_position_side

    detect_position_side(
        client: _Mt5ClientProtocol, symbol: str
    ) -> PositionSide | None

Detect the net open position side for a symbol.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbol` | `str` | Symbol to inspect. | required

Returns:

Type | Description
--- | ---
`PositionSide | None` | `"long"` when there are buy positions and no sell positions,
`PositionSide | None` | `"short"` when there are sell positions and no buy positions, or
`PositionSide | None` | `None` when no positions or mixed exposure exists.

Source code in `mt5cli/trading.py`

    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    def detect_position_side(
        client: _Mt5ClientProtocol,
        symbol: str,
    ) -> PositionSide | None:
        """Detect the net open position side for a symbol.

        Args:
            client: Connected MT5 client instance.
            symbol: Symbol to inspect.

        Returns:
            ``"long"`` when there are buy positions and no sell positions,
            ``"short"`` when there are sell positions and no buy positions, or
            ``None`` when no positions or mixed exposure exists.
        """
        positions = get_positions_frame(client, symbol=symbol)
        if positions.empty:
            return None

        buy_type = client.mt5.POSITION_TYPE_BUY
        sell_type = client.mt5.POSITION_TYPE_SELL
        buy_volume = _sum_position_volume(positions, buy_type)
        sell_volume = _sum_position_volume(positions, sell_type)
        if buy_volume > 0 and sell_volume == 0:
            return "long"
        if sell_volume > 0 and buy_volume == 0:
            return "short"
        return None

### determine_order_limits

    determine_order_limits(
        client: _Mt5ClientProtocol,
        symbol: str,
        side: PositionSide | str,
        stop_loss_limit_ratio: float | None = None,
        take_profit_limit_ratio: float | None = None,
    ) -> OrderLimits

Derive entry and protective order prices from current market quotes.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbol` | `str` | Symbol used for the quote lookup. | required
`side` | `PositionSide | str` | Position side as `"long"`/`"short"` (`"buy"`/`"sell"` aliases are accepted). | required
`stop_loss_limit_ratio` | `float | None` | Relative distance from entry for stop loss in `[0, 1)`. A value of `0` omits the stop loss. | `None`
`take_profit_limit_ratio` | `float | None` | Relative distance from entry for take profit in `[0, 1)`. A value of `0` omits the take profit. | `None`

Returns:

Type | Description
--- | ---
`OrderLimits` | Dictionary with `entry`, `stop_loss`, and `take_profit` keys.
`OrderLimits` | Omitted protective levels are returned as `None`.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If required tick data is invalid or computed SL/TP prices violate available `trade_stops_level` pre-validation.

Source code in `mt5cli/trading.py`

    1203
    1204
    1205
    1206
    1207
    1208
    1209
    1210
    1211
    1212
    1213
    1214
    1215
    1216
    1217
    1218
    1219
    1220
    1221
    1222
    1223
    1224
    1225
    1226
    1227
    1228
    1229
    1230
    1231
    1232
    1233
    1234
    1235
    1236
    1237
    1238
    1239
    1240
    1241
    1242
    1243
    1244
    1245
    1246
    1247
    1248
    1249
    1250
    1251
    1252
    1253
    1254
    1255
    1256
    1257
    1258
    1259
    1260
    1261
    1262
    1263
    1264
    1265
    1266
    1267
    1268
    1269
    1270
    1271
    1272
    1273
    1274
    1275
    1276
    1277
    1278
    1279
    1280
    def determine_order_limits(
        client: _Mt5ClientProtocol,
        symbol: str,
        side: PositionSide | str,
        stop_loss_limit_ratio: float | None = None,
        take_profit_limit_ratio: float | None = None,
    ) -> OrderLimits:
        """Derive entry and protective order prices from current market quotes.

        Args:
            client: Connected MT5 client instance.
            symbol: Symbol used for the quote lookup.
            side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
                aliases are accepted).
            stop_loss_limit_ratio: Relative distance from entry for stop loss in
                ``[0, 1)``. A value of ``0`` omits the stop loss.
            take_profit_limit_ratio: Relative distance from entry for take profit in
                ``[0, 1)``. A value of ``0`` omits the take profit.

        Returns:
            Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
            Omitted protective levels are returned as ``None``.

        Raises:
            Mt5OperationError: If required tick data is invalid or computed SL/TP
                prices violate available ``trade_stops_level`` pre-validation.
        """
        stop_loss_ratio = stop_loss_limit_ratio or 0.0
        take_profit_ratio = take_profit_limit_ratio or 0.0
        _require_protective_ratio(stop_loss_ratio, "stop_loss_limit_ratio")
        _require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
        normalized_side = _position_side_from_order_side(side)
        tick = get_tick_snapshot(client, symbol)
        entry_key = "ask" if normalized_side == "long" else "bid"
        entry = extract_tick_price(tick, entry_key)
        if entry is None:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5OperationError(msg)
        try:
            symbol_info = get_symbol_snapshot(client, symbol)
        except (AttributeError, KeyError, TypeError, ValueError):
            symbol_info = {}
        try:
            digits = int(symbol_info.get("digits") or 8)
        except (TypeError, ValueError):
            digits = 8
        min_distance = _minimum_stop_distance(symbol_info)

        stop_loss: float | None = None
        if stop_loss_ratio > 0:
            if normalized_side == "long":
                stop_loss = entry * (1.0 - stop_loss_ratio)
            else:
                stop_loss = entry * (1.0 + stop_loss_ratio)
            stop_loss = round(stop_loss, digits)

        take_profit: float | None = None
        if take_profit_ratio > 0:
            if normalized_side == "long":
                take_profit = entry * (1.0 + take_profit_ratio)
            else:
                take_profit = entry * (1.0 - take_profit_ratio)
            take_profit = round(take_profit, digits)

        _validate_protective_prices(
            symbol=symbol,
            side=normalized_side,
            entry=entry,
            stop_loss=stop_loss,
            take_profit=take_profit,
            min_distance=min_distance,
        )

        return {
            "entry": entry,
            "stop_loss": stop_loss,
            "take_profit": take_profit,
        }

### ensure_symbol_selected

    ensure_symbol_selected(
        client: _Mt5ClientProtocol, symbol: str
    ) -> None

Ensure a symbol is visible in Market Watch before sending orders.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected MT5 client instance. | required
`symbol` | `str` | Symbol to select. | required

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If the symbol cannot be selected in Market Watch or `symbol_select` is unavailable on the client.

Source code in `mt5cli/trading.py`

    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    def ensure_symbol_selected(client: _Mt5ClientProtocol, symbol: str) -> None:
        """Ensure a symbol is visible in Market Watch before sending orders.

        Args:
            client: Connected MT5 client instance.
            symbol: Symbol to select.

        Raises:
            Mt5OperationError: If the symbol cannot be selected in Market Watch or
                ``symbol_select`` is unavailable on the client.
        """
        snapshot = get_symbol_snapshot(client, symbol)
        if snapshot.get("visible"):
            return
        select = getattr(client, "symbol_select", None)
        if not callable(select):
            msg = "MT5 client is missing required method: symbol_select"
            raise Mt5OperationError(msg)
        if select(symbol, enable=True):
            return
        last_error = getattr(client, "last_error", None)
        detail = f" ({last_error()})" if callable(last_error) else ""
        msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
        raise Mt5OperationError(msg)

### estimate_order_margin

    estimate_order_margin(
        client: _Mt5ClientProtocol,
        symbol: str,
        order_side: OrderSide | str,
        volume: float,
    ) -> float

Estimate required margin for one order at the current market price.

Returns:

Type | Description
--- | ---
`float` | Positive finite margin required for the order at the current quote.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If volume, tick data, or margin estimation is invalid.

Source code in `mt5cli/trading.py`

    705
    706
    707
    708
    709
    710
    711
    712
    713
    714
    715
    716
    717
    718
    719
    720
    721
    722
    723
    724
    725
    726
    727
    728
    729
    730
    731
    732
    733
    734
    735
    736
    737
    738
    739
    740
    def estimate_order_margin(
        client: _Mt5ClientProtocol,
        symbol: str,
        order_side: OrderSide | str,
        volume: float,
    ) -> float:
        """Estimate required margin for one order at the current market price.

        Returns:
            Positive finite margin required for the order at the current quote.

        Raises:
            Mt5OperationError: If volume, tick data, or margin estimation is invalid.
        """
        if not _is_positive_finite_number(volume):
            msg = "Volume must be a positive finite number to estimate order margin."
            raise Mt5OperationError(msg)
        side = _normalize_order_side(order_side)
        tick = get_tick_snapshot(client, symbol)
        price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
        if price is None:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5OperationError(msg)
        order_type = (
            client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
        )
        raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
        try:
            margin = float(raw_margin)
        except (TypeError, ValueError) as exc:
            msg = f"Margin estimate is invalid for {symbol!r}."
            raise Mt5OperationError(msg) from exc
        if margin <= 0 or not isfinite(margin):
            msg = f"Margin estimate is invalid for {symbol!r}."
            raise Mt5OperationError(msg)
        return margin

### extract_tick_price

    extract_tick_price(
        tick: Mapping[str, object], key: str
    ) -> float | None

Return a positive finite float from tick[key], or None if invalid.

Accepts int, float, or numeric string values. Returns None when the key is missing, the value is None, non-numeric, NaN, infinite, zero, or negative. Booleans are treated as non-numeric and return None.

Source code in `mt5cli/trading.py`

    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    def extract_tick_price(tick: Mapping[str, object], key: str) -> float | None:
        """Return a positive finite float from tick[key], or None if invalid.

        Accepts int, float, or numeric string values. Returns None when the key is
        missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
        Booleans are treated as non-numeric and return None.
        """
        value = tick.get(key)
        if value is None or isinstance(value, bool):
            return None
        if isinstance(value, int | float):
            price = float(value)
        elif isinstance(value, str):
            try:
                price = float(value)
            except ValueError:
                return None
        else:
            return None
        if not isfinite(price) or price <= 0:
            return None
        return price

### fetch_latest_closed_rates_for_trading_client

    fetch_latest_closed_rates_for_trading_client(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        granularity: str,
        count: int,
    ) -> DataFrame

Fetch the latest closed bars from a connected trading client.

Returns:

Type | Description
--- | ---
`DataFrame` | Up to `count` closed bars ordered oldest to newest.

Raises:

Type | Description
--- | ---
`ValueError` | If `count` is not positive, rate data is empty or malformed, or the `time` column is missing.
`Mt5OperationError` | If the trading client cannot fetch rate data.

Source code in `mt5cli/trading.py`

    1607
    1608
    1609
    1610
    1611
    1612
    1613
    1614
    1615
    1616
    1617
    1618
    1619
    1620
    1621
    1622
    1623
    1624
    1625
    1626
    1627
    1628
    1629
    1630
    1631
    1632
    1633
    1634
    1635
    1636
    1637
    1638
    1639
    1640
    1641
    1642
    1643
    1644
    1645
    1646
    1647
    1648
    1649
    1650
    def fetch_latest_closed_rates_for_trading_client(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        granularity: str,
        count: int,
    ) -> pd.DataFrame:
        """Fetch the latest closed bars from a connected trading client.

        Returns:
            Up to ``count`` closed bars ordered oldest to newest.

        Raises:
            ValueError: If ``count`` is not positive, rate data is empty or
                malformed, or the ``time`` column is missing.
            Mt5OperationError: If the trading client cannot fetch rate data.
        """
        if count <= 0:
            msg = "count must be positive."
            raise ValueError(msg)
        fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
        if not callable(fetch_method):
            msg = "MT5 trading client cannot fetch rate data."
            raise Mt5OperationError(msg)
        fetched = fetch_method(symbol, granularity, count + 1)
        if not isinstance(fetched, pd.DataFrame):
            msg = (
                f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
                "expected a DataFrame."
            )
            raise ValueError(msg)  # noqa: TRY004
        frame = fetched
        frame = _ensure_rate_time_column(frame)
        if "time" not in frame.columns:
            msg = f"Rate data is missing a time column for {symbol!r}."
            raise ValueError(msg)
        closed = drop_forming_rate_bar(frame)
        if closed.empty:
            msg = (
                f"Rate data is empty for {symbol!r} at granularity {granularity!r} "
                f"with count {count}."
            )
            raise ValueError(msg)
        return closed.tail(count).reset_index(drop=True)

### fetch_latest_closed_rates_indexed

    fetch_latest_closed_rates_indexed(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        granularity: str,
        count: int,
    ) -> DataFrame

Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.

Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for closed-bar detection and validation, then converts the `time` column to a UTC-aware :class:`~pandas.DatetimeIndex` named `"time"` and drops the original column. Intended for downstream time-series consumers that require a datetime index rather than a `time` column.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `_Mt5ClientProtocol` | Connected trading client with rate-fetch capability. | required
`symbol` | `str` | Symbol name. | required
`granularity` | `str` | Timeframe string (for example `"M1"`, `"H1"`). | required
`count` | `int` | Maximum number of closed bars to return. | required

Returns:

Type | Description
--- | ---
`DataFrame` | Up to `count` closed bars ordered oldest to newest, with a
`DataFrame` | UTC-aware `DatetimeIndex` named `"time"`. The original `time`
`DataFrame` | column is dropped.

Raises:

Type | Description
--- | ---
`ValueError` | If `count` is not positive, rate data is empty or malformed, the `time` column is missing, or timestamp data is invalid or unparseable.

Source code in `mt5cli/trading.py`

    1692
    1693
    1694
    1695
    1696
    1697
    1698
    1699
    1700
    1701
    1702
    1703
    1704
    1705
    1706
    1707
    1708
    1709
    1710
    1711
    1712
    1713
    1714
    1715
    1716
    1717
    1718
    1719
    1720
    1721
    1722
    1723
    1724
    1725
    1726
    1727
    1728
    1729
    1730
    1731
    1732
    1733
    1734
    1735
    1736
    def fetch_latest_closed_rates_indexed(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        granularity: str,
        count: int,
    ) -> pd.DataFrame:
        """Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.

        Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for
        closed-bar detection and validation, then converts the ``time`` column to a
        UTC-aware :class:`~pandas.DatetimeIndex` named ``"time"`` and drops the
        original column. Intended for downstream time-series consumers that require
        a datetime index rather than a ``time`` column.

        Args:
            client: Connected trading client with rate-fetch capability.
            symbol: Symbol name.
            granularity: Timeframe string (for example ``"M1"``, ``"H1"``).
            count: Maximum number of closed bars to return.

        Returns:
            Up to ``count`` closed bars ordered oldest to newest, with a
            UTC-aware ``DatetimeIndex`` named ``"time"``. The original ``time``
            column is dropped.

        Raises:
            ValueError: If ``count`` is not positive, rate data is empty or
                malformed, the ``time`` column is missing, or timestamp data
                is invalid or unparseable.
        """
        frame = fetch_latest_closed_rates_for_trading_client(
            client,
            symbol=symbol,
            granularity=granularity,
            count=count,
        )
        if "time" not in frame.columns:
            msg = f"Rate data is missing a time column for {symbol!r}."
            raise ValueError(msg)
        idx = _rate_time_to_utc(frame["time"], symbol)
        idx.name = "time"
        result = frame.drop(columns=["time"])
        result.index = idx
        return result

### get_account_snapshot

    get_account_snapshot(
        client: _Mt5ClientProtocol,
    ) -> dict[str, float | int | str | None]

Return normalized account state with stable keys.

Source code in `mt5cli/trading.py`

    634
    635
    636
    637
    638
    639
    640
    641
    642
    def get_account_snapshot(
        client: _Mt5ClientProtocol,
    ) -> dict[str, float | int | str | None]:
        """Return normalized account state with stable keys."""
        value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
        return cast(
            "dict[str, float | int | str | None]",
            _snapshot_from_value(value, _ACCOUNT_SNAPSHOT_FIELDS),
        )

### get_positions_frame

    get_positions_frame(
        client: _Mt5ClientProtocol, symbol: str | None = None
    ) -> DataFrame

Return open positions as a DataFrame with stable baseline columns.

Source code in `mt5cli/trading.py`

    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    def get_positions_frame(
        client: _Mt5ClientProtocol,
        symbol: str | None = None,
    ) -> pd.DataFrame:
        """Return open positions as a DataFrame with stable baseline columns."""
        frame = client.positions_get_as_df(symbol=symbol)
        for column in POSITION_COLUMNS:
            if column not in frame.columns:
                frame[column] = pd.Series(dtype="object")
        return frame

### get_symbol_snapshot

    get_symbol_snapshot(
        client: _Mt5ClientProtocol, symbol: str
    ) -> dict[str, float | int | str | bool | None]

Return normalized symbol metadata required for trading decisions.

Source code in `mt5cli/trading.py`

    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    def get_symbol_snapshot(
        client: _Mt5ClientProtocol,
        symbol: str,
    ) -> dict[str, float | int | str | bool | None]:
        """Return normalized symbol metadata required for trading decisions."""
        method = getattr(client, "symbol_info_as_dict", None)
        value = method(symbol=symbol) if callable(method) else client.symbol_info(symbol)
        snapshot = _snapshot_from_value(value, _SYMBOL_SNAPSHOT_FIELDS)
        snapshot["symbol"] = snapshot.get("symbol") or symbol
        return cast("dict[str, float | int | str | bool | None]", snapshot)

### get_tick_snapshot

    get_tick_snapshot(
        client: _Mt5ClientProtocol, symbol: str
    ) -> dict[str, float | int | None]

Return normalized latest tick data, including bid, ask, and timestamp.

Source code in `mt5cli/trading.py`

    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    def get_tick_snapshot(
        client: _Mt5ClientProtocol,
        symbol: str,
    ) -> dict[str, float | int | None]:
        """Return normalized latest tick data, including bid, ask, and timestamp."""
        method = getattr(client, "symbol_info_tick_as_dict", None)
        value = (
            method(symbol=symbol) if callable(method) else client.symbol_info_tick(symbol)
        )
        snapshot = _snapshot_from_value(value, _TICK_SNAPSHOT_FIELDS)
        snapshot["symbol"] = snapshot.get("symbol") or symbol
        return cast("dict[str, float | int | None]", snapshot)

### mt5_trading_session

    mt5_trading_session(
        config: Mt5Config | None = None,
        *,
        login: int | str | None = None,
        password: str | None = None,
        server: str | None = None,
        path: str | None = None,
        timeout: int | None = None,
        retry_count: int = 0,
    ) -> Iterator[_Mt5ClientProtocol]

Open a trading-capable MT5 session and always shut down safely.

Launches the MetaTrader 5 terminal using `Mt5Config.path` when set, initializes and logs in via `initialize_and_login_mt5()`, yields a connected client supporting required MT5 methods, and calls `shutdown()` on exit even when an error is raised inside the context.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`config` | `Mt5Config | None` | MT5 connection configuration. Defaults to an empty config that attaches to a running terminal. | `None`
`login` | `int | str | None` | Optional trading account login. | `None`
`password` | `str | None` | Optional trading account password. | `None`
`server` | `str | None` | Optional trading server name. | `None`
`path` | `str | None` | Optional terminal executable path. | `None`
`timeout` | `int | None` | Optional connection timeout in milliseconds. | `None`
`retry_count` | `int` | Number of initialization retries. | `0`

Yields:

Type | Description
--- | ---
`_Mt5ClientProtocol` | Connected client supporting required MT5 trading methods.

Source code in `mt5cli/trading.py`

    1739
    1740
    1741
    1742
    1743
    1744
    1745
    1746
    1747
    1748
    1749
    1750
    1751
    1752
    1753
    1754
    1755
    1756
    1757
    1758
    1759
    1760
    1761
    1762
    1763
    1764
    1765
    1766
    1767
    1768
    1769
    1770
    1771
    1772
    1773
    1774
    1775
    1776
    1777
    1778
    1779
    1780
    1781
    1782
    @contextmanager
    def mt5_trading_session(
        config: Mt5Config | None = None,
        *,
        login: int | str | None = None,
        password: str | None = None,
        server: str | None = None,
        path: str | None = None,
        timeout: int | None = None,
        retry_count: int = 0,
    ) -> Iterator[_Mt5ClientProtocol]:
        """Open a trading-capable MT5 session and always shut down safely.

        Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
        initializes and logs in via ``initialize_and_login_mt5()``, yields a
        connected client supporting required MT5 methods, and calls ``shutdown()``
        on exit even when an error is raised inside the context.

        Args:
            config: MT5 connection configuration. Defaults to an empty config that
                attaches to a running terminal.
            login: Optional trading account login.
            password: Optional trading account password.
            server: Optional trading server name.
            path: Optional terminal executable path.
            timeout: Optional connection timeout in milliseconds.
            retry_count: Number of initialization retries.

        Yields:
            Connected client supporting required MT5 trading methods.
        """
        client = create_trading_client(
            config=config,
            login=login,
            password=password,
            server=server,
            path=path,
            timeout=timeout,
            retry_count=retry_count,
        )
        try:
            yield client
        finally:
            client.shutdown()

### normalize_order_volume

    normalize_order_volume(
        volume: float,
        *,
        volume_min: float,
        volume_max: float,
        volume_step: float,
    ) -> float

Normalize a requested order volume to broker volume constraints.

Returns:

Type | Description
--- | ---
`float` | Volume floored to the nearest valid broker step from `volume_min`,
`float` | capped at `volume_max` when finite and positive, and rounded
`float` | deterministically. Returns `0.0` when inputs or constraints are
`float` | invalid, non-finite, or the capped request is below `volume_min`.

Source code in `mt5cli/trading.py`

    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    def normalize_order_volume(
        volume: float,
        *,
        volume_min: float,
        volume_max: float,
        volume_step: float,
    ) -> float:
        """Normalize a requested order volume to broker volume constraints.

        Returns:
            Volume floored to the nearest valid broker step from ``volume_min``,
            capped at ``volume_max`` when finite and positive, and rounded
            deterministically. Returns ``0.0`` when inputs or constraints are
            invalid, non-finite, or the capped request is below ``volume_min``.
        """
        if not _is_finite_number(volume):
            return 0.0
        if not _is_positive_finite_number(volume_min):
            return 0.0
        if not _is_positive_finite_number(volume_step):
            return 0.0
        has_volume_cap = _is_positive_finite_number(volume_max)
        capped = min(volume, volume_max) if has_volume_cap else volume
        if capped < volume_min:
            return 0.0
        steps = floor(((capped - volume_min) / volume_step) + 1e-12)
        normalized = volume_min + max(0, steps) * volume_step
        if has_volume_cap:
            normalized = min(normalized, volume_max)
        return round(normalized, 10)

### place_market_order

    place_market_order(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        volume: float,
        order_side: OrderSide,
        order_filling_mode: OrderFillingMode = "IOC",
        order_time_mode: OrderTimeMode = "GTC",
        sl: float | None = None,
        tp: float | None = None,
        position: int | None = None,
        dry_run: bool = False,
    ) -> OrderExecutionResult

Place one normalized market order or return a dry-run result.

`order_send()` raises only when MT5 returns no response. When MT5 returns a response with a known non-success retcode, this helper returns `status="failed"` and keeps the normalized response details for callers to inspect.

Returns:

Type | Description
--- | ---
`OrderExecutionResult` | Normalized execution result containing request and response details.

Raises:

Type | Description
--- | ---
`Mt5OperationError` | If volume or required tick data is invalid.

Source code in `mt5cli/trading.py`

    1283
    1284
    1285
    1286
    1287
    1288
    1289
    1290
    1291
    1292
    1293
    1294
    1295
    1296
    1297
    1298
    1299
    1300
    1301
    1302
    1303
    1304
    1305
    1306
    1307
    1308
    1309
    1310
    1311
    1312
    1313
    1314
    1315
    1316
    1317
    1318
    1319
    1320
    1321
    1322
    1323
    1324
    1325
    1326
    1327
    1328
    1329
    1330
    1331
    1332
    1333
    1334
    1335
    1336
    1337
    1338
    1339
    1340
    1341
    1342
    1343
    1344
    1345
    1346
    1347
    1348
    1349
    1350
    1351
    1352
    1353
    1354
    1355
    1356
    1357
    1358
    1359
    1360
    1361
    1362
    1363
    1364
    1365
    1366
    1367
    1368
    1369
    1370
    1371
    1372
    1373
    def place_market_order(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        volume: float,
        order_side: OrderSide,
        order_filling_mode: OrderFillingMode = "IOC",
        order_time_mode: OrderTimeMode = "GTC",
        sl: float | None = None,
        tp: float | None = None,
        position: int | None = None,
        dry_run: bool = False,
    ) -> OrderExecutionResult:
        """Place one normalized market order or return a dry-run result.

        ``order_send()`` raises only when MT5 returns no response. When MT5 returns
        a response with a known non-success retcode, this helper returns
        ``status="failed"`` and keeps the normalized response details for callers
        to inspect.

        Returns:
            Normalized execution result containing request and response details.

        Raises:
            Mt5OperationError: If volume or required tick data is invalid.
        """
        if volume <= 0:
            msg = "volume must be positive."
            raise Mt5OperationError(msg)
        side = _normalize_order_side(order_side)
        if not dry_run:
            ensure_symbol_selected(client, symbol)
        tick = get_tick_snapshot(client, symbol)
        price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
        if price is None:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5OperationError(msg)
        request = {
            "action": client.mt5.TRADE_ACTION_DEAL,
            "symbol": symbol,
            "volume": volume,
            "type": (
                client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
            ),
            "price": price,
            "type_filling": _resolve_mt5_constant(
                client.mt5,
                "ORDER_FILLING",
                order_filling_mode,
                _ORDER_FILLING_MODES,
            ),
            "type_time": _resolve_mt5_constant(
                client.mt5,
                "ORDER_TIME",
                order_time_mode,
                _ORDER_TIME_MODES,
            ),
        }
        if sl is not None:
            request["sl"] = sl
        if tp is not None:
            request["tp"] = tp
        if position is not None:
            request["position"] = position
        if dry_run:
            return {
                "status": "dry_run",
                "symbol": symbol,
                "order_side": side,
                "volume": volume,
                "retcode": None,
                "comment": None,
                "request": cast("dict[str, object]", request),
                "response": None,
                "dry_run": True,
            }
        response = client.order_send(request)
        response_dict = _snapshot_from_value(response, ())
        raw_retcode = response_dict.get("retcode")
        retcode = _optional_int(raw_retcode)
        return {
            "status": _order_status_from_retcode(client.mt5, raw_retcode),
            "symbol": symbol,
            "order_side": side,
            "volume": volume,
            "retcode": retcode,
            "comment": _optional_str(response_dict.get("comment")),
            "request": cast("dict[str, object]", request),
            "response": response_dict,
            "dry_run": False,
        }

### update_sltp_for_open_positions

    update_sltp_for_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbol: str | None = None,
        tickets: list[int] | None = None,
        stop_loss: float | None = None,
        take_profit: float | None = None,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]

Update SL/TP for matching open positions.

Returns:

Type | Description
--- | ---
`list[OrderExecutionResult]` | Normalized execution results for matching positions.

Source code in `mt5cli/trading.py`

    1543
    1544
    1545
    1546
    1547
    1548
    1549
    1550
    1551
    1552
    1553
    1554
    1555
    1556
    1557
    1558
    1559
    1560
    1561
    1562
    1563
    1564
    1565
    1566
    1567
    1568
    1569
    1570
    1571
    1572
    1573
    1574
    1575
    1576
    1577
    1578
    1579
    1580
    1581
    1582
    1583
    1584
    1585
    1586
    1587
    1588
    1589
    1590
    1591
    1592
    1593
    1594
    1595
    1596
    1597
    1598
    1599
    1600
    1601
    1602
    1603
    1604
    def update_sltp_for_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbol: str | None = None,
        tickets: list[int] | None = None,
        stop_loss: float | None = None,
        take_profit: float | None = None,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]:
        """Update SL/TP for matching open positions.

        Returns:
            Normalized execution results for matching positions.
        """
        positions = _filter_positions(
            get_positions_frame(client),
            symbols=symbol,
            tickets=tickets,
        )
        results: list[OrderExecutionResult] = []
        for row in positions.to_dict("records"):
            request = {
                "action": client.mt5.TRADE_ACTION_SLTP,
                "symbol": row["symbol"],
                "position": row["ticket"],
            }
            sl = _optional_price(row.get("sl") if stop_loss is None else stop_loss)
            tp = _optional_price(row.get("tp") if take_profit is None else take_profit)
            if sl is not None:
                request["sl"] = sl
            if tp is not None:
                request["tp"] = tp
            if dry_run:
                response = None
                status: ExecutionStatus = "dry_run"
            else:
                ensure_symbol_selected(client, str(row["symbol"]))
                response = _snapshot_from_value(client.order_send(request), ())
                status = _order_status_from_retcode(
                    client.mt5,
                    response.get("retcode"),
                )
            results.append(
                {
                    "status": status,
                    "symbol": str(row["symbol"]),
                    "order_side": "BUY"
                    if row["type"] == client.mt5.POSITION_TYPE_BUY
                    else "SELL",
                    "volume": float(row["volume"]),
                    "retcode": None
                    if response is None
                    else _optional_int(response.get("retcode")),
                    "comment": None
                    if response is None
                    else _optional_str(response.get("comment")),
                    "request": cast("dict[str, object]", request),
                    "response": response,
                    "dry_run": dry_run,
                },
            )
        return results

### update_trailing_stop_loss_for_open_positions

    update_trailing_stop_loss_for_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        trailing_stop_ratio: float,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]

Update open positions whose trailing stop loss should move favorably.

Returns:

Type | Description
--- | ---
`list[OrderExecutionResult]` | Normalized execution results for positions that need an SL update.

Source code in `mt5cli/trading.py`

    1512
    1513
    1514
    1515
    1516
    1517
    1518
    1519
    1520
    1521
    1522
    1523
    1524
    1525
    1526
    1527
    1528
    1529
    1530
    1531
    1532
    1533
    1534
    1535
    1536
    1537
    1538
    1539
    1540
    def update_trailing_stop_loss_for_open_positions(
        client: _Mt5ClientProtocol,
        *,
        symbol: str,
        trailing_stop_ratio: float,
        dry_run: bool = False,
    ) -> list[OrderExecutionResult]:
        """Update open positions whose trailing stop loss should move favorably.

        Returns:
            Normalized execution results for positions that need an SL update.
        """
        updates = calculate_trailing_stop_updates(
            client,
            symbol=symbol,
            trailing_stop_ratio=trailing_stop_ratio,
        )
        results: list[OrderExecutionResult] = []
        for ticket, stop_loss in updates.items():
            results.extend(
                update_sltp_for_open_positions(
                    client,
                    symbol=symbol,
                    tickets=[ticket],
                    stop_loss=stop_loss,
                    dry_run=dry_run,
                ),
            )
        return results

## Trading-capable MT5 sessions

`create_trading_client()` and `mt5_trading_session()` complement the read-only `mt5_session()` helper in `sdk.py`. They return or yield an initialized client supporting order execution and account management, use `Mt5Config.path` to launch the terminal when configured, and `mt5_trading_session()` always calls `shutdown()` on exit.

    from mt5cli import create_trading_client, mt5_trading_session

    with mt5_trading_session(
        path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
        login="12345",
        password="secret",
        server="Broker-Demo",
        retry_count=2,
    ) as client:
        positions = client.positions_get_as_df(symbol="EURUSD")

    client = create_trading_client(login=12345, server="Broker-Demo")
    try:
        account = client.account_info_as_dict()
    finally:
        client.shutdown()

`login` accepts `int`, numeric `str`, or an empty string; empty strings are treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to `pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default. Use `mt5_session()` / `MT5Client` for read-only data collection.

## State and order helpers

These helpers are strategy-agnostic and do not depend on signal detection, betting logic, or scheduling code in downstream applications.

    from mt5cli import (
        calculate_positions_margin,
        calculate_spread_ratio,
        calculate_margin_and_volume,
        close_open_positions,
        detect_position_side,
        determine_order_limits,
        estimate_order_margin,
        fetch_latest_closed_rates_for_trading_client,
        fetch_latest_closed_rates_indexed,
        get_account_snapshot,
        get_positions_frame,
        get_symbol_snapshot,
        get_tick_snapshot,
        normalize_order_volume,
        place_market_order,
    )

    account = get_account_snapshot(client)
    symbol = get_symbol_snapshot(client, "EURUSD")
    tick = get_tick_snapshot(client, "EURUSD")
    positions = get_positions_frame(client, "EURUSD")
    side = detect_position_side(client, "EURUSD")
    spread_ratio = calculate_spread_ratio(client, "EURUSD")
    volume = normalize_order_volume(
        0.15,
        volume_min=symbol["volume_min"],
        volume_max=symbol["volume_max"],
        volume_step=symbol["volume_step"],
    )
    buy_margin = (
        estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
    )
    open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
    closed_bars = fetch_latest_closed_rates_for_trading_client(
        client,
        symbol="EURUSD",
        granularity="M1",
        count=100,
    )
    # Or fetch with a UTC DatetimeIndex instead of a "time" column:
    indexed_bars = fetch_latest_closed_rates_indexed(
        client,
        symbol="EURUSD",
        granularity="M1",
        count=100,
    )
    # indexed_bars.index is a UTC-aware DatetimeIndex named "time"
    sizing = calculate_margin_and_volume(
        client,
        "EURUSD",
        unit_margin_ratio=0.5,
        preserved_margin_ratio=0.2,
    )
    limits = determine_order_limits(
        client,
        "EURUSD",
        side="long",
        stop_loss_limit_ratio=0.01,
        take_profit_limit_ratio=0.02,
    )
    preview = place_market_order(
        client,
        symbol="EURUSD",
        volume=sizing["buy_volume"],
        order_side="BUY",
        sl=limits["stop_loss"],
        tp=limits["take_profit"],
        dry_run=True,
    )
    closed = close_open_positions(client, symbols="EURUSD", dry_run=True)

`detect_position_side()` returns `long` for buy-only exposure, `short` for sell-only exposure, and `None` for no positions or mixed long/short exposure. `calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises `Mt5OperationError` when bid or ask is missing or non-positive. `normalize_order_volume()` returns `0.0` for invalid constraints or sub-minimum requests; check the result before calling `estimate_order_margin()`, which requires a positive finite volume. `calculate_positions_margin()` silently skips rows with missing symbols, non-positive volumes, non-finite volumes, or unsupported position types, but propagates `Mt5OperationError` from `estimate_order_margin()` when a valid row encounters invalid tick data or margin results from the broker.

SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` omits that level. SL/TP prices are rounded with symbol `digits` metadata when available. `determine_order_limits()` pre-validates computed SL/TP prices against available `trade_stops_level * point` metadata when present; violations raise `Mt5OperationError`. This is a planning helper only: it does not guarantee broker acceptance because live validation can still depend on price movement, bid/ask side, freeze levels, and server-side rules, and it does not validate `trade_freeze_level`. When symbol metadata cannot be loaded, protective prices still round with `digits=8` and stop-level validation is skipped. `unit_margin_ratio` and `preserved_margin_ratio` for `calculate_margin_and_volume()` accept `0 <= ratio <= 1`; `unit_margin_ratio=0` requests one minimum valid unit when the post-reserve margin can afford it. Negative `margin_free` is clamped to `0.0` before sizing. Execution helpers return normalized `OrderExecutionResult` dictionaries containing the request, response, status, retcode, and `dry_run` flag; `dry_run=True` never sends an order or mutates Market Watch visibility. `ensure_symbol_selected()` adds hidden symbols to Market Watch before live order placement and SL/TP updates. Failed, malformed, or unknown broker retcodes are fail-closed and returned as `status="failed"` while keeping the normalized response for inspection.

## Order planning return contracts

    from mt5cli import MarginVolume, OrderLimits, OrderExecutionResult

    sizing: MarginVolume = calculate_margin_and_volume(
        client,
        "EURUSD",
        unit_margin_ratio=0.5,
        preserved_margin_ratio=0.2,
    )
    limits: OrderLimits = determine_order_limits(
        client,
        "EURUSD",
        side="long",
        stop_loss_limit_ratio=0.01,
        take_profit_limit_ratio=0.02,
    )
    preview: OrderExecutionResult = place_market_order(
        client,
        symbol="EURUSD",
        volume=sizing["buy_volume"],
        order_side="BUY",
        sl=limits["stop_loss"],
        tp=limits["take_profit"],
        dry_run=True,
    )
    updates: list[OrderExecutionResult] = update_sltp_for_open_positions(
        client,
        symbol="EURUSD",
        stop_loss=limits["stop_loss"],
        dry_run=True,
    )

Closes issue #33: strategy-neutral order planning and execution helpers exposed through the stable package root without embedding entry/exit policy.

## Migration from application-local helpers

Application-local concern | mt5cli replacement
--- | ---
Manual terminal spawn/kill around trading code | `mt5_trading_session()`
Local position-side detection | `detect_position_side()`
Local margin/volume sizing | `calculate_margin_and_volume()`
Local broker volume step normalization | `normalize_order_volume()`
Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()`
Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()`
Local SL/TP price derivation | `determine_order_limits()`
Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)`

Keep read-only data collection on `mt5_session()` / `MT5Client`; use `mt5_trading_session()` only where order placement or trading calculations are required.

#### Search

From here you can search these documents. Enter your search terms below.

#### Keyboard Shortcuts

Keys | Action
--- | ---
`?` | Open this help
`n` | Next page
`p` | Previous page
`s` | Search