# Trading - mt5cli API Documentation
Author: dceoy

# 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']

### __all__ `module-attribute`

    __all__ = [
        "POSITION_COLUMNS",
        "ExecutionStatus",
        "MarginVolume",
        "OrderExecutionResult",
        "OrderFillingMode",
        "OrderLimits",
        "OrderSide",
        "OrderTimeMode",
        "PositionSide",
        "calculate_margin_and_volume",
        "calculate_new_position_margin_ratio",
        "calculate_spread_ratio",
        "calculate_volume_by_margin",
        "close_open_positions",
        "create_trading_client",
        "detect_position_side",
        "determine_order_limits",
        "ensure_symbol_selected",
        "get_account_snapshot",
        "get_positions_frame",
        "get_symbol_snapshot",
        "get_tick_snapshot",
        "mt5_trading_session",
        "place_market_order",
        "update_sltp_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_margin_and_volume

    calculate_margin_and_volume(
        client: Mt5TradingClient,
        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` | `Mt5TradingClient` | Connected `Mt5TradingClient` 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`

    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    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
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670

    def calculate_margin_and_volume(
        client: Mt5TradingClient,
        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 ``Mt5TradingClient`` 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:
            native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
            if callable(native_calculate_volume):
                buy_volume = float(
                    cast(
                        "float | int | str",
                        native_calculate_volume(symbol, trade_margin, "BUY"),
                    ),
                )
                sell_volume = float(
                    cast(
                        "float | int | str",
                        native_calculate_volume(symbol, trade_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: Mt5TradingClient,
        *,
        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
--- | ---
`Mt5TradingError` | If equity or required tick data is invalid.

Source code in `mt5cli/trading.py`

    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583

    def calculate_new_position_margin_ratio(
        client: Mt5TradingClient,
        *,
        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:
            Mt5TradingError: 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 Mt5TradingError(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)
            tick = get_tick_snapshot(client, symbol)
            price = tick["ask"] if side == "BUY" else tick["bid"]
            if not isinstance(price, int | float) or price <= 0:
                msg = f"Tick price is unavailable for {symbol!r}."
                raise Mt5TradingError(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_spread_ratio

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

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

Raises:

Type | Description
--- | ---
`Mt5TradingError` | If bid or ask is unavailable or non-positive.

Source code in `mt5cli/trading.py`

    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549

    def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
        """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.

        Raises:
            Mt5TradingError: If bid or ask is unavailable or non-positive.
        """
        tick = get_tick_snapshot(client, symbol)
        bid = tick.get("bid")
        ask = tick.get("ask")
        if not isinstance(bid, int | float) or not isinstance(ask, int | float):
            msg = f"Tick bid/ask is unavailable for {symbol!r}."
            raise Mt5TradingError(msg)
        if bid <= 0 or ask <= 0:
            msg = f"Tick bid/ask must be positive for {symbol!r}."
            raise Mt5TradingError(msg)
        return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)

### calculate_volume_by_margin

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

Calculate max normalized volume affordable for one side.

Returns:

Type | Description
--- | ---
`float` | Affordable volume rounded down to symbol volume constraints.

Raises:

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

Source code in `mt5cli/trading.py`

    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    701
    702
    703
    704
    705
    706
    707
    708
    709
    710
    711
    712

    def calculate_volume_by_margin(
        client: Mt5TradingClient,
        symbol: str,
        available_margin: float,
        order_side: OrderSide,
    ) -> float:
        """Calculate max normalized volume affordable for one side.

        Returns:
            Affordable volume rounded down to symbol volume constraints.

        Raises:
            Mt5TradingError: 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 Mt5TradingError(msg)
        side = _normalize_order_side(order_side)
        tick = get_tick_snapshot(client, symbol)
        price = tick["ask"] if side == "BUY" else tick["bid"]
        if not isinstance(price, int | float) or price <= 0:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5TradingError(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
        raw_volume = available_margin / min_margin * volume_min
        capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
        steps = floor(((capped - volume_min) / volume_step) + 1e-12)
        normalized = volume_min + max(0, steps) * volume_step
        return round(normalized, 10) if normalized >= volume_min else 0.0

### close_open_positions

    close_open_positions(
        client: Mt5TradingClient,
        *,
        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`

    903
    904
    905
    906
    907
    908
    909
    910
    911
    912
    913
    914
    915
    916
    917
    918
    919
    920
    921
    922
    923
    924
    925
    926
    927
    928
    929
    930
    931
    932
    933

    def close_open_positions(
        client: Mt5TradingClient,
        *,
        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,
    ) -> Mt5TradingClient

Return an initialized and logged-in trading client.

Source code in `mt5cli/trading.py`

    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    451
    452

    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,
    ) -> Mt5TradingClient:
        """Return an initialized and logged-in trading client."""
        mt5_config = _resolve_config(
            config=config,
            login=login,
            password=password,
            server=server,
            path=path,
            timeout=timeout,
        )
        client = Mt5TradingClient(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: Mt5TradingClient, symbol: str
    ) -> PositionSide | None

Detect the net open position side for a symbol.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`client` | `Mt5TradingClient` | Connected `Mt5TradingClient` 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`

    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482

    def detect_position_side(
        client: Mt5TradingClient,
        symbol: str,
    ) -> PositionSide | None:
        """Detect the net open position side for a symbol.

        Args:
            client: Connected ``Mt5TradingClient`` 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: Mt5TradingClient,
        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` | `Mt5TradingClient` | Connected `Mt5TradingClient` 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
--- | ---
`Mt5TradingError` | If required tick data is invalid or computed SL/TP prices violate available `trade_stops_level` pre-validation.

Source code in `mt5cli/trading.py`

    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
    741
    742
    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
    783
    784
    785
    786
    787
    788
    789
    790
    791
    792

    def determine_order_limits(
        client: Mt5TradingClient,
        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 ``Mt5TradingClient`` 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:
            Mt5TradingError: 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_value = tick["ask"] if normalized_side == "long" else tick["bid"]
        if not isinstance(entry_value, int | float):
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5TradingError(msg)
        entry = float(entry_value)
        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: Mt5TradingClient, symbol: str
    ) -> None

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

Parameters:

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

Raises:

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

Source code in `mt5cli/trading.py`

    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227

    def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
        """Ensure a symbol is visible in Market Watch before sending orders.

        Args:
            client: Connected ``Mt5TradingClient`` instance.
            symbol: Symbol to select.

        Raises:
            Mt5TradingError: 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 Mt5TradingError(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 Mt5TradingError(msg)

### get_account_snapshot

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

Return normalized account state with stable keys.

Source code in `mt5cli/trading.py`

    485
    486
    487
    488
    489
    490
    491
    492
    493

    def get_account_snapshot(
        client: Mt5TradingClient,
    ) -> 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: Mt5TradingClient, symbol: str | None = None
    ) -> DataFrame

Return open positions as a DataFrame with stable baseline columns.

Source code in `mt5cli/trading.py`

    522
    523
    524
    525
    526
    527
    528
    529
    530
    531

    def get_positions_frame(
        client: Mt5TradingClient,
        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: Mt5TradingClient, symbol: str
    ) -> dict[str, float | int | str | bool | None]

Return normalized symbol metadata required for trading decisions.

Source code in `mt5cli/trading.py`

    496
    497
    498
    499
    500
    501
    502
    503
    504
    505

    def get_symbol_snapshot(
        client: Mt5TradingClient,
        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: Mt5TradingClient, symbol: str
    ) -> dict[str, float | int | None]

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

Source code in `mt5cli/trading.py`

    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519

    def get_tick_snapshot(
        client: Mt5TradingClient,
        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[Mt5TradingClient]

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 :class:`~pdmt5.Mt5TradingClient`, 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 passed to `Mt5TradingClient`. | `0`

Yields:

Type | Description
--- | ---
`Mt5TradingClient` | Connected `Mt5TradingClient` bound to the session.

Source code in `mt5cli/trading.py`

    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

    @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[Mt5TradingClient]:
        """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 :class:`~pdmt5.Mt5TradingClient`, 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 passed to
                ``Mt5TradingClient``.

        Yields:
            Connected ``Mt5TradingClient`` bound to the session.
        """
        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()

### place_market_order

    place_market_order(
        client: Mt5TradingClient,
        *,
        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.

`pdmt5.Mt5TradingClient.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
--- | ---
`Mt5TradingError` | If volume or required tick data is invalid.

Source code in `mt5cli/trading.py`

    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
    826
    827
    828
    829
    830
    831
    832
    833
    834
    835
    836
    837
    838
    839
    840
    841
    842
    843
    844
    845
    846
    847
    848
    849
    850
    851
    852
    853
    854
    855
    856
    857
    858
    859
    860
    861
    862
    863
    864
    865
    866
    867
    868
    869
    870
    871
    872
    873
    874
    875
    876
    877
    878
    879
    880
    881
    882
    883
    884
    885

    def place_market_order(
        client: Mt5TradingClient,
        *,
        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.

        ``pdmt5.Mt5TradingClient.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:
            Mt5TradingError: If volume or required tick data is invalid.
        """
        if volume <= 0:
            msg = "volume must be positive."
            raise Mt5TradingError(msg)
        side = _normalize_order_side(order_side)
        if not dry_run:
            ensure_symbol_selected(client, symbol)
        tick = get_tick_snapshot(client, symbol)
        price = tick["ask"] if side == "BUY" else tick["bid"]
        if not isinstance(price, int | float) or price <= 0:
            msg = f"Tick price is unavailable for {symbol!r}."
            raise Mt5TradingError(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": float(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: Mt5TradingClient,
        *,
        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`

    936
    937
    938
    939
    940
    941
    942
    943
    944
    945
    946
    947
    948
    949
    950
    951
    952
    953
    954
    955
    956
    957
    958
    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
    986
    987
    988
    989
    990
    991
    992
    993
    994
    995
    996
    997

    def update_sltp_for_open_positions(
        client: Mt5TradingClient,
        *,
        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

## 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 `pdmt5.Mt5TradingClient`, 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. The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.

## 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_spread_ratio,
        calculate_margin_and_volume,
        close_open_positions,
        detect_position_side,
        determine_order_limits,
        get_account_snapshot,
        get_positions_frame,
        get_symbol_snapshot,
        get_tick_snapshot,
        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")
    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 `Mt5TradingError` when bid or ask is missing or non-positive.

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 `Mt5TradingError`. 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 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()` / `Mt5CliClient`; 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