From 93565681e1170661a29c1c45f67f7c66a0967a59 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:26:54 +0900 Subject: [PATCH] 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 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 --- README.md | 4 +- docs/api/public-contract.md | 15 ++- docs/api/trading.md | 11 +- mt5cli/exceptions.py | 13 +- mt5cli/sdk.py | 9 +- mt5cli/trading.py | 244 ++++++++++++++++++++++-------------- pyproject.toml | 5 +- tests/test_contracts.py | 16 ++- tests/test_trading.py | 87 ++++++------- 9 files changed, 248 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 896edee..0c34e1b 100644 --- a/README.md +++ b/README.md @@ -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 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-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")`. ```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. - **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. - **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. diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index d0d5686..13f732d 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -16,9 +16,12 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5 | **downstream** | Strategy logic; signals; risk policy; backtesting; optimization; YAML/application semantics | Downstream code should import raw pdmt5 types and constants (such as -`Mt5Config`, `Mt5TradingClient`, `Mt5RuntimeError`, `Mt5TradingError`, -`TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly from `pdmt5` when needed. -mt5cli does not serve as a pass-through compatibility namespace for pdmt5. +`Mt5Config`, `Mt5RuntimeError`, `TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly +from `pdmt5` when needed. mt5cli does not serve as a pass-through compatibility +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` 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` | | `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 | -| `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 | | `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 | | `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) | | `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)` | @@ -111,7 +114,7 @@ strategy policy. `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. 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 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 diff --git a/docs/api/trading.md b/docs/api/trading.md index c4b8805..ccb22a5 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -6,8 +6,9 @@ `create_trading_client()` and `mt5_trading_session()` complement the read-only `mt5_session()` helper in `sdk.py`. They return or yield an initialized -`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when -configured, and `mt5_trading_session()` always calls `shutdown()` on exit. +client supporting order execution and account management, use `Mt5Config.path` +to launch the terminal when configured, and `mt5_trading_session()` always +calls `shutdown()` on exit. ```python 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 sell-only exposure, and `None` for no positions or mixed long/short exposure. `calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises -`Mt5TradingError` when bid or ask is missing or non-positive. +`Mt5OperationError` when bid or ask is missing or non-positive. `normalize_order_volume()` returns `0.0` for invalid constraints or sub-minimum requests; check the result before calling `estimate_order_margin()`, which requires a positive finite volume. `calculate_positions_margin()` silently skips rows with missing symbols, non-positive volumes, non-finite volumes, or -unsupported position types, but propagates `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. SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` omits that level. SL/TP prices are rounded with symbol `digits` metadata when available. `determine_order_limits()` pre-validates computed SL/TP prices against available `trade_stops_level * point` metadata when present; violations raise -`Mt5TradingError`. This is a planning helper only: it does not guarantee broker +`Mt5OperationError`. This is a planning helper only: it does not guarantee broker acceptance because live validation can still depend on price movement, bid/ask side, freeze levels, and server-side rules, and it does not validate `trade_freeze_level`. When symbol metadata cannot be loaded, protective prices diff --git a/mt5cli/exceptions.py b/mt5cli/exceptions.py index 5fcdc10..63dfeec 100644 --- a/mt5cli/exceptions.py +++ b/mt5cli/exceptions.py @@ -4,11 +4,16 @@ from __future__ import annotations from typing import TYPE_CHECKING, TypeVar -from pdmt5 import Mt5RuntimeError, Mt5TradingError +from pdmt5 import Mt5RuntimeError if TYPE_CHECKING: from collections.abc import Callable +try: + from pdmt5 import Mt5TradingError +except ImportError: # pragma: no cover + Mt5TradingError = None # type: ignore[assignment] + T = TypeVar("T") __all__ = [ @@ -22,7 +27,7 @@ __all__ = [ ] _RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = ( - Mt5TradingError, + *([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[misc] Mt5RuntimeError, ) @@ -50,7 +55,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool: exc: Exception raised by MT5 or pdmt5. Returns: - True for ``Mt5RuntimeError`` and ``Mt5TradingError``. + True for ``Mt5RuntimeError`` and ``Mt5TradingError`` (if available). """ return isinstance(exc, _RECOVERABLE_MT5_ERRORS) @@ -65,7 +70,7 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError: ``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for 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)) if isinstance(exc, Mt5RuntimeError): return Mt5ConnectionError(str(exc)) diff --git a/mt5cli/sdk.py b/mt5cli/sdk.py index b048df2..4669279 100644 --- a/mt5cli/sdk.py +++ b/mt5cli/sdk.py @@ -15,7 +15,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Self, TypeVar, cast 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 ( create_cash_events_view, @@ -49,7 +54,7 @@ T = TypeVar("T") logger = logging.getLogger(__name__) _RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = ( - Mt5TradingError, + *([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[assignment] Mt5RuntimeError, sqlite3.Error, ValueError, diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 39f402e..ae5cbdf 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -6,20 +6,79 @@ import logging from contextlib import contextmanager from math import floor, isfinite 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 -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 .sdk import build_config from .utils import coerce_login as _coerce_login if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence + from typing import Any _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"] OrderSide = Literal["BUY", "SELL"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"] @@ -188,7 +247,7 @@ def _validate_protective_prices( """Validate SL/TP distances against broker stop-level constraints. 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: return @@ -198,37 +257,37 @@ def _validate_protective_prices( f"Stop loss for {symbol!r} violates broker stop level " f"(minimum distance {min_distance})." ) - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) if take_profit is not None and (take_profit - entry) < min_distance: msg = ( f"Take profit for {symbol!r} violates broker stop level " f"(minimum distance {min_distance})." ) - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) return if stop_loss is not None and (stop_loss - entry) < min_distance: msg = ( f"Stop loss for {symbol!r} violates broker stop level " f"(minimum distance {min_distance})." ) - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) if take_profit is not None and (entry - take_profit) < min_distance: msg = ( f"Take profit for {symbol!r} violates broker stop level " 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. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbol: Symbol to select. 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. """ 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) if not callable(select): msg = "MT5 client is missing required method: symbol_select" - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) if select(symbol, enable=True): return last_error = getattr(client, "last_error", None) detail = f" ({last_error()})" if callable(last_error) else "" msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) 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} -def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object: +def _call_snapshot_method(client: _Mt5ClientProtocol, *names: str) -> object: for name in names: method = getattr(client, name, None) if callable(method): @@ -394,7 +453,7 @@ def _resolve_mt5_constant( return cast("int", getattr(mt5, name)) except AttributeError as exc: 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: @@ -478,7 +537,7 @@ def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus: def _calculate_min_volume_if_affordable( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, available_margin: float, order_side: OrderSide, @@ -495,14 +554,14 @@ def _calculate_min_volume_if_affordable( or (volume_max > 0 and volume_min > volume_max) ): msg = f"Invalid volume constraints for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) side = _normalize_order_side(order_side) price = extract_tick_price( get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" ) if price is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) order_type = ( 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, timeout: int | None = None, retry_count: int = 0, -) -> Mt5TradingClient: - """Return an initialized and logged-in trading client.""" +) -> _Mt5ClientProtocol: + """Return an initialized and logged-in trading client. + + Returns: + A client instance supporting the required MT5 trading methods. + """ mt5_config = _resolve_config( config=config, login=login, @@ -529,7 +592,7 @@ def create_trading_client( path=path, timeout=timeout, ) - client = Mt5TradingClient(config=mt5_config, retry_count=retry_count) + client = Mt5DataClient(config=mt5_config, retry_count=retry_count) try: client.initialize_and_login_mt5() except Exception: @@ -539,13 +602,13 @@ def create_trading_client( def detect_position_side( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, ) -> PositionSide | None: """Detect the net open position side for a symbol. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbol: Symbol to inspect. Returns: @@ -569,7 +632,7 @@ def detect_position_side( def get_account_snapshot( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, ) -> dict[str, float | int | str | None]: """Return normalized account state with stable keys.""" value = _call_snapshot_method(client, "account_info_as_dict", "account_info") @@ -580,7 +643,7 @@ def get_account_snapshot( def get_symbol_snapshot( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, ) -> dict[str, float | int | str | bool | None]: """Return normalized symbol metadata required for trading decisions.""" @@ -592,7 +655,7 @@ def get_symbol_snapshot( def get_tick_snapshot( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, ) -> dict[str, float | int | None]: """Return normalized latest tick data, including bid, ask, and timestamp.""" @@ -606,7 +669,7 @@ def get_tick_snapshot( def get_positions_frame( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str | None = None, ) -> pd.DataFrame: """Return open positions as a DataFrame with stable baseline columns.""" @@ -618,7 +681,7 @@ def get_positions_frame( def _order_side_from_position_type( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, position_type: object, ) -> OrderSide | None: 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( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, order_side: OrderSide | str, volume: float, @@ -651,17 +714,17 @@ def estimate_order_margin( Positive finite margin required for the order at the current quote. 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): msg = "Volume must be a positive finite number to estimate order margin." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) side = _normalize_order_side(order_side) tick = get_tick_snapshot(client, symbol) price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") if price is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) order_type = ( 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) except (TypeError, ValueError) as exc: 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): msg = f"Margin estimate is invalid for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) return margin def calculate_positions_margin( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbols: Sequence[str] | None = None, ) -> float: """Return the sum of estimated current margin for open positions. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbols: Optional symbol filter. When omitted, all open positions are included. @@ -720,7 +783,7 @@ def calculate_positions_margin( def calculate_positions_margin_by_symbol( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbols: Sequence[str], suppress_errors: bool = True, @@ -732,10 +795,10 @@ def calculate_positions_margin_by_symbol( first-seen order. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbols: Symbols to compute margin for. 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. Returns: @@ -744,7 +807,7 @@ def calculate_positions_margin_by_symbol( with ``suppress_errors=True``. Raises: - Mt5TradingError: When a symbol raises ``Mt5TradingError`` and + Mt5OperationError: When a symbol raises ``Mt5OperationError`` and ``suppress_errors=False``. Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and ``suppress_errors=False``. @@ -755,7 +818,7 @@ def calculate_positions_margin_by_symbol( for symbol in dict.fromkeys(symbols): try: result[symbol] = calculate_positions_margin(client, symbols=[symbol]) - except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc: + except (Mt5OperationError, Mt5RuntimeError, AttributeError) as exc: if not suppress_errors: raise _logger.warning("Skipping margin for %r: %s", symbol, exc) @@ -763,7 +826,7 @@ def calculate_positions_margin_by_symbol( def calculate_positions_margin_safe( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbols: Sequence[str], ) -> float: @@ -773,7 +836,7 @@ def calculate_positions_margin_safe( ``suppress_errors=True``. Failed symbols are silently skipped. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbols: Symbols to include. 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. Raises: - Mt5TradingError: If bid or ask is unavailable. + Mt5OperationError: If bid or ask is unavailable. """ tick = get_tick_snapshot(client, symbol) bid = extract_tick_price(tick, "bid") ask = extract_tick_price(tick, "ask") if bid is None or ask is None: msg = f"Tick bid/ask is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) return (ask - bid) / ((ask + bid) / 2.0) def calculate_new_position_margin_ratio( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, 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. Raises: - Mt5TradingError: If equity or required tick data is invalid. + Mt5OperationError: If equity or required tick data is invalid. """ account = get_account_snapshot(client) equity = float(account.get("equity") or 0.0) if equity <= 0: msg = "Account equity must be positive to calculate margin ratio." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) margin = float(account.get("margin") or 0.0) if new_position_side is not None and new_position_volume > 0: side = _normalize_order_side(new_position_side) @@ -825,7 +888,7 @@ def calculate_new_position_margin_ratio( ) if price is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) order_type = ( 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 -def _account_equity(client: Mt5TradingClient) -> float: +def _account_equity(client: _Mt5ClientProtocol) -> float: account = get_account_snapshot(client) return _required_account_number(account, "equity", allow_zero=False) @@ -849,7 +912,7 @@ def _required_account_number( raw_value = account.get(field) if isinstance(raw_value, bool) or not isinstance(raw_value, Real): msg = f"Account {field} must be a finite number to calculate margin ratio." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) value = float(raw_value) if ( not isfinite(value) @@ -861,12 +924,12 @@ def _required_account_number( if allow_zero else f"Account {field} must be a positive finite number." ) - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) return value def calculate_account_projected_margin_ratio( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str | None = None, new_position_side: OrderSide | None = None, @@ -894,7 +957,7 @@ def calculate_account_projected_margin_ratio( def calculate_projected_margin_ratio( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, new_position_side: OrderSide | None = None, @@ -933,7 +996,7 @@ def _validate_projection_mode(projection_mode: str) -> ProjectionMode: def calculate_symbol_group_margin_ratio( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbols: Sequence[str], new_symbol: str | None = None, @@ -962,7 +1025,7 @@ def calculate_symbol_group_margin_ratio( fails and ``suppress_errors`` is ``False``. Mt5RuntimeError: When symbol margin lookup or projected margin lookup 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 ``False``. """ @@ -987,7 +1050,7 @@ def calculate_symbol_group_margin_ratio( new_position_side, new_position_volume, ) - except (Mt5TradingError, Mt5RuntimeError, AttributeError): + except (Mt5OperationError, Mt5RuntimeError, AttributeError): if not suppress_errors: raise _logger.warning("Skipping projected margin for %r.", new_symbol) @@ -999,7 +1062,7 @@ def calculate_symbol_group_margin_ratio( def calculate_margin_and_volume( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, unit_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. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbol: Symbol used for minimum-lot margin and volume calculations. unit_margin_ratio: Fraction of post-reserve margin to allocate per unit. preserved_margin_ratio: Fraction of ``margin_free`` to preserve. @@ -1066,7 +1129,7 @@ def calculate_margin_and_volume( def calculate_volume_by_margin( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, available_margin: float, order_side: OrderSide, @@ -1079,7 +1142,7 @@ def calculate_volume_by_margin( constraints; ``0.0`` when no affordable step exists. 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: 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) if volume_min <= 0 or volume_step <= 0: msg = f"Invalid volume constraints for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) side = _normalize_order_side(order_side) price = extract_tick_price( get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" ) if price is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) order_type = ( 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( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, symbol: str, side: PositionSide | str, 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. Args: - client: Connected ``Mt5TradingClient`` instance. + client: Connected MT5 client instance. symbol: Symbol used for the quote lookup. side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"`` aliases are accepted). @@ -1161,7 +1224,7 @@ def determine_order_limits( Omitted protective levels are returned as ``None``. 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. """ 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) if entry is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) try: symbol_info = get_symbol_snapshot(client, symbol) except (AttributeError, KeyError, TypeError, ValueError): @@ -1218,7 +1281,7 @@ def determine_order_limits( def place_market_order( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, volume: float, @@ -1232,20 +1295,20 @@ def place_market_order( ) -> 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. + ``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. + Mt5OperationError: If volume or required tick data is invalid. """ if volume <= 0: msg = "volume must be positive." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) side = _normalize_order_side(order_side) if not dry_run: ensure_symbol_selected(client, symbol) @@ -1253,7 +1316,7 @@ def place_market_order( price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") if price is None: msg = f"Tick price is unavailable for {symbol!r}." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) request = { "action": client.mt5.TRADE_ACTION_DEAL, "symbol": symbol, @@ -1326,7 +1389,7 @@ def _filter_positions( def close_open_positions( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbols: str | list[str] | None = None, tickets: list[int] | None = None, @@ -1358,7 +1421,7 @@ def close_open_positions( return results -def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None: +def _symbol_digits(client: _Mt5ClientProtocol, symbol: str) -> int | None: try: raw_digits = get_symbol_snapshot(client, symbol).get("digits") if raw_digits is None: @@ -1379,7 +1442,7 @@ def _current_stop_loss(value: object) -> float | None: def _trailing_stop_loss( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, position_type: object, current_sl: float | None, @@ -1402,7 +1465,7 @@ def _trailing_stop_loss( def calculate_trailing_stop_updates( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, trailing_stop_ratio: float, @@ -1447,7 +1510,7 @@ def calculate_trailing_stop_updates( def update_trailing_stop_loss_for_open_positions( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, trailing_stop_ratio: float, @@ -1478,7 +1541,7 @@ def update_trailing_stop_loss_for_open_positions( def update_sltp_for_open_positions( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str | 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( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, granularity: str, @@ -1556,7 +1619,7 @@ def fetch_latest_closed_rates_for_trading_client( 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. + Mt5OperationError: If the trading client cannot fetch rate data. """ if count <= 0: 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) if not callable(fetch_method): msg = "MT5 trading client cannot fetch rate data." - raise Mt5TradingError(msg) + raise Mt5OperationError(msg) fetched = fetch_method(symbol, granularity, count + 1) if not isinstance(fetched, pd.DataFrame): msg = ( @@ -1627,7 +1690,7 @@ def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex: def fetch_latest_closed_rates_indexed( - client: Mt5TradingClient, + client: _Mt5ClientProtocol, *, symbol: str, granularity: str, @@ -1683,13 +1746,13 @@ def mt5_trading_session( path: str | None = None, timeout: int | None = None, retry_count: int = 0, -) -> Iterator[Mt5TradingClient]: +) -> Iterator[_Mt5ClientProtocol]: """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. + connected client supporting required MT5 methods, and calls ``shutdown()`` + on exit even when an error is raised inside the context. Args: config: MT5 connection configuration. Defaults to an empty config that @@ -1699,11 +1762,10 @@ def mt5_trading_session( 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``. + retry_count: Number of initialization retries. Yields: - Connected ``Mt5TradingClient`` bound to the session. + Connected client supporting required MT5 trading methods. """ client = create_trading_client( config=config, diff --git a/pyproject.toml b/pyproject.toml index 62d43c1..54498f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,7 +178,10 @@ omit = [ [tool.coverage.report] show_missing = true fail_under = 100 -exclude_lines = ["if TYPE_CHECKING:"] +exclude_also = [ + "if TYPE_CHECKING:", + "^\\s+\\.\\.\\.$", +] [build-system] requires = ["hatchling"] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 7767b96..7519466 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -705,7 +705,7 @@ class TestStableSdkContract: """Trading session helper initializes and always shuts down.""" mock_client = MagicMock() mocker.patch( - "mt5cli.trading.Mt5TradingClient", + "mt5cli.trading.Mt5DataClient", return_value=mock_client, ) @@ -734,7 +734,7 @@ class TestStableSdkContract: """Trading session helper shuts down even when the body raises.""" mock_client = MagicMock() mocker.patch( - "mt5cli.trading.Mt5TradingClient", + "mt5cli.trading.Mt5DataClient", 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__" +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 # --------------------------------------------------------------------------- diff --git a/tests/test_trading.py b/tests/test_trading.py index 9f26b01..c9b89e3 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -14,6 +14,7 @@ from numpy import int64 as np_int64 from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pytest_mock import MockerFixture # noqa: TC002 +from mt5cli.exceptions import Mt5OperationError from mt5cli.sdk import build_config from mt5cli.trading import ( MarginVolume, @@ -434,7 +435,7 @@ class TestDetermineOrderLimits: client = MagicMock() 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") def test_accepts_numeric_string_entry(self) -> None: @@ -483,7 +484,7 @@ class TestDetermineOrderLimits: client = MagicMock() 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) @pytest.mark.parametrize( @@ -512,7 +513,7 @@ class TestDetermineOrderLimits: "point": 0.0001, } - with pytest.raises(Mt5TradingError, match=match): + with pytest.raises(Mt5OperationError, match=match): determine_order_limits(client, "EURUSD", side, **{kwarg: 0.0001}) 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.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") def test_raises_when_symbol_select_is_unavailable(self) -> None: @@ -629,7 +630,7 @@ class TestDetermineOrderLimits: del client.symbol_select with pytest.raises( - Mt5TradingError, + Mt5OperationError, match="missing required method: symbol_select", ): ensure_symbol_selected(client, "EURUSD") @@ -645,7 +646,7 @@ class TestMt5TradingSession: """Test mt5_trading_session connects, yields a client, and shuts down.""" mock_client = MagicMock() trading_client = mocker.patch( - "mt5cli.trading.Mt5TradingClient", + "mt5cli.trading.Mt5DataClient", return_value=mock_client, ) @@ -668,10 +669,10 @@ class TestCreateTradingClient: """Tests for create_trading_client.""" 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() trading_client = mocker.patch( - "mt5cli.trading.Mt5TradingClient", + "mt5cli.trading.Mt5DataClient", return_value=mock_client, ) @@ -695,7 +696,7 @@ class TestCreateTradingClient: def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None: """Test empty login strings are treated as None.""" trading_client = mocker.patch( - "mt5cli.trading.Mt5TradingClient", + "mt5cli.trading.Mt5DataClient", return_value=MagicMock(), ) @@ -708,7 +709,7 @@ class TestCreateTradingClient: """Test failed initialization shuts the client down.""" mock_client = MagicMock() 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"): create_trading_client() @@ -791,7 +792,7 @@ class TestSnapshotsAndState: client = MagicMock() 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") def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None: @@ -799,7 +800,7 @@ class TestSnapshotsAndState: client = MagicMock() 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") def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None: @@ -843,7 +844,7 @@ class TestSnapshotsAndState: client = MagicMock() 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") @@ -1033,7 +1034,7 @@ class TestEstimateOrderMargin: """Test non-positive volume raises Mt5TradingError.""" 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) @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.""" 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) client.symbol_info_tick_as_dict.assert_not_called() @@ -1052,7 +1053,7 @@ class TestEstimateOrderMargin: client = _mock_trade_client() 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) def test_rejects_non_positive_tick_price(self) -> None: @@ -1060,7 +1061,7 @@ class TestEstimateOrderMargin: client = _mock_trade_client() 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) def test_rejects_non_finite_tick_price(self) -> None: @@ -1071,7 +1072,7 @@ class TestEstimateOrderMargin: "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) @pytest.mark.parametrize( @@ -1085,7 +1086,7 @@ class TestEstimateOrderMargin: client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} 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) @@ -1184,7 +1185,7 @@ class TestCalculatePositionsMargin: ) 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) def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None: @@ -1376,7 +1377,7 @@ class TestVolumeAndExecution: "volume_step": 0.1, } - with pytest.raises(Mt5TradingError): + with pytest.raises(Mt5OperationError): calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY") 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} - with pytest.raises(Mt5TradingError): + with pytest.raises(Mt5OperationError): calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL") def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget( @@ -1604,7 +1605,7 @@ class TestVolumeAndExecution: "volume_step": 0.1, } - with pytest.raises(Mt5TradingError, match="Invalid volume constraints"): + with pytest.raises(Mt5OperationError, match="Invalid volume constraints"): calculate_margin_and_volume( client, "EURUSD", @@ -1623,7 +1624,7 @@ class TestVolumeAndExecution: } 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( client, "EURUSD", @@ -1722,7 +1723,7 @@ class TestVolumeAndExecution: client = _mock_trade_client() 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") 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.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( client, symbol="EURUSD", @@ -1905,7 +1906,7 @@ class TestVolumeAndExecution: client = _mock_trade_client() 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) def test_account_projected_margin_ratio_propagates_candidate_margin_error( @@ -1920,10 +1921,10 @@ class TestVolumeAndExecution: } mocker.patch( "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( client, symbol="EURUSD", @@ -1999,7 +2000,7 @@ class TestVolumeAndExecution: client = _mock_trade_client() 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"]) def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None: @@ -2007,7 +2008,7 @@ class TestVolumeAndExecution: client = _mock_trade_client() 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") def test_symbol_group_margin_ratio_suppresses_projected_failure( @@ -2024,7 +2025,7 @@ class TestVolumeAndExecution: ) mocker.patch( "mt5cli.trading.estimate_order_margin", - side_effect=Mt5TradingError("bad tick"), + side_effect=Mt5OperationError("bad tick"), ) with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): @@ -2053,10 +2054,10 @@ class TestVolumeAndExecution: ) mocker.patch( "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( client, symbols=["EURUSD"], @@ -2177,7 +2178,7 @@ class TestVolumeAndExecution: ) mocker.patch( "mt5cli.trading.estimate_order_margin", - side_effect=Mt5TradingError("bad tick"), + side_effect=Mt5OperationError("bad tick"), ) with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): @@ -2208,10 +2209,10 @@ class TestVolumeAndExecution: ) mocker.patch( "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( client, symbols=["EURUSD"], @@ -2335,7 +2336,7 @@ class TestVolumeAndExecution: del client.mt5.ORDER_FILLING_IOC 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( client, symbol="EURUSD", @@ -2346,7 +2347,7 @@ class TestVolumeAndExecution: def test_place_market_order_rejects_invalid_volume(self) -> None: """Test non-positive volume raises a trading error.""" - with pytest.raises(Mt5TradingError): + with pytest.raises(Mt5OperationError): place_market_order( _mock_trade_client(), symbol="EURUSD", @@ -2359,7 +2360,7 @@ class TestVolumeAndExecution: client = _mock_trade_client() client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1} - with pytest.raises(Mt5TradingError): + with pytest.raises(Mt5OperationError): place_market_order( client, symbol="EURUSD", @@ -2913,7 +2914,7 @@ class TestVolumeAndExecution: """Test shutdown is called when initialization fails.""" mock_client = MagicMock() 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(): pass @@ -3070,7 +3071,7 @@ class TestVolumeAndExecution: def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None: """Test shutdown is called when the context body raises.""" 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" with pytest.raises(RuntimeError, match=body_error), mt5_trading_session(): @@ -3201,7 +3202,7 @@ class TestFetchLatestClosedRatesForTradingClient: """Test missing rate-fetch methods raise Mt5TradingError.""" 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( client, symbol="EURUSD",