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_positions_margin",
    "calculate_spread_ratio",
    "calculate_volume_by_margin",
    "close_open_positions",
    "create_trading_client",
    "detect_position_side",
    "determine_order_limits",
    "ensure_symbol_selected",
    "estimate_order_margin",
    "fetch_latest_closed_rates_for_trading_client",
    "get_account_snapshot",
    "get_positions_frame",
    "get_symbol_snapshot",
    "get_tick_snapshot",
    "mt5_trading_session",
    "normalize_order_volume",
    "place_market_order",
    "update_sltp_for_open_positions",
]

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_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
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
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_positions_margin

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

Return the sum of estimated current margin for open positions.

Parameters:

Name Type Description Default
client Mt5TradingClient

Connected Mt5TradingClient 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: Mt5TradingClient,
    *,
    symbols: Sequence[str] | None = None,
) -> float:
    """Return the sum of estimated current margin for open positions.

    Args:
        client: Connected ``Mt5TradingClient`` 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_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
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
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
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
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
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
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
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)

estimate_order_margin

estimate_order_margin(
    client: Mt5TradingClient,
    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
Mt5TradingError

If volume, tick data, or margin estimation is invalid.

Source code in mt5cli/trading.py
def estimate_order_margin(
    client: Mt5TradingClient,
    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:
        Mt5TradingError: 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 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 or not isfinite(price):
        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
    )
    raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
    try:
        margin = float(raw_margin)
    except (TypeError, ValueError) as exc:
        msg = f"Margin estimate is invalid for {symbol!r}."
        raise Mt5TradingError(msg) from exc
    if margin <= 0 or not isfinite(margin):
        msg = f"Margin estimate is invalid for {symbol!r}."
        raise Mt5TradingError(msg)
    return margin

fetch_latest_closed_rates_for_trading_client

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

Mt5TradingError

If the trading client cannot fetch rate data.

Source code in mt5cli/trading.py
def fetch_latest_closed_rates_for_trading_client(
    client: Mt5TradingClient,
    *,
    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.
        Mt5TradingError: If the trading client cannot fetch rate data.
    """
    if count <= 0:
        msg = "count must be positive."
        raise ValueError(msg)
    fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
    if callable(fetch_method):
        fetched = fetch_method(symbol, granularity, count + 1)
    else:
        copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
        if not callable(copy_method):
            msg = "MT5 trading client cannot fetch rate data."
            raise Mt5TradingError(msg)
        fetched = copy_method(
            symbol=symbol,
            timeframe=parse_timeframe(granularity),
            start_pos=0,
            count=count + 1,
        )
    if not isinstance(fetched, pd.DataFrame):
        msg = (
            f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
            "expected a DataFrame."
        )
        raise ValueError(msg)  # noqa: TRY004
    frame = fetched
    frame = _ensure_rate_time_column(frame)
    if "time" not in frame.columns:
        msg = f"Rate data is missing a time column for {symbol!r}."
        raise ValueError(msg)
    closed = drop_forming_rate_bar(frame)
    if closed.empty:
        msg = (
            f"Rate data is empty for {symbol!r} at granularity {granularity!r} "
            f"with count {count}."
        )
        raise ValueError(msg)
    return closed.tail(count).reset_index(drop=True)

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
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
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
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
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
@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()

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: 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
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
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_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,
    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,
)
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. 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 Mt5TradingError 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 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 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()
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.