fix: decouple mt5cli from pdmt5 high-level trading helpers (#76)

* fix: decouple mt5cli from pdmt5 high-level trading helpers

- Replace Mt5TradingClient type annotations with internal _Mt5ClientProtocol
- Lazy-import Mt5TradingClient in create_trading_client to avoid hard dependency
- Replace Mt5TradingError with Mt5OperationError in mt5cli validation paths
- Update exception handling to support future pdmt5 versions without Mt5TradingError
- Add test to enforce that mt5cli doesn't import high-level symbols at module level
- Update documentation to clarify dependency boundaries

mt5cli now relies only on low-level MT5 primitives:
- Mt5Config for configuration
- Mt5RuntimeError for runtime errors
- Raw MT5 methods (order_send, order_check, account_info, etc.)

This aligns with pdmt5's direction to remove high-level trading helpers and focus
on low-level MT5 access plus DataFrame/dict conversion.

Fixes #75 (dceoy/mt5cli#75)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcGVFTVgyqzse3LLw38ber

* fix: address PR #76 review feedback on pdmt5 decoupling

- Replace Mt5TradingClient with Mt5DataClient in create_trading_client()
  so the function no longer depends on the high-level trading client
- Fix _RECOVERABLE_MT5_ERRORS in exceptions.py to use tuple unpacking
  form, removing the incorrect ternary assignment
- Add pragma: no cover to except ImportError branches in exceptions.py
  and sdk.py (dead code when pdmt5 is installed)
- Switch coverage exclude_lines to exclude_also so the default
  pragma: no cover pattern is preserved; also exclude bare ... stubs
  (Protocol method bodies) from coverage
- Correct inaccurate note in docs/api/public-contract.md: Mt5TradingClient
  is no longer required internally; Mt5TradingError is conditionally
  available but mt5cli raises Mt5OperationError for trading failures
- Update all mock patches from pdmt5.Mt5TradingClient to
  mt5cli.trading.Mt5DataClient to match the new module-level import

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Daichi Narushima
2026-06-26 22:26:54 +09:00
committed by GitHub
co-authored by Claude
parent f435544f07
commit 93565681e1
9 changed files with 248 additions and 156 deletions
+2 -2
View File
@@ -246,7 +246,7 @@ update_history_with_config(
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`. - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame. - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets. - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted. - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only recoverable MT5 errors and re-raises once `retry_count` is exhausted.
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`. - **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
```python ```python
@@ -263,7 +263,7 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli. - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`. - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`. - **Trading session helpers**: use `mt5_trading_session()` for a trading-capable client that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate. - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit. - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns. - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
+9 -6
View File
@@ -16,9 +16,12 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
| **downstream** | Strategy logic; signals; risk policy; backtesting; optimization; YAML/application semantics | | **downstream** | Strategy logic; signals; risk policy; backtesting; optimization; YAML/application semantics |
Downstream code should import raw pdmt5 types and constants (such as Downstream code should import raw pdmt5 types and constants (such as
`Mt5Config`, `Mt5TradingClient`, `Mt5RuntimeError`, `Mt5TradingError`, `Mt5Config`, `Mt5RuntimeError`, `TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly
`TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly from `pdmt5` when needed. from `pdmt5` when needed. mt5cli does not serve as a pass-through compatibility
mt5cli does not serve as a pass-through compatibility namespace for pdmt5. namespace for pdmt5. mt5cli's trading helpers type their client parameter against
an internal protocol backed by `pdmt5.Mt5DataClient`; `Mt5TradingClient` is no
longer required. `Mt5TradingError` is conditionally imported where still present
in pdmt5, but mt5cli raises `Mt5OperationError` for all trading-related failures.
Note: the former `mt5cli` re-export `TICK_FLAG_MAP` corresponds to `COPY_TICKS_MAP` Note: the former `mt5cli` re-export `TICK_FLAG_MAP` corresponds to `COPY_TICKS_MAP`
in pdmt5 — the name changed, it was not simply moved. in pdmt5 — the name changed, it was not simply moved.
@@ -42,7 +45,7 @@ These names are exported from `mt5cli` and enumerated in
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` | | `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` | | `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
| `mt5_session` | Context manager: initialize, login, yield client, shutdown | | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle | | `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a client supporting order execution and account management |
| `AccountSpec` | Generic account group: symbols plus optional credentials | | `AccountSpec` | Generic account group: symbols plus optional credentials |
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` | | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
@@ -56,7 +59,7 @@ timestamp normalization in downstream apps.
| ------------------------------------------------ | ------------------------------------------------------------------------------- | | ------------------------------------------------ | ------------------------------------------------------------------------------- |
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data | | `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar | | `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex | | `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active trading client session; returns RangeIndex |
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) | | `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper | | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` | | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
@@ -111,7 +114,7 @@ strategy policy.
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
Order helpers validate broker stop-level distance in `determine_order_limits()` and Order helpers validate broker stop-level distance in `determine_order_limits()` and
raise `Mt5TradingError` when computed SL/TP prices are too close to the entry raise `Mt5OperationError` when computed SL/TP prices are too close to the entry
quote. Validation uses `trade_stops_level * point` from the current quote and quote. Validation uses `trade_stops_level * point` from the current quote and
symbol metadata as a pre-check only; it does not guarantee live order acceptance symbol metadata as a pre-check only; it does not guarantee live order acceptance
after price movement and does not inspect `trade_freeze_level`. Live after price movement and does not inspect `trade_freeze_level`. Live
+6 -5
View File
@@ -6,8 +6,9 @@
`create_trading_client()` and `mt5_trading_session()` complement the read-only `create_trading_client()` and `mt5_trading_session()` complement the read-only
`mt5_session()` helper in `sdk.py`. They return or yield an initialized `mt5_session()` helper in `sdk.py`. They return or yield an initialized
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when client supporting order execution and account management, use `Mt5Config.path`
configured, and `mt5_trading_session()` always calls `shutdown()` on exit. to launch the terminal when configured, and `mt5_trading_session()` always
calls `shutdown()` on exit.
```python ```python
from mt5cli import create_trading_client, mt5_trading_session from mt5cli import create_trading_client, mt5_trading_session
@@ -115,19 +116,19 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
`detect_position_side()` returns `long` for buy-only exposure, `short` for `detect_position_side()` returns `long` for buy-only exposure, `short` for
sell-only exposure, and `None` for no positions or mixed long/short exposure. sell-only exposure, and `None` for no positions or mixed long/short exposure.
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises `calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
`Mt5TradingError` when bid or ask is missing or non-positive. `Mt5OperationError` when bid or ask is missing or non-positive.
`normalize_order_volume()` returns `0.0` for invalid constraints or `normalize_order_volume()` returns `0.0` for invalid constraints or
sub-minimum requests; check the result before calling `estimate_order_margin()`, sub-minimum requests; check the result before calling `estimate_order_margin()`,
which requires a positive finite volume. `calculate_positions_margin()` silently which requires a positive finite volume. `calculate_positions_margin()` silently
skips rows with missing symbols, non-positive volumes, non-finite volumes, or skips rows with missing symbols, non-positive volumes, non-finite volumes, or
unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row unsupported position types, but propagates `Mt5OperationError` from `estimate_order_margin()` when a valid row
encounters invalid tick data or margin results from the broker. encounters invalid tick data or margin results from the broker.
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` 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 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. `determine_order_limits()` pre-validates computed SL/TP prices against
available `trade_stops_level * point` metadata when present; violations raise available `trade_stops_level * point` metadata when present; violations raise
`Mt5TradingError`. This is a planning helper only: it does not guarantee broker `Mt5OperationError`. This is a planning helper only: it does not guarantee broker
acceptance because live validation can still depend on price movement, bid/ask acceptance because live validation can still depend on price movement, bid/ask
side, freeze levels, and server-side rules, and it does not validate side, freeze levels, and server-side rules, and it does not validate
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices `trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
+9 -4
View File
@@ -4,11 +4,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
from pdmt5 import Mt5RuntimeError, Mt5TradingError from pdmt5 import Mt5RuntimeError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
try:
from pdmt5 import Mt5TradingError
except ImportError: # pragma: no cover
Mt5TradingError = None # type: ignore[assignment]
T = TypeVar("T") T = TypeVar("T")
__all__ = [ __all__ = [
@@ -22,7 +27,7 @@ __all__ = [
] ]
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = ( _RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
Mt5TradingError, *([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[misc]
Mt5RuntimeError, Mt5RuntimeError,
) )
@@ -50,7 +55,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool:
exc: Exception raised by MT5 or pdmt5. exc: Exception raised by MT5 or pdmt5.
Returns: Returns:
True for ``Mt5RuntimeError`` and ``Mt5TradingError``. True for ``Mt5RuntimeError`` and ``Mt5TradingError`` (if available).
""" """
return isinstance(exc, _RECOVERABLE_MT5_ERRORS) return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
@@ -65,7 +70,7 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError:
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for ``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
trading failures, or the original exception when it is not recognized. trading failures, or the original exception when it is not recognized.
""" """
if isinstance(exc, Mt5TradingError): if Mt5TradingError is not None and isinstance(exc, Mt5TradingError):
return Mt5OperationError(str(exc)) return Mt5OperationError(str(exc))
if isinstance(exc, Mt5RuntimeError): if isinstance(exc, Mt5RuntimeError):
return Mt5ConnectionError(str(exc)) return Mt5ConnectionError(str(exc))
+7 -2
View File
@@ -15,7 +15,12 @@ from pathlib import Path
from typing import TYPE_CHECKING, Self, TypeVar, cast from typing import TYPE_CHECKING, Self, TypeVar, cast
import pandas as pd import pandas as pd
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError, Mt5TradingError from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
try:
from pdmt5 import Mt5TradingError
except ImportError: # pragma: no cover
Mt5TradingError = None # type: ignore[assignment]
from .history import ( from .history import (
create_cash_events_view, create_cash_events_view,
@@ -49,7 +54,7 @@ T = TypeVar("T")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = ( _RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
Mt5TradingError, *([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[assignment]
Mt5RuntimeError, Mt5RuntimeError,
sqlite3.Error, sqlite3.Error,
ValueError, ValueError,
+153 -91
View File
@@ -6,20 +6,79 @@ import logging
from contextlib import contextmanager from contextlib import contextmanager
from math import floor, isfinite from math import floor, isfinite
from numbers import Integral, Real from numbers import Integral, Real
from typing import TYPE_CHECKING, Literal, TypedDict, cast from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast
import pandas as pd import pandas as pd
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
from .exceptions import Mt5OperationError
from .history import drop_forming_rate_bar from .history import drop_forming_rate_bar
from .sdk import build_config from .sdk import build_config
from .utils import coerce_login as _coerce_login from .utils import coerce_login as _coerce_login
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
from typing import Any
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class _Mt5ClientProtocol(Protocol):
"""Minimal protocol for MT5 clients with methods required by mt5cli.
This protocol describes the interface required by mt5cli trading helpers.
It uses positional-only parameters to avoid structural subtyping issues with
different client implementations that may use different parameter names.
"""
@property
def mt5(self) -> Any: # noqa: ANN401
"""MT5 module with trading constants (POSITION_TYPE_*, ORDER_TYPE_*, etc.)."""
...
def account_info_as_dict(self) -> dict[str, Any]:
"""Return account information as a dictionary."""
...
def symbol_info(self, symbol: str, /) -> object:
"""Return symbol information."""
...
def symbol_info_tick(self, symbol: str, /) -> object:
"""Return latest symbol tick information."""
...
def positions_get_as_df(self, symbol: str | None = None) -> pd.DataFrame:
"""Return open positions as a DataFrame."""
...
def order_calc_margin(
self, /, action: int, symbol: str, volume: float, price: float
) -> Any: # noqa: ANN401
"""Calculate required margin for an order."""
...
def order_send(self, request: dict[str, Any], /) -> Any: # noqa: ANN401
"""Send an order request and return the response."""
...
def symbol_select(self, symbol: str, enable: bool = True) -> bool:
"""Select/deselect a symbol in Market Watch."""
...
def last_error(self) -> object:
"""Return the last error message or info."""
...
def shutdown(self) -> None:
"""Shut down the MT5 client."""
...
def initialize_and_login_mt5(self) -> None:
"""Initialize and login to MT5."""
...
PositionSide = Literal["long", "short"] PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"] OrderSide = Literal["BUY", "SELL"]
OrderFillingMode = Literal["IOC", "FOK", "RETURN"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
@@ -188,7 +247,7 @@ def _validate_protective_prices(
"""Validate SL/TP distances against broker stop-level constraints. """Validate SL/TP distances against broker stop-level constraints.
Raises: Raises:
Mt5TradingError: When a protective price is closer than ``min_distance``. Mt5OperationError: When a protective price is closer than ``min_distance``.
""" """
if min_distance <= 0: if min_distance <= 0:
return return
@@ -198,37 +257,37 @@ def _validate_protective_prices(
f"Stop loss for {symbol!r} violates broker stop level " f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})." f"(minimum distance {min_distance})."
) )
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
if take_profit is not None and (take_profit - entry) < min_distance: if take_profit is not None and (take_profit - entry) < min_distance:
msg = ( msg = (
f"Take profit for {symbol!r} violates broker stop level " f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})." f"(minimum distance {min_distance})."
) )
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
return return
if stop_loss is not None and (stop_loss - entry) < min_distance: if stop_loss is not None and (stop_loss - entry) < min_distance:
msg = ( msg = (
f"Stop loss for {symbol!r} violates broker stop level " f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})." f"(minimum distance {min_distance})."
) )
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
if take_profit is not None and (entry - take_profit) < min_distance: if take_profit is not None and (entry - take_profit) < min_distance:
msg = ( msg = (
f"Take profit for {symbol!r} violates broker stop level " f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})." f"(minimum distance {min_distance})."
) )
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None: def ensure_symbol_selected(client: _Mt5ClientProtocol, symbol: str) -> None:
"""Ensure a symbol is visible in Market Watch before sending orders. """Ensure a symbol is visible in Market Watch before sending orders.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbol: Symbol to select. symbol: Symbol to select.
Raises: Raises:
Mt5TradingError: If the symbol cannot be selected in Market Watch or Mt5OperationError: If the symbol cannot be selected in Market Watch or
``symbol_select`` is unavailable on the client. ``symbol_select`` is unavailable on the client.
""" """
snapshot = get_symbol_snapshot(client, symbol) snapshot = get_symbol_snapshot(client, symbol)
@@ -237,13 +296,13 @@ def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
select = getattr(client, "symbol_select", None) select = getattr(client, "symbol_select", None)
if not callable(select): if not callable(select):
msg = "MT5 client is missing required method: symbol_select" msg = "MT5 client is missing required method: symbol_select"
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
if select(symbol, enable=True): if select(symbol, enable=True):
return return
last_error = getattr(client, "last_error", None) last_error = getattr(client, "last_error", None)
detail = f" ({last_error()})" if callable(last_error) else "" detail = f" ({last_error()})" if callable(last_error) else ""
msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}." msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
def _require_unit_ratio(value: float, name: str) -> None: def _require_unit_ratio(value: float, name: str) -> None:
@@ -370,7 +429,7 @@ def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, ob
return {field: row.get(field) for field in fields} return {field: row.get(field) for field in fields}
def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object: def _call_snapshot_method(client: _Mt5ClientProtocol, *names: str) -> object:
for name in names: for name in names:
method = getattr(client, name, None) method = getattr(client, name, None)
if callable(method): if callable(method):
@@ -394,7 +453,7 @@ def _resolve_mt5_constant(
return cast("int", getattr(mt5, name)) return cast("int", getattr(mt5, name))
except AttributeError as exc: except AttributeError as exc:
msg = f"MT5 module is missing required constant: {name}" msg = f"MT5 module is missing required constant: {name}"
raise Mt5TradingError(msg) from exc raise Mt5OperationError(msg) from exc
def _parse_digit_string(value: str) -> int | None: def _parse_digit_string(value: str) -> int | None:
@@ -478,7 +537,7 @@ def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus:
def _calculate_min_volume_if_affordable( def _calculate_min_volume_if_affordable(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
available_margin: float, available_margin: float,
order_side: OrderSide, order_side: OrderSide,
@@ -495,14 +554,14 @@ def _calculate_min_volume_if_affordable(
or (volume_max > 0 and volume_min > volume_max) or (volume_max > 0 and volume_min > volume_max)
): ):
msg = f"Invalid volume constraints for {symbol!r}." msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
price = extract_tick_price( price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
) )
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
order_type = ( order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
) )
@@ -519,8 +578,12 @@ def create_trading_client(
path: str | None = None, path: str | None = None,
timeout: int | None = None, timeout: int | None = None,
retry_count: int = 0, retry_count: int = 0,
) -> Mt5TradingClient: ) -> _Mt5ClientProtocol:
"""Return an initialized and logged-in trading client.""" """Return an initialized and logged-in trading client.
Returns:
A client instance supporting the required MT5 trading methods.
"""
mt5_config = _resolve_config( mt5_config = _resolve_config(
config=config, config=config,
login=login, login=login,
@@ -529,7 +592,7 @@ def create_trading_client(
path=path, path=path,
timeout=timeout, timeout=timeout,
) )
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count) client = Mt5DataClient(config=mt5_config, retry_count=retry_count)
try: try:
client.initialize_and_login_mt5() client.initialize_and_login_mt5()
except Exception: except Exception:
@@ -539,13 +602,13 @@ def create_trading_client(
def detect_position_side( def detect_position_side(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
) -> PositionSide | None: ) -> PositionSide | None:
"""Detect the net open position side for a symbol. """Detect the net open position side for a symbol.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbol: Symbol to inspect. symbol: Symbol to inspect.
Returns: Returns:
@@ -569,7 +632,7 @@ def detect_position_side(
def get_account_snapshot( def get_account_snapshot(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
) -> dict[str, float | int | str | None]: ) -> dict[str, float | int | str | None]:
"""Return normalized account state with stable keys.""" """Return normalized account state with stable keys."""
value = _call_snapshot_method(client, "account_info_as_dict", "account_info") value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
@@ -580,7 +643,7 @@ def get_account_snapshot(
def get_symbol_snapshot( def get_symbol_snapshot(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
) -> dict[str, float | int | str | bool | None]: ) -> dict[str, float | int | str | bool | None]:
"""Return normalized symbol metadata required for trading decisions.""" """Return normalized symbol metadata required for trading decisions."""
@@ -592,7 +655,7 @@ def get_symbol_snapshot(
def get_tick_snapshot( def get_tick_snapshot(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
) -> dict[str, float | int | None]: ) -> dict[str, float | int | None]:
"""Return normalized latest tick data, including bid, ask, and timestamp.""" """Return normalized latest tick data, including bid, ask, and timestamp."""
@@ -606,7 +669,7 @@ def get_tick_snapshot(
def get_positions_frame( def get_positions_frame(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str | None = None, symbol: str | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Return open positions as a DataFrame with stable baseline columns.""" """Return open positions as a DataFrame with stable baseline columns."""
@@ -618,7 +681,7 @@ def get_positions_frame(
def _order_side_from_position_type( def _order_side_from_position_type(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
position_type: object, position_type: object,
) -> OrderSide | None: ) -> OrderSide | None:
if position_type == client.mt5.POSITION_TYPE_BUY: if position_type == client.mt5.POSITION_TYPE_BUY:
@@ -640,7 +703,7 @@ def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
def estimate_order_margin( def estimate_order_margin(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
order_side: OrderSide | str, order_side: OrderSide | str,
volume: float, volume: float,
@@ -651,17 +714,17 @@ def estimate_order_margin(
Positive finite margin required for the order at the current quote. Positive finite margin required for the order at the current quote.
Raises: Raises:
Mt5TradingError: If volume, tick data, or margin estimation is invalid. Mt5OperationError: If volume, tick data, or margin estimation is invalid.
""" """
if not _is_positive_finite_number(volume): if not _is_positive_finite_number(volume):
msg = "Volume must be a positive finite number to estimate order margin." msg = "Volume must be a positive finite number to estimate order margin."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
order_type = ( order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
) )
@@ -670,22 +733,22 @@ def estimate_order_margin(
margin = float(raw_margin) margin = float(raw_margin)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
msg = f"Margin estimate is invalid for {symbol!r}." msg = f"Margin estimate is invalid for {symbol!r}."
raise Mt5TradingError(msg) from exc raise Mt5OperationError(msg) from exc
if margin <= 0 or not isfinite(margin): if margin <= 0 or not isfinite(margin):
msg = f"Margin estimate is invalid for {symbol!r}." msg = f"Margin estimate is invalid for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
return margin return margin
def calculate_positions_margin( def calculate_positions_margin(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbols: Sequence[str] | None = None, symbols: Sequence[str] | None = None,
) -> float: ) -> float:
"""Return the sum of estimated current margin for open positions. """Return the sum of estimated current margin for open positions.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbols: Optional symbol filter. When omitted, all open positions are symbols: Optional symbol filter. When omitted, all open positions are
included. included.
@@ -720,7 +783,7 @@ def calculate_positions_margin(
def calculate_positions_margin_by_symbol( def calculate_positions_margin_by_symbol(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbols: Sequence[str], symbols: Sequence[str],
suppress_errors: bool = True, suppress_errors: bool = True,
@@ -732,10 +795,10 @@ def calculate_positions_margin_by_symbol(
first-seen order. first-seen order.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbols: Symbols to compute margin for. symbols: Symbols to compute margin for.
suppress_errors: When ``True``, log and skip symbols that raise suppress_errors: When ``True``, log and skip symbols that raise
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``. ``Mt5OperationError``, ``Mt5RuntimeError``, or ``AttributeError``.
When ``False``, re-raise the first failure. When ``False``, re-raise the first failure.
Returns: Returns:
@@ -744,7 +807,7 @@ def calculate_positions_margin_by_symbol(
with ``suppress_errors=True``. with ``suppress_errors=True``.
Raises: Raises:
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and Mt5OperationError: When a symbol raises ``Mt5OperationError`` and
``suppress_errors=False``. ``suppress_errors=False``.
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
``suppress_errors=False``. ``suppress_errors=False``.
@@ -755,7 +818,7 @@ def calculate_positions_margin_by_symbol(
for symbol in dict.fromkeys(symbols): for symbol in dict.fromkeys(symbols):
try: try:
result[symbol] = calculate_positions_margin(client, symbols=[symbol]) result[symbol] = calculate_positions_margin(client, symbols=[symbol])
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc: except (Mt5OperationError, Mt5RuntimeError, AttributeError) as exc:
if not suppress_errors: if not suppress_errors:
raise raise
_logger.warning("Skipping margin for %r: %s", symbol, exc) _logger.warning("Skipping margin for %r: %s", symbol, exc)
@@ -763,7 +826,7 @@ def calculate_positions_margin_by_symbol(
def calculate_positions_margin_safe( def calculate_positions_margin_safe(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbols: Sequence[str], symbols: Sequence[str],
) -> float: ) -> float:
@@ -773,7 +836,7 @@ def calculate_positions_margin_safe(
``suppress_errors=True``. Failed symbols are silently skipped. ``suppress_errors=True``. Failed symbols are silently skipped.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbols: Symbols to include. symbols: Symbols to include.
Returns: Returns:
@@ -785,23 +848,23 @@ def calculate_positions_margin_safe(
) )
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float: def calculate_spread_ratio(client: _Mt5ClientProtocol, symbol: str) -> float:
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick. """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
Raises: Raises:
Mt5TradingError: If bid or ask is unavailable. Mt5OperationError: If bid or ask is unavailable.
""" """
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
bid = extract_tick_price(tick, "bid") bid = extract_tick_price(tick, "bid")
ask = extract_tick_price(tick, "ask") ask = extract_tick_price(tick, "ask")
if bid is None or ask is None: if bid is None or ask is None:
msg = f"Tick bid/ask is unavailable for {symbol!r}." msg = f"Tick bid/ask is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
return (ask - bid) / ((ask + bid) / 2.0) return (ask - bid) / ((ask + bid) / 2.0)
def calculate_new_position_margin_ratio( def calculate_new_position_margin_ratio(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
new_position_side: OrderSide | None = None, new_position_side: OrderSide | None = None,
@@ -810,13 +873,13 @@ def calculate_new_position_margin_ratio(
"""Return total margin/equity ratio after an optional hypothetical position. """Return total margin/equity ratio after an optional hypothetical position.
Raises: Raises:
Mt5TradingError: If equity or required tick data is invalid. Mt5OperationError: If equity or required tick data is invalid.
""" """
account = get_account_snapshot(client) account = get_account_snapshot(client)
equity = float(account.get("equity") or 0.0) equity = float(account.get("equity") or 0.0)
if equity <= 0: if equity <= 0:
msg = "Account equity must be positive to calculate margin ratio." msg = "Account equity must be positive to calculate margin ratio."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
margin = float(account.get("margin") or 0.0) margin = float(account.get("margin") or 0.0)
if new_position_side is not None and new_position_volume > 0: if new_position_side is not None and new_position_volume > 0:
side = _normalize_order_side(new_position_side) side = _normalize_order_side(new_position_side)
@@ -825,7 +888,7 @@ def calculate_new_position_margin_ratio(
) )
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
order_type = ( order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
) )
@@ -835,7 +898,7 @@ def calculate_new_position_margin_ratio(
return margin / equity return margin / equity
def _account_equity(client: Mt5TradingClient) -> float: def _account_equity(client: _Mt5ClientProtocol) -> float:
account = get_account_snapshot(client) account = get_account_snapshot(client)
return _required_account_number(account, "equity", allow_zero=False) return _required_account_number(account, "equity", allow_zero=False)
@@ -849,7 +912,7 @@ def _required_account_number(
raw_value = account.get(field) raw_value = account.get(field)
if isinstance(raw_value, bool) or not isinstance(raw_value, Real): if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
msg = f"Account {field} must be a finite number to calculate margin ratio." msg = f"Account {field} must be a finite number to calculate margin ratio."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
value = float(raw_value) value = float(raw_value)
if ( if (
not isfinite(value) not isfinite(value)
@@ -861,12 +924,12 @@ def _required_account_number(
if allow_zero if allow_zero
else f"Account {field} must be a positive finite number." else f"Account {field} must be a positive finite number."
) )
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
return value return value
def calculate_account_projected_margin_ratio( def calculate_account_projected_margin_ratio(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str | None = None, symbol: str | None = None,
new_position_side: OrderSide | None = None, new_position_side: OrderSide | None = None,
@@ -894,7 +957,7 @@ def calculate_account_projected_margin_ratio(
def calculate_projected_margin_ratio( def calculate_projected_margin_ratio(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
new_position_side: OrderSide | None = None, new_position_side: OrderSide | None = None,
@@ -933,7 +996,7 @@ def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
def calculate_symbol_group_margin_ratio( def calculate_symbol_group_margin_ratio(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbols: Sequence[str], symbols: Sequence[str],
new_symbol: str | None = None, new_symbol: str | None = None,
@@ -962,7 +1025,7 @@ def calculate_symbol_group_margin_ratio(
fails and ``suppress_errors`` is ``False``. fails and ``suppress_errors`` is ``False``.
Mt5RuntimeError: When symbol margin lookup or projected margin lookup Mt5RuntimeError: When symbol margin lookup or projected margin lookup
fails and ``suppress_errors`` is ``False``. fails and ``suppress_errors`` is ``False``.
Mt5TradingError: When account equity is invalid, or when symbol margin Mt5OperationError: When account equity is invalid, or when symbol margin
lookup or projected margin lookup fails and ``suppress_errors`` is lookup or projected margin lookup fails and ``suppress_errors`` is
``False``. ``False``.
""" """
@@ -987,7 +1050,7 @@ def calculate_symbol_group_margin_ratio(
new_position_side, new_position_side,
new_position_volume, new_position_volume,
) )
except (Mt5TradingError, Mt5RuntimeError, AttributeError): except (Mt5OperationError, Mt5RuntimeError, AttributeError):
if not suppress_errors: if not suppress_errors:
raise raise
_logger.warning("Skipping projected margin for %r.", new_symbol) _logger.warning("Skipping projected margin for %r.", new_symbol)
@@ -999,7 +1062,7 @@ def calculate_symbol_group_margin_ratio(
def calculate_margin_and_volume( def calculate_margin_and_volume(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
unit_margin_ratio: float, unit_margin_ratio: float,
preserved_margin_ratio: float, preserved_margin_ratio: float,
@@ -1013,7 +1076,7 @@ def calculate_margin_and_volume(
side when the post-reserve margin can afford it. side when the post-reserve margin can afford it.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbol: Symbol used for minimum-lot margin and volume calculations. symbol: Symbol used for minimum-lot margin and volume calculations.
unit_margin_ratio: Fraction of post-reserve margin to allocate per unit. unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
preserved_margin_ratio: Fraction of ``margin_free`` to preserve. preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
@@ -1066,7 +1129,7 @@ def calculate_margin_and_volume(
def calculate_volume_by_margin( def calculate_volume_by_margin(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
available_margin: float, available_margin: float,
order_side: OrderSide, order_side: OrderSide,
@@ -1079,7 +1142,7 @@ def calculate_volume_by_margin(
constraints; ``0.0`` when no affordable step exists. constraints; ``0.0`` when no affordable step exists.
Raises: Raises:
Mt5TradingError: If symbol volume constraints or tick data are invalid. Mt5OperationError: If symbol volume constraints or tick data are invalid.
""" """
if available_margin <= 0: if available_margin <= 0:
return 0.0 return 0.0
@@ -1089,14 +1152,14 @@ def calculate_volume_by_margin(
volume_step = float(symbol_info.get("volume_step") or volume_min 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: if volume_min <= 0 or volume_step <= 0:
msg = f"Invalid volume constraints for {symbol!r}." msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
price = extract_tick_price( price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
) )
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
order_type = ( order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
) )
@@ -1138,7 +1201,7 @@ def calculate_volume_by_margin(
def determine_order_limits( def determine_order_limits(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
symbol: str, symbol: str,
side: PositionSide | str, side: PositionSide | str,
stop_loss_limit_ratio: float | None = None, stop_loss_limit_ratio: float | None = None,
@@ -1147,7 +1210,7 @@ def determine_order_limits(
"""Derive entry and protective order prices from current market quotes. """Derive entry and protective order prices from current market quotes.
Args: Args:
client: Connected ``Mt5TradingClient`` instance. client: Connected MT5 client instance.
symbol: Symbol used for the quote lookup. symbol: Symbol used for the quote lookup.
side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"`` side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
aliases are accepted). aliases are accepted).
@@ -1161,7 +1224,7 @@ def determine_order_limits(
Omitted protective levels are returned as ``None``. Omitted protective levels are returned as ``None``.
Raises: Raises:
Mt5TradingError: If required tick data is invalid or computed SL/TP Mt5OperationError: If required tick data is invalid or computed SL/TP
prices violate available ``trade_stops_level`` pre-validation. prices violate available ``trade_stops_level`` pre-validation.
""" """
stop_loss_ratio = stop_loss_limit_ratio or 0.0 stop_loss_ratio = stop_loss_limit_ratio or 0.0
@@ -1174,7 +1237,7 @@ def determine_order_limits(
entry = extract_tick_price(tick, entry_key) entry = extract_tick_price(tick, entry_key)
if entry is None: if entry is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
try: try:
symbol_info = get_symbol_snapshot(client, symbol) symbol_info = get_symbol_snapshot(client, symbol)
except (AttributeError, KeyError, TypeError, ValueError): except (AttributeError, KeyError, TypeError, ValueError):
@@ -1218,7 +1281,7 @@ def determine_order_limits(
def place_market_order( def place_market_order(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
volume: float, volume: float,
@@ -1232,20 +1295,20 @@ def place_market_order(
) -> OrderExecutionResult: ) -> OrderExecutionResult:
"""Place one normalized market order or return a dry-run result. """Place one normalized market order or return a dry-run result.
``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no ``order_send()`` raises only when MT5 returns no response. When MT5 returns
response. When MT5 returns a response with a known non-success retcode, this a response with a known non-success retcode, this helper returns
helper returns ``status="failed"`` and keeps the normalized response ``status="failed"`` and keeps the normalized response details for callers
details for callers to inspect. to inspect.
Returns: Returns:
Normalized execution result containing request and response details. Normalized execution result containing request and response details.
Raises: Raises:
Mt5TradingError: If volume or required tick data is invalid. Mt5OperationError: If volume or required tick data is invalid.
""" """
if volume <= 0: if volume <= 0:
msg = "volume must be positive." msg = "volume must be positive."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
if not dry_run: if not dry_run:
ensure_symbol_selected(client, symbol) ensure_symbol_selected(client, symbol)
@@ -1253,7 +1316,7 @@ def place_market_order(
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
request = { request = {
"action": client.mt5.TRADE_ACTION_DEAL, "action": client.mt5.TRADE_ACTION_DEAL,
"symbol": symbol, "symbol": symbol,
@@ -1326,7 +1389,7 @@ def _filter_positions(
def close_open_positions( def close_open_positions(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbols: str | list[str] | None = None, symbols: str | list[str] | None = None,
tickets: list[int] | None = None, tickets: list[int] | None = None,
@@ -1358,7 +1421,7 @@ def close_open_positions(
return results return results
def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None: def _symbol_digits(client: _Mt5ClientProtocol, symbol: str) -> int | None:
try: try:
raw_digits = get_symbol_snapshot(client, symbol).get("digits") raw_digits = get_symbol_snapshot(client, symbol).get("digits")
if raw_digits is None: if raw_digits is None:
@@ -1379,7 +1442,7 @@ def _current_stop_loss(value: object) -> float | None:
def _trailing_stop_loss( def _trailing_stop_loss(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
position_type: object, position_type: object,
current_sl: float | None, current_sl: float | None,
@@ -1402,7 +1465,7 @@ def _trailing_stop_loss(
def calculate_trailing_stop_updates( def calculate_trailing_stop_updates(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
trailing_stop_ratio: float, trailing_stop_ratio: float,
@@ -1447,7 +1510,7 @@ def calculate_trailing_stop_updates(
def update_trailing_stop_loss_for_open_positions( def update_trailing_stop_loss_for_open_positions(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
trailing_stop_ratio: float, trailing_stop_ratio: float,
@@ -1478,7 +1541,7 @@ def update_trailing_stop_loss_for_open_positions(
def update_sltp_for_open_positions( def update_sltp_for_open_positions(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str | None = None, symbol: str | None = None,
tickets: list[int] | None = None, tickets: list[int] | None = None,
@@ -1542,7 +1605,7 @@ def update_sltp_for_open_positions(
def fetch_latest_closed_rates_for_trading_client( def fetch_latest_closed_rates_for_trading_client(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
granularity: str, granularity: str,
@@ -1556,7 +1619,7 @@ def fetch_latest_closed_rates_for_trading_client(
Raises: Raises:
ValueError: If ``count`` is not positive, rate data is empty or ValueError: If ``count`` is not positive, rate data is empty or
malformed, or the ``time`` column is missing. malformed, or the ``time`` column is missing.
Mt5TradingError: If the trading client cannot fetch rate data. Mt5OperationError: If the trading client cannot fetch rate data.
""" """
if count <= 0: if count <= 0:
msg = "count must be positive." msg = "count must be positive."
@@ -1564,7 +1627,7 @@ def fetch_latest_closed_rates_for_trading_client(
fetch_method = getattr(client, "fetch_latest_rates_as_df", None) fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
if not callable(fetch_method): if not callable(fetch_method):
msg = "MT5 trading client cannot fetch rate data." msg = "MT5 trading client cannot fetch rate data."
raise Mt5TradingError(msg) raise Mt5OperationError(msg)
fetched = fetch_method(symbol, granularity, count + 1) fetched = fetch_method(symbol, granularity, count + 1)
if not isinstance(fetched, pd.DataFrame): if not isinstance(fetched, pd.DataFrame):
msg = ( msg = (
@@ -1627,7 +1690,7 @@ def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
def fetch_latest_closed_rates_indexed( def fetch_latest_closed_rates_indexed(
client: Mt5TradingClient, client: _Mt5ClientProtocol,
*, *,
symbol: str, symbol: str,
granularity: str, granularity: str,
@@ -1683,13 +1746,13 @@ def mt5_trading_session(
path: str | None = None, path: str | None = None,
timeout: int | None = None, timeout: int | None = None,
retry_count: int = 0, retry_count: int = 0,
) -> Iterator[Mt5TradingClient]: ) -> Iterator[_Mt5ClientProtocol]:
"""Open a trading-capable MT5 session and always shut down safely. """Open a trading-capable MT5 session and always shut down safely.
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set, Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
initializes and logs in via ``initialize_and_login_mt5()``, yields a initializes and logs in via ``initialize_and_login_mt5()``, yields a
connected :class:`~pdmt5.Mt5TradingClient`, and calls ``shutdown()`` on connected client supporting required MT5 methods, and calls ``shutdown()``
exit even when an error is raised inside the context. on exit even when an error is raised inside the context.
Args: Args:
config: MT5 connection configuration. Defaults to an empty config that config: MT5 connection configuration. Defaults to an empty config that
@@ -1699,11 +1762,10 @@ def mt5_trading_session(
server: Optional trading server name. server: Optional trading server name.
path: Optional terminal executable path. path: Optional terminal executable path.
timeout: Optional connection timeout in milliseconds. timeout: Optional connection timeout in milliseconds.
retry_count: Number of initialization retries passed to retry_count: Number of initialization retries.
``Mt5TradingClient``.
Yields: Yields:
Connected ``Mt5TradingClient`` bound to the session. Connected client supporting required MT5 trading methods.
""" """
client = create_trading_client( client = create_trading_client(
config=config, config=config,
+4 -1
View File
@@ -178,7 +178,10 @@ omit = [
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
fail_under = 100 fail_under = 100
exclude_lines = ["if TYPE_CHECKING:"] exclude_also = [
"if TYPE_CHECKING:",
"^\\s+\\.\\.\\.$",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
+14 -2
View File
@@ -705,7 +705,7 @@ class TestStableSdkContract:
"""Trading session helper initializes and always shuts down.""" """Trading session helper initializes and always shuts down."""
mock_client = MagicMock() mock_client = MagicMock()
mocker.patch( mocker.patch(
"mt5cli.trading.Mt5TradingClient", "mt5cli.trading.Mt5DataClient",
return_value=mock_client, return_value=mock_client,
) )
@@ -734,7 +734,7 @@ class TestStableSdkContract:
"""Trading session helper shuts down even when the body raises.""" """Trading session helper shuts down even when the body raises."""
mock_client = MagicMock() mock_client = MagicMock()
mocker.patch( mocker.patch(
"mt5cli.trading.Mt5TradingClient", "mt5cli.trading.Mt5DataClient",
return_value=mock_client, return_value=mock_client,
) )
@@ -828,6 +828,18 @@ def test_pdmt5_pass_through_names_removed_from_public_contract(name: str) -> Non
assert name not in mt5cli.__all__, f"{name!r} should not be in mt5cli.__all__" assert name not in mt5cli.__all__, f"{name!r} should not be in mt5cli.__all__"
def test_mt5cli_does_not_import_high_level_trading_symbols() -> None:
"""mt5cli doesn't import Mt5TradingClient or Mt5TradingError at module level."""
trading_module = importlib.import_module("mt5cli.trading")
module_dict = vars(trading_module)
assert "Mt5TradingClient" not in module_dict, (
"mt5cli.trading should not import Mt5TradingClient at module level"
)
assert "Mt5TradingError" not in module_dict, (
"mt5cli.trading should not import Mt5TradingError at module level"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Packaging metadata # Packaging metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+44 -43
View File
@@ -14,6 +14,7 @@ from numpy import int64 as np_int64
from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from pytest_mock import MockerFixture # noqa: TC002 from pytest_mock import MockerFixture # noqa: TC002
from mt5cli.exceptions import Mt5OperationError
from mt5cli.sdk import build_config from mt5cli.sdk import build_config
from mt5cli.trading import ( from mt5cli.trading import (
MarginVolume, MarginVolume,
@@ -434,7 +435,7 @@ class TestDetermineOrderLimits:
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
determine_order_limits(client, "EURUSD", "long") determine_order_limits(client, "EURUSD", "long")
def test_accepts_numeric_string_entry(self) -> None: def test_accepts_numeric_string_entry(self) -> None:
@@ -483,7 +484,7 @@ class TestDetermineOrderLimits:
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = tick client.symbol_info_tick_as_dict.return_value = tick
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
determine_order_limits(client, "EURUSD", side) determine_order_limits(client, "EURUSD", side)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -512,7 +513,7 @@ class TestDetermineOrderLimits:
"point": 0.0001, "point": 0.0001,
} }
with pytest.raises(Mt5TradingError, match=match): with pytest.raises(Mt5OperationError, match=match):
determine_order_limits(client, "EURUSD", side, **{kwarg: 0.0001}) determine_order_limits(client, "EURUSD", side, **{kwarg: 0.0001})
def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None: def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None:
@@ -619,7 +620,7 @@ class TestDetermineOrderLimits:
client.symbol_select.return_value = False client.symbol_select.return_value = False
client.last_error.return_value = (1, "not found") client.last_error.return_value = (1, "not found")
with pytest.raises(Mt5TradingError, match="Failed to select symbol 'EURUSD'"): with pytest.raises(Mt5OperationError, match="Failed to select symbol 'EURUSD'"):
ensure_symbol_selected(client, "EURUSD") ensure_symbol_selected(client, "EURUSD")
def test_raises_when_symbol_select_is_unavailable(self) -> None: def test_raises_when_symbol_select_is_unavailable(self) -> None:
@@ -629,7 +630,7 @@ class TestDetermineOrderLimits:
del client.symbol_select del client.symbol_select
with pytest.raises( with pytest.raises(
Mt5TradingError, Mt5OperationError,
match="missing required method: symbol_select", match="missing required method: symbol_select",
): ):
ensure_symbol_selected(client, "EURUSD") ensure_symbol_selected(client, "EURUSD")
@@ -645,7 +646,7 @@ class TestMt5TradingSession:
"""Test mt5_trading_session connects, yields a client, and shuts down.""" """Test mt5_trading_session connects, yields a client, and shuts down."""
mock_client = MagicMock() mock_client = MagicMock()
trading_client = mocker.patch( trading_client = mocker.patch(
"mt5cli.trading.Mt5TradingClient", "mt5cli.trading.Mt5DataClient",
return_value=mock_client, return_value=mock_client,
) )
@@ -668,10 +669,10 @@ class TestCreateTradingClient:
"""Tests for create_trading_client.""" """Tests for create_trading_client."""
def test_initializes_with_keyword_config(self, mocker: MockerFixture) -> None: def test_initializes_with_keyword_config(self, mocker: MockerFixture) -> None:
"""Test keyword configuration is forwarded to Mt5TradingClient.""" """Test keyword configuration is forwarded to Mt5DataClient."""
mock_client = MagicMock() mock_client = MagicMock()
trading_client = mocker.patch( trading_client = mocker.patch(
"mt5cli.trading.Mt5TradingClient", "mt5cli.trading.Mt5DataClient",
return_value=mock_client, return_value=mock_client,
) )
@@ -695,7 +696,7 @@ class TestCreateTradingClient:
def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None: def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None:
"""Test empty login strings are treated as None.""" """Test empty login strings are treated as None."""
trading_client = mocker.patch( trading_client = mocker.patch(
"mt5cli.trading.Mt5TradingClient", "mt5cli.trading.Mt5DataClient",
return_value=MagicMock(), return_value=MagicMock(),
) )
@@ -708,7 +709,7 @@ class TestCreateTradingClient:
"""Test failed initialization shuts the client down.""" """Test failed initialization shuts the client down."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom") mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client) mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
with pytest.raises(Mt5RuntimeError, match="boom"): with pytest.raises(Mt5RuntimeError, match="boom"):
create_trading_client() create_trading_client()
@@ -791,7 +792,7 @@ class TestSnapshotsAndState:
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.0} client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.0}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_spread_ratio(client, "EURUSD") calculate_spread_ratio(client, "EURUSD")
def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None: def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None:
@@ -799,7 +800,7 @@ class TestSnapshotsAndState:
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.0} client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.0}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_spread_ratio(client, "EURUSD") calculate_spread_ratio(client, "EURUSD")
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None: def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
@@ -843,7 +844,7 @@ class TestSnapshotsAndState:
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = tick client.symbol_info_tick_as_dict.return_value = tick
with pytest.raises(Mt5TradingError, match="Tick bid/ask is unavailable"): with pytest.raises(Mt5OperationError, match="Tick bid/ask is unavailable"):
calculate_spread_ratio(client, "EURUSD") calculate_spread_ratio(client, "EURUSD")
@@ -1033,7 +1034,7 @@ class TestEstimateOrderMargin:
"""Test non-positive volume raises Mt5TradingError.""" """Test non-positive volume raises Mt5TradingError."""
client = _mock_trade_client() client = _mock_trade_client()
with pytest.raises(Mt5TradingError, match="positive finite number"): with pytest.raises(Mt5OperationError, match="positive finite number"):
estimate_order_margin(client, "EURUSD", "BUY", 0.0) estimate_order_margin(client, "EURUSD", "BUY", 0.0)
@pytest.mark.parametrize("volume", [float("nan"), float("inf")], ids=["nan", "inf"]) @pytest.mark.parametrize("volume", [float("nan"), float("inf")], ids=["nan", "inf"])
@@ -1041,7 +1042,7 @@ class TestEstimateOrderMargin:
"""Test NaN or infinite volume raises Mt5TradingError without broker calls.""" """Test NaN or infinite volume raises Mt5TradingError without broker calls."""
client = _mock_trade_client() client = _mock_trade_client()
with pytest.raises(Mt5TradingError, match="positive finite number"): with pytest.raises(Mt5OperationError, match="positive finite number"):
estimate_order_margin(client, "EURUSD", "BUY", volume) estimate_order_margin(client, "EURUSD", "BUY", volume)
client.symbol_info_tick_as_dict.assert_not_called() client.symbol_info_tick_as_dict.assert_not_called()
@@ -1052,7 +1053,7 @@ class TestEstimateOrderMargin:
client = _mock_trade_client() client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1) estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_positive_tick_price(self) -> None: def test_rejects_non_positive_tick_price(self) -> None:
@@ -1060,7 +1061,7 @@ class TestEstimateOrderMargin:
client = _mock_trade_client() client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000} client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1) estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_finite_tick_price(self) -> None: def test_rejects_non_finite_tick_price(self) -> None:
@@ -1071,7 +1072,7 @@ class TestEstimateOrderMargin:
"bid": 1.1000, "bid": 1.1000,
} }
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1) estimate_order_margin(client, "EURUSD", "BUY", 0.1)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1085,7 +1086,7 @@ class TestEstimateOrderMargin:
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = margin_value client.order_calc_margin.return_value = margin_value
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"): with pytest.raises(Mt5OperationError, match="Margin estimate is invalid"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1) estimate_order_margin(client, "EURUSD", "BUY", 0.1)
@@ -1184,7 +1185,7 @@ class TestCalculatePositionsMargin:
) )
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
calculate_positions_margin(client) calculate_positions_margin(client)
def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None: def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None:
@@ -1376,7 +1377,7 @@ class TestVolumeAndExecution:
"volume_step": 0.1, "volume_step": 0.1,
} }
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY") calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY")
def test_calculate_volume_by_margin_rejects_bad_tick(self) -> None: def test_calculate_volume_by_margin_rejects_bad_tick(self) -> None:
@@ -1389,7 +1390,7 @@ class TestVolumeAndExecution:
} }
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": None} client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": None}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL") calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget( def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget(
@@ -1604,7 +1605,7 @@ class TestVolumeAndExecution:
"volume_step": 0.1, "volume_step": 0.1,
} }
with pytest.raises(Mt5TradingError, match="Invalid volume constraints"): with pytest.raises(Mt5OperationError, match="Invalid volume constraints"):
calculate_margin_and_volume( calculate_margin_and_volume(
client, client,
"EURUSD", "EURUSD",
@@ -1623,7 +1624,7 @@ class TestVolumeAndExecution:
} }
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 99.0} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 99.0}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
calculate_margin_and_volume( calculate_margin_and_volume(
client, client,
"EURUSD", "EURUSD",
@@ -1722,7 +1723,7 @@ class TestVolumeAndExecution:
client = _mock_trade_client() client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 0.0, "margin": 50.0} client.account_info_as_dict.return_value = {"equity": 0.0, "margin": 50.0}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_new_position_margin_ratio(client, symbol="EURUSD") calculate_new_position_margin_ratio(client, symbol="EURUSD")
def test_new_position_margin_ratio_rejects_bad_tick(self) -> None: def test_new_position_margin_ratio_rejects_bad_tick(self) -> None:
@@ -1731,7 +1732,7 @@ class TestVolumeAndExecution:
client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0} client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0}
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.0} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.0}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
calculate_new_position_margin_ratio( calculate_new_position_margin_ratio(
client, client,
symbol="EURUSD", symbol="EURUSD",
@@ -1905,7 +1906,7 @@ class TestVolumeAndExecution:
client = _mock_trade_client() client = _mock_trade_client()
client.account_info_as_dict.return_value = account client.account_info_as_dict.return_value = account
with pytest.raises(Mt5TradingError, match=match): with pytest.raises(Mt5OperationError, match=match):
calculate_account_projected_margin_ratio(client) calculate_account_projected_margin_ratio(client)
def test_account_projected_margin_ratio_propagates_candidate_margin_error( def test_account_projected_margin_ratio_propagates_candidate_margin_error(
@@ -1920,10 +1921,10 @@ class TestVolumeAndExecution:
} }
mocker.patch( mocker.patch(
"mt5cli.trading.estimate_order_margin", "mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"), side_effect=Mt5OperationError("bad tick"),
) )
with pytest.raises(Mt5TradingError, match="bad tick"): with pytest.raises(Mt5OperationError, match="bad tick"):
calculate_account_projected_margin_ratio( calculate_account_projected_margin_ratio(
client, client,
symbol="EURUSD", symbol="EURUSD",
@@ -1999,7 +2000,7 @@ class TestVolumeAndExecution:
client = _mock_trade_client() client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 0.0} client.account_info_as_dict.return_value = {"equity": 0.0}
with pytest.raises(Mt5TradingError, match="Account equity"): with pytest.raises(Mt5OperationError, match="Account equity"):
calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"]) calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"])
def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None: def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None:
@@ -2007,7 +2008,7 @@ class TestVolumeAndExecution:
client = _mock_trade_client() client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": "invalid"} client.account_info_as_dict.return_value = {"equity": "invalid"}
with pytest.raises(Mt5TradingError, match="Account equity"): with pytest.raises(Mt5OperationError, match="Account equity"):
calculate_projected_margin_ratio(client, symbol="EURUSD") calculate_projected_margin_ratio(client, symbol="EURUSD")
def test_symbol_group_margin_ratio_suppresses_projected_failure( def test_symbol_group_margin_ratio_suppresses_projected_failure(
@@ -2024,7 +2025,7 @@ class TestVolumeAndExecution:
) )
mocker.patch( mocker.patch(
"mt5cli.trading.estimate_order_margin", "mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"), side_effect=Mt5OperationError("bad tick"),
) )
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
@@ -2053,10 +2054,10 @@ class TestVolumeAndExecution:
) )
mocker.patch( mocker.patch(
"mt5cli.trading.estimate_order_margin", "mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"), side_effect=Mt5OperationError("bad tick"),
) )
with pytest.raises(Mt5TradingError, match="bad tick"): with pytest.raises(Mt5OperationError, match="bad tick"):
calculate_symbol_group_margin_ratio( calculate_symbol_group_margin_ratio(
client, client,
symbols=["EURUSD"], symbols=["EURUSD"],
@@ -2177,7 +2178,7 @@ class TestVolumeAndExecution:
) )
mocker.patch( mocker.patch(
"mt5cli.trading.estimate_order_margin", "mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"), side_effect=Mt5OperationError("bad tick"),
) )
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
@@ -2208,10 +2209,10 @@ class TestVolumeAndExecution:
) )
mocker.patch( mocker.patch(
"mt5cli.trading.estimate_order_margin", "mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"), side_effect=Mt5OperationError("bad tick"),
) )
with pytest.raises(Mt5TradingError, match="bad tick"): with pytest.raises(Mt5OperationError, match="bad tick"):
calculate_symbol_group_margin_ratio( calculate_symbol_group_margin_ratio(
client, client,
symbols=["EURUSD"], symbols=["EURUSD"],
@@ -2335,7 +2336,7 @@ class TestVolumeAndExecution:
del client.mt5.ORDER_FILLING_IOC del client.mt5.ORDER_FILLING_IOC
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
with pytest.raises(Mt5TradingError, match="ORDER_FILLING_IOC"): with pytest.raises(Mt5OperationError, match="ORDER_FILLING_IOC"):
place_market_order( place_market_order(
client, client,
symbol="EURUSD", symbol="EURUSD",
@@ -2346,7 +2347,7 @@ class TestVolumeAndExecution:
def test_place_market_order_rejects_invalid_volume(self) -> None: def test_place_market_order_rejects_invalid_volume(self) -> None:
"""Test non-positive volume raises a trading error.""" """Test non-positive volume raises a trading error."""
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
place_market_order( place_market_order(
_mock_trade_client(), _mock_trade_client(),
symbol="EURUSD", symbol="EURUSD",
@@ -2359,7 +2360,7 @@ class TestVolumeAndExecution:
client = _mock_trade_client() client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1} client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
with pytest.raises(Mt5TradingError): with pytest.raises(Mt5OperationError):
place_market_order( place_market_order(
client, client,
symbol="EURUSD", symbol="EURUSD",
@@ -2913,7 +2914,7 @@ class TestVolumeAndExecution:
"""Test shutdown is called when initialization fails.""" """Test shutdown is called when initialization fails."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom") mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client) mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session(): with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session():
pass pass
@@ -3070,7 +3071,7 @@ class TestVolumeAndExecution:
def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None: def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None:
"""Test shutdown is called when the context body raises.""" """Test shutdown is called when the context body raises."""
mock_client = MagicMock() mock_client = MagicMock()
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client) mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
body_error = "body error" body_error = "body error"
with pytest.raises(RuntimeError, match=body_error), mt5_trading_session(): with pytest.raises(RuntimeError, match=body_error), mt5_trading_session():
@@ -3201,7 +3202,7 @@ class TestFetchLatestClosedRatesForTradingClient:
"""Test missing rate-fetch methods raise Mt5TradingError.""" """Test missing rate-fetch methods raise Mt5TradingError."""
client = MagicMock(spec=[]) client = MagicMock(spec=[])
with pytest.raises(Mt5TradingError, match="cannot fetch rate data"): with pytest.raises(Mt5OperationError, match="cannot fetch rate data"):
fetch_latest_closed_rates_for_trading_client( fetch_latest_closed_rates_for_trading_client(
client, client,
symbol="EURUSD", symbol="EURUSD",