| 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:
- 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,
- }
+ | 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:
+ 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,
+ }
|
@@ -1330,69 +1332,71 @@ side when the post-reserve margin can afford it.
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
+ | 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)
+ price = _valid_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 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
|
@@ -1492,37 +1496,7 @@ included.
Source code in mt5cli/trading.py
- 643
-644
-645
-646
-647
-648
-649
-650
-651
-652
-653
-654
-655
-656
-657
-658
-659
-660
-661
-662
-663
-664
-665
-666
-667
-668
-669
-670
-671
-672
-673
+ | 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
+682
+683
+684
+685
+686
+687
+688
+689
+690
+691
+692
+693
+694
+695
+696
+697
+698
+699
+700
+701
+702
+703
+704
+705
+706
+707
+708
+709
+710
+711
+712
| def calculate_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_positions_margin_by_symbol
+
+
+
+ calculate_positions_margin_by_symbol(
+ client: Mt5TradingClient,
+ *,
+ 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
+ |
+
+ Mt5TradingClient
+ |
+
+
+ Connected Mt5TradingClient instance.
+
+ |
+
+ required
+ |
+
+
+
+ symbols
+ |
+
+ Sequence[str]
+ |
+
+
+ Symbols to compute margin for.
+
+ |
+
+ required
+ |
+
+
+
+ suppress_errors
+ |
+
+ bool
+ |
+
+
+ When True, log and skip symbols that raise
+Mt5TradingError, 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 |
+
+
+
+
+
+ Mt5TradingError
+ |
+
+
+ When a symbol raises Mt5TradingError 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: Mt5TradingClient,
+ *,
+ 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 ``Mt5TradingClient`` instance.
+ symbols: Symbols to compute margin for.
+ suppress_errors: When ``True``, log and skip symbols that raise
+ ``Mt5TradingError``, ``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:
+ Mt5TradingError: When a symbol raises ``Mt5TradingError`` 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 (Mt5TradingError, 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: Mt5TradingClient, *, 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
+ |
+
+ Mt5TradingClient
+ |
+
+
+ Connected Mt5TradingClient instance.
+
+ |
+
+ required
+ |
+
+
+
+ symbols
+ |
+
+ Sequence[str]
+ |
+
+
+ |
+
+ 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: Mt5TradingClient,
+ *,
+ 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 ``Mt5TradingClient`` 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,
+ )
|
@@ -1620,37 +2030,37 @@ included.
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)
+ | 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)
|
@@ -1751,141 +2161,145 @@ included.
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:
- 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:
- 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)
- price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "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
- 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
+ | 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:
+ 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:
+ 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)
+ price = _valid_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 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
+ 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
|
@@ -1939,67 +2353,67 @@ included.
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
+ | 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
|
@@ -2033,57 +2447,57 @@ included.
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
+ | 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
|
@@ -2220,61 +2634,61 @@ included.
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
+ | 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
|
@@ -2470,161 +2884,161 @@ 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,
- }
+ | 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,
+ }
|
@@ -2722,12 +3136,7 @@ prices violate available trade_stops_level pre-validation.
Source code in mt5cli/trading.py
- 211
-212
-213
-214
-215
-216
+ | 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)
+234
+235
+236
+237
+238
+239
| 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)
|
@@ -2849,77 +3263,77 @@ prices violate available trade_stops_level pre-validation.
Source code in mt5cli/trading.py
- 605
-606
-607
-608
-609
-610
-611
-612
-613
-614
-615
-616
-617
-618
-619
-620
-621
-622
-623
-624
-625
-626
-627
-628
-629
-630
-631
-632
-633
-634
-635
+ | 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
+640
+641
+642
+643
+644
+645
+646
+647
+648
+649
+650
+651
+652
+653
+654
+655
+656
+657
+658
+659
+660
+661
+662
+663
+664
+665
+666
+667
+668
+669
+670
| def 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 = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
+ if price is None:
+ 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, 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
|
@@ -3007,111 +3421,111 @@ malformed, or the time column is missing.
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)
+ | 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)
|
@@ -3294,95 +3708,95 @@ is invalid or unparseable.
Source code in mt5cli/trading.py
- | def fetch_latest_closed_rates_indexed(
- client: Mt5TradingClient,
- *,
- 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
+ | def fetch_latest_closed_rates_indexed(
+ client: Mt5TradingClient,
+ *,
+ 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
|
@@ -3409,23 +3823,23 @@ is invalid or unparseable.
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),
- )
+ | 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),
+ )
|
@@ -3452,25 +3866,25 @@ is invalid or unparseable.
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
+ | 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
|
@@ -3497,25 +3911,25 @@ is invalid or unparseable.
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)
+ | 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)
|
@@ -3542,29 +3956,29 @@ is invalid or unparseable.
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)
+ | 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)
|
@@ -3754,95 +4168,95 @@ attaches to a running terminal.
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()
+ | @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()
|
@@ -3926,12 +4340,7 @@ attaches to a running terminal.
Source code in mt5cli/trading.py
- 296
-297
-298
-299
-300
-301
+ | 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)
+325
+326
+327
+328
+329
+330
| 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)
|
@@ -4086,187 +4500,187 @@ details for callers to inspect.
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,
- }
+ | 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 = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
+ if price is None:
+ 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": 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,
+ }
|
@@ -4322,129 +4736,129 @@ details for callers to inspect.
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
+ | 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
|
diff --git a/index.html b/index.html
index 0fe135c..cc2dab5 100644
--- a/index.html
+++ b/index.html
@@ -653,5 +653,5 @@
diff --git a/objects.inv b/objects.inv
index a823cf79490f64ae13f05396344551b6c4b15ae7..18b251f342899a0299db7342b74f8070d5f912b8 100644
GIT binary patch
delta 3545
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|