Trading Module

mt5cli.trading

Trading-capable MetaTrader 5 session helpers and operational utilities.

ExecutionStatus module-attribute

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

OrderFillingMode module-attribute

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

OrderSide module-attribute

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

OrderTimeMode module-attribute

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

POSITION_COLUMNS module-attribute

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

PositionSide module-attribute

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

__all__ module-attribute

__all__ = [
    "POSITION_COLUMNS",
    "ExecutionStatus",
    "MarginVolume",
    "OrderExecutionResult",
    "OrderFillingMode",
    "OrderLimits",
    "OrderSide",
    "OrderTimeMode",
    "PositionSide",
    "calculate_margin_and_volume",
    "calculate_new_position_margin_ratio",
    "calculate_spread_ratio",
    "calculate_volume_by_margin",
    "close_open_positions",
    "create_trading_client",
    "detect_position_side",
    "determine_order_limits",
    "ensure_symbol_selected",
    "get_account_snapshot",
    "get_positions_frame",
    "get_symbol_snapshot",
    "get_tick_snapshot",
    "mt5_trading_session",
    "place_market_order",
    "update_sltp_for_open_positions",
]

MarginVolume

Bases: TypedDict

Affordable volume bounds derived from account margin and symbol constraints.

available_margin instance-attribute

available_margin: float

buy_volume instance-attribute

buy_volume: float

margin_free instance-attribute

margin_free: float

sell_volume instance-attribute

sell_volume: float

trade_margin instance-attribute

trade_margin: float

volume_max instance-attribute

volume_max: float

volume_min instance-attribute

volume_min: float

volume_step instance-attribute

volume_step: float

OrderExecutionResult

Bases: TypedDict

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

comment instance-attribute

comment: str | None

dry_run instance-attribute

dry_run: bool

order_side instance-attribute

order_side: OrderSide

request instance-attribute

request: dict[str, object]

response instance-attribute

response: dict[str, object] | None

retcode instance-attribute

retcode: int | None

status instance-attribute

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

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

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_spread_ratio,
    calculate_margin_and_volume,
    close_open_positions,
    detect_position_side,
    determine_order_limits,
    get_account_snapshot,
    get_positions_frame,
    get_symbol_snapshot,
    get_tick_snapshot,
    place_market_order,
)

account = get_account_snapshot(client)
symbol = get_symbol_snapshot(client, "EURUSD")
tick = get_tick_snapshot(client, "EURUSD")
positions = get_positions_frame(client, "EURUSD")
side = detect_position_side(client, "EURUSD")
spread_ratio = calculate_spread_ratio(client, "EURUSD")
sizing = calculate_margin_and_volume(
    client,
    "EURUSD",
    unit_margin_ratio=0.5,
    preserved_margin_ratio=0.2,
)
limits = determine_order_limits(
    client,
    "EURUSD",
    side="long",
    stop_loss_limit_ratio=0.01,
    take_profit_limit_ratio=0.02,
)
preview = place_market_order(
    client,
    symbol="EURUSD",
    volume=sizing["buy_volume"],
    order_side="BUY",
    sl=limits["stop_loss"],
    tp=limits["take_profit"],
    dry_run=True,
)
closed = close_open_positions(client, symbols="EURUSD", dry_run=True)

detect_position_side() returns long for buy-only exposure, short for sell-only exposure, and None for no positions or mixed long/short exposure. calculate_spread_ratio() uses (ask - bid) / ((ask + bid) / 2) and raises Mt5TradingError when bid or ask is missing or non-positive.

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

Order planning return contracts

from mt5cli import MarginVolume, OrderLimits, OrderExecutionResult

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

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

Migration from application-local helpers

Application-local concern mt5cli replacement
Manual terminal spawn/kill around trading code mt5_trading_session()
Local position-side detection detect_position_side()
Local margin/volume sizing calculate_margin_and_volume()
Local SL/TP price derivation determine_order_limits()
Throttled SQLite history loop with ad-hoc error handling ThrottledHistoryUpdater(suppress_errors=True)

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