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",
    "magic",
)

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",
    "fetch_recent_history_deals_for_trading_client",
    "get_account_snapshot",
    "get_positions_frame",
    "get_symbol_snapshot",
    "get_tick_snapshot",
    "mt5_trading_session",
    "normalize_order_volume",
    "place_market_order",
    "resolve_broker_filling_mode",
    "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

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
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
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
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
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
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
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
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
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
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,
    magic: int | None = None,
) -> 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
def calculate_trailing_stop_updates(
    client: _Mt5ClientProtocol,
    *,
    symbol: str,
    trailing_stop_ratio: float,
    magic: int | None = None,
) -> 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 = _filter_positions(
        get_positions_frame(client, symbol=symbol), magic=magic
    )
    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
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,
    deviation: int | None = None,
    comment: str | None = None,
    magic: 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
def close_open_positions(
    client: _Mt5ClientProtocol,
    *,
    symbols: str | list[str] | None = None,
    tickets: list[int] | None = None,
    deviation: int | None = None,
    comment: str | None = None,
    magic: 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,
        magic=magic,
    )
    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"]),
            deviation=deviation,
            comment=comment,
            magic=magic,
            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,
) -> _TradingHistoryDealsClientProtocol

Return an initialized and logged-in trading client.

The returned object is a raw pdmt5.Mt5DataClient instance, not the higher-level mt5cli.MT5Client wrapper. Use mt5_session() / MT5Client for read-only data collection. For live trading helpers (margin, volume, order execution, position management) pass the returned client to the strategy-agnostic helpers in this module.

For history deal retrieval use :func:fetch_recent_history_deals_for_trading_client; the returned client satisfies :class:_HistoryDealsClientProtocol so no additional wrapping is required.

Returns:

Type Description
_TradingHistoryDealsClientProtocol

A pdmt5.Mt5DataClient instance satisfying _Mt5ClientProtocol

_TradingHistoryDealsClientProtocol

and _HistoryDealsClientProtocol. Caller is responsible for calling

_TradingHistoryDealsClientProtocol

client.shutdown() when done; prefer mt5_trading_session() to

_TradingHistoryDealsClientProtocol

manage lifetime automatically.

Source code in mt5cli/trading.py
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,
) -> _TradingHistoryDealsClientProtocol:
    """Return an initialized and logged-in trading client.

    The returned object is a raw ``pdmt5.Mt5DataClient`` instance, not the
    higher-level ``mt5cli.MT5Client`` wrapper. Use ``mt5_session()`` /
    ``MT5Client`` for read-only data collection. For live trading helpers
    (margin, volume, order execution, position management) pass the returned
    client to the strategy-agnostic helpers in this module.

    For history deal retrieval use
    :func:`fetch_recent_history_deals_for_trading_client`; the returned client
    satisfies :class:`_HistoryDealsClientProtocol` so no additional wrapping is
    required.

    Returns:
        A ``pdmt5.Mt5DataClient`` instance satisfying ``_Mt5ClientProtocol``
        and ``_HistoryDealsClientProtocol``. Caller is responsible for calling
        ``client.shutdown()`` when done; prefer ``mt5_trading_session()`` to
        manage lifetime automatically.
    """
    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,
    *,
    magic: int | None = None,
) -> 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
magic int | None

Optional magic number filter applied fail-closed.

