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:
co-authored by
Claude
parent
f435544f07
commit
93565681e1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user