None

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
def detect_position_side(
    client: _Mt5ClientProtocol,
    symbol: str,
    *,
    magic: int | None = None,
) -> PositionSide | None:
    """Detect the net open position side for a symbol.

    Args:
        client: Connected MT5 client instance.
        symbol: Symbol to inspect.
        magic: Optional magic number filter applied fail-closed.

    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 = _filter_positions(
        get_positions_frame(client, symbol=symbol), magic=magic
    )
    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
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
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
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
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
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)
    timeframe = parse_timeframe(granularity)
    fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
    copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
    if callable(fetch_method):
        fetched = fetch_method(symbol, granularity, count + 1)
    elif callable(copy_method):
        fetched = copy_method(
            symbol=symbol, timeframe=timeframe, start_pos=0, count=count + 1
        )
    else:
        msg = "MT5 trading client cannot fetch rate data."
        raise Mt5OperationError(msg)
    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
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

fetch_recent_history_deals_for_trading_client

fetch_recent_history_deals_for_trading_client(
    client: _HistoryDealsClientProtocol,
    *,
    symbol: str | None = None,
    group: str | None = None,
    hours: float = 24.0,
    date_to: datetime | None = None,
) -> DataFrame

Fetch recent history deals from an already-connected trading client.

Computes a trailing window ending at date_to (or datetime.now(UTC) when omitted) and delegates to the client's history_deals_get_as_df method. The object returned by :func:create_trading_client (a raw pdmt5.Mt5DataClient) satisfies this protocol directly. Note that mt5cli.sdk.Mt5CliClient (used via mt5_session()) exposes history_deals(), not history_deals_get_as_df(), and therefore does not satisfy this protocol; use this helper with trading-client sessions only.

The returned DataFrame preserves every column from the underlying client (time, symbol, type, entry, volume, profit, position_id, etc.). No strategy-specific transformations are applied; downstream packages own entry/exit classification, Kelly fractions, and any other betting or signal semantics.

Parameters:

Name Type Description Default
client _HistoryDealsClientProtocol

Connected pdmt5.Mt5DataClient (or compatible) with history_deals_get_as_df capability, as returned by :func:create_trading_client.

required
symbol str | None

Optional symbol filter passed to the underlying client.

None
group str | None

Optional symbol group filter passed to the underlying client.

None
hours float

Trailing window length in hours. Must be positive.

24.0
date_to datetime | None

Window end timestamp. Defaults to datetime.now(UTC).

None

Returns:

Type Description
DataFrame

DataFrame ordered chronologically by time (when the column

DataFrame

exists) with a RangeIndex. Schema-preserving empty DataFrames

DataFrame

(zero rows but columns present) are passed through with a reset

DataFrame

index. Returns a bare empty DataFrame only when the underlying

DataFrame

client returns None.

Raises:

Type Description
ValueError

If hours is not positive.

Example::

from mt5cli import (
    create_trading_client,
    fetch_recent_history_deals_for_trading_client,
)

client = create_trading_client(login=12345, server="Broker-Demo")
try:
    deals_df = fetch_recent_history_deals_for_trading_client(
        client,
        symbol="JP225",
        hours=24,
    )
finally:
    client.shutdown()
Source code in mt5cli/trading.py
def fetch_recent_history_deals_for_trading_client(
    client: _HistoryDealsClientProtocol,
    *,
    symbol: str | None = None,
    group: str | None = None,
    hours: float = 24.0,
    date_to: datetime | None = None,
) -> pd.DataFrame:
    """Fetch recent history deals from an already-connected trading client.

    Computes a trailing window ending at ``date_to`` (or ``datetime.now(UTC)``
    when omitted) and delegates to the client's ``history_deals_get_as_df``
    method. The object returned by :func:`create_trading_client` (a raw
    ``pdmt5.Mt5DataClient``) satisfies this protocol directly. Note that
    ``mt5cli.sdk.Mt5CliClient`` (used via ``mt5_session()``) exposes
    ``history_deals()``, not ``history_deals_get_as_df()``, and therefore does
    not satisfy this protocol; use this helper with trading-client sessions only.

    The returned DataFrame preserves every column from the underlying client
    (``time``, ``symbol``, ``type``, ``entry``, ``volume``, ``profit``,
    ``position_id``, etc.). No strategy-specific transformations are applied;
    downstream packages own entry/exit classification, Kelly fractions, and
    any other betting or signal semantics.

    Args:
        client: Connected ``pdmt5.Mt5DataClient`` (or compatible) with
            ``history_deals_get_as_df`` capability, as returned by
            :func:`create_trading_client`.
        symbol: Optional symbol filter passed to the underlying client.
        group: Optional symbol group filter passed to the underlying client.
        hours: Trailing window length in hours. Must be positive.
        date_to: Window end timestamp. Defaults to ``datetime.now(UTC)``.

    Returns:
        DataFrame ordered chronologically by ``time`` (when the column
        exists) with a ``RangeIndex``. Schema-preserving empty DataFrames
        (zero rows but columns present) are passed through with a reset
        index. Returns a bare empty DataFrame only when the underlying
        client returns ``None``.

    Raises:
        ValueError: If ``hours`` is not positive.

    Example::

        from mt5cli import (
            create_trading_client,
            fetch_recent_history_deals_for_trading_client,
        )

        client = create_trading_client(login=12345, server="Broker-Demo")
        try:
            deals_df = fetch_recent_history_deals_for_trading_client(
                client,
                symbol="JP225",
                hours=24,
            )
        finally:
            client.shutdown()
    """
    if not isfinite(hours) or hours <= 0:
        msg = "hours must be finite and positive."
        raise ValueError(msg)
    end = date_to if date_to is not None else datetime.now(UTC)
    start = end - timedelta(hours=hours)
    raw = client.history_deals_get_as_df(
        date_from=start,
        date_to=end,
        group=group,
        symbol=symbol,
    )
    if raw is None:
        return pd.DataFrame()
    if raw.empty:
        return raw.reset_index(drop=True)
    if "time" in raw.columns:
        raw = raw.sort_values("time")
    return raw.reset_index(drop=True)

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
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
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
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
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[_TradingHistoryDealsClientProtocol]

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
_TradingHistoryDealsClientProtocol

Connected client supporting required MT5 trading methods.

Source code in mt5cli/trading.py
@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[_TradingHistoryDealsClientProtocol]:
    """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
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,
    deviation: int | None = None,
    comment: str | None = None,
    magic: 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
def place_market_order(  # noqa: C901, PLR0913
    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,
    deviation: int | None = None,
    comment: str | None = None,
    magic: 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 deviation is not None:
        request["deviation"] = deviation
    if comment is not None:
        request["comment"] = comment
    if magic is not None:
        request["magic"] = magic
    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,
    }

resolve_broker_filling_mode

resolve_broker_filling_mode(
    client: _Mt5ClientProtocol,
    *,
    symbol: str,
    preferred_modes: Sequence[OrderFillingMode] = (
        "IOC",
        "FOK",
        "RETURN",
    ),
    default_mode: OrderFillingMode = "IOC",
) -> OrderFillingMode

Return the first broker-supported filling mode from a preferred order.

Raises:

Type Description
ValueError

If any preferred or default mode name is unsupported.

Source code in mt5cli/trading.py
def resolve_broker_filling_mode(
    client: _Mt5ClientProtocol,
    *,
    symbol: str,
    preferred_modes: Sequence[OrderFillingMode] = ("IOC", "FOK", "RETURN"),
    default_mode: OrderFillingMode = "IOC",
) -> OrderFillingMode:
    """Return the first broker-supported filling mode from a preferred order.

    Raises:
        ValueError: If any preferred or default mode name is unsupported.
    """
    preferred = [mode.upper() for mode in preferred_modes]
    for mode in [*preferred, default_mode]:
        if mode not in _ORDER_FILLING_MODES:
            msg = f"Unsupported order_filling mode: {mode!r}."
            raise ValueError(msg)

    preferred_default = cast(
        "OrderFillingMode", preferred[0] if preferred else default_mode
    )
    supported = _supported_filling_modes(
        client,
        symbol=symbol,
        preferred_default=preferred_default,
    )
    if supported is None:
        return preferred_default

    for mode in preferred:
        if mode in supported:
            return cast("OrderFillingMode", mode)
    fallback_mode = next(
        mode for mode in (default_mode, "IOC", "FOK", "RETURN") if mode in supported
    )
    return cast("OrderFillingMode", fallback_mode)

update_sltp_for_open_positions

update_sltp_for_open_positions(
    client: _Mt5ClientProtocol,
    *,
    symbol: str | None = None,
    tickets: list[int] | None = None,
    magic: 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
def update_sltp_for_open_positions(
    client: _Mt5ClientProtocol,
    *,
    symbol: str | None = None,
    tickets: list[int] | None = None,
    magic: 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,
        magic=magic,
    )
    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,
    magic: int | None = None,
    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
def update_trailing_stop_loss_for_open_positions(
    client: _Mt5ClientProtocol,
    *,
    symbol: str,
    trailing_stop_ratio: float,
    magic: int | None = None,
    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,
        magic=magic,
    )
    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,
                magic=magic,
                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.

create_trading_client() returns a raw pdmt5.Mt5DataClient instance, not the higher-level MT5Client wrapper. Use mt5_session() / MT5Client for read-only data collection; use mt5_trading_session() only where order placement or trading calculations are required.

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.

Retrieving recent history deals

fetch_recent_history_deals_for_trading_client() fetches history deals from an already-connected trading client over a trailing time window. It works directly with the object returned by create_trading_client() (a raw pdmt5.Mt5DataClient) without requiring any additional wrapping.

The helper returns a chronologically sorted DataFrame with a RangeIndex and all columns from the underlying client (time, symbol, type, entry, volume, profit, position_id, etc.). It does not apply any strategy-specific transformations — entry/exit classification, Kelly fractions, and betting semantics belong in downstream applications.

from mt5cli import (
    create_trading_client,
    fetch_recent_history_deals_for_trading_client,
)

client = create_trading_client(login=12345, server="Broker-Demo")
try:
    deals_df = fetch_recent_history_deals_for_trading_client(
        client,
        symbol="JP225",
        hours=24,
    )
finally:
    client.shutdown()

Or inside a managed session:

from mt5cli import fetch_recent_history_deals_for_trading_client, mt5_trading_session

with mt5_trading_session(login=12345, server="Broker-Demo") as client:
    deals_df = fetch_recent_history_deals_for_trading_client(
        client,
        symbol="JP225",
        hours=48,
    )

hours must be positive; date_to defaults to datetime.now(UTC). An empty or None result from the underlying client is normalized to an empty DataFrame.

Downstream packages own all strategy-specific transformations. mt5cli does not provide entry-deal classification, Kelly sizing, or any betting-specific helpers.

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 recent deal history fetch from a trading session fetch_recent_history_deals_for_trading_client()
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.