diff --git a/README.md b/README.md index 16b417e..085aca3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Generic MT5 data and execution infrastructure for Python applications. Export from the CLI or import a small, stable Python API in downstream packages. +The [Public API Contract](docs/api/public-contract.md) lists stable SDK exports (`mt5cli.STABLE_SDK_EXPORTS`), CLI commands, internal helpers, and responsibilities that remain out of scope (strategy logic, backtests, optimization). + Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. ## Architecture @@ -227,7 +229,7 @@ update_history_with_config( - **`collect-history`**: explicit date-range export into SQLite. - **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`. - **`rates` table**: normalized storage with `symbol` and `timeframe` columns. -- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate___` when a symbol has one timeframe, otherwise `rate____` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize. +- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate___` when a symbol has one timeframe, otherwise `rate____` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline downstream tools. - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`. - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame. - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets. @@ -260,12 +262,12 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only - Windows OS (MetaTrader 5 requirement) - MetaTrader 5 platform installed -### Migration note for mteor +### Migration note for downstream trading apps Replace local MT5 lifecycle and trading helper code with mt5cli imports: ```python -# Before (local mteor helpers) +# Before (local application helpers) # with local_mt5_trading_session(config) as client: # side = local_detect_position_side(client, symbol) # sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio) diff --git a/docs/api/index.md b/docs/api/index.md index 395ab20..184ad52 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -2,10 +2,15 @@ This section documents the mt5cli public Python API and CLI modules. +Start with the [Public API Contract](public-contract.md) for the stable +downstream SDK surface, CLI boundary, internal modules, and out-of-scope strategy +responsibilities. + ## Public API layers | Module | Purpose | | ----------------------------------------- | ------------------------------------------------------------------------- | +| [Public API Contract](public-contract.md) | Stable downstream SDK exports, CLI boundary, and out-of-scope items | | [Client](client.md) | `MT5Client` session abstraction for data access and order primitives | | [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers | | [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers | @@ -30,7 +35,10 @@ flowchart TD SDK --> PDMT5["pdmt5.Mt5DataClient"] ``` -Downstream packages should depend on the package root exports (`MT5Client`, `DataKind`, `normalize_dataframe`, `export_dataframe`, `collect_history`, etc.) rather than private modules. +Downstream packages should depend on the package root exports documented in the +[Public API Contract](public-contract.md) (`MT5Client`, +`DataKind`, `normalize_dataframe`, `collect_history`, `load_rate_data`, +`resolve_rate_view_name`, etc.) rather than private modules. `MT5Client.order_send()` is a live execution primitive that can place real trades. mt5cli exposes minimal execution helpers only; strategy logic, signals, backtests, and optimization remain out of scope and must be implemented downstream with explicit execution gating. diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md new file mode 100644 index 0000000..adc3cd6 --- /dev/null +++ b/docs/api/public-contract.md @@ -0,0 +1,184 @@ +# Public API Contract + +mt5cli is the generic MT5 data and execution infrastructure layer for downstream +Python applications. The intended dependency direction is: + +```text +downstream app -> mt5cli -> pdmt5 -> MetaTrader 5 +``` + +Downstream packages should import from the package root (`from mt5cli import +...`) and treat the symbols listed below as the stable SDK contract. CLI +commands mirror the same behavior but are not importable Python APIs. + +## Stable downstream SDK API + +These names are exported from `mt5cli` and covered by the contract in +`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient` +alias for new code. + +### Session lifecycle and configuration + +| Symbol | Role | +| ----------------------------------------------- | ---------------------------------------------------------------- | +| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` | +| `build_config` | Build `pdmt5.Mt5Config` from connection fields | +| `mt5_session` | Context manager: initialize, login, yield client, shutdown | +| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle | +| `AccountSpec` | Generic account group: symbols plus optional credentials | +| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders | +| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment | + +Credential resolution is generic: any environment variable name may appear inside +`${...}`. mt5cli does not hard-code application-specific keys such as +`mt5_login` or `mt5_exe`. + +### Read-only MT5 data access + +Module-level helpers open a transient connection per call. Prefer `mt5_session` +or `MT5Client` when making many requests in one process. + +| Area | Symbols | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` | +| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` | +| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` | +| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` | +| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` | + +Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at +the package root refers to `importlib.metadata.version` (package metadata), not +the MT5 SDK helper. + +### Closed-bar rate helpers + +MetaTrader 5 returns the still-forming bar as the last row when +`start_pos=0`. Use these helpers instead of reimplementing bar trimming or +timestamp normalization in downstream apps. + +| Symbol | Role | +| ------------------------------------------------ | ------------------------------------------------------------ | +| `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 | +| `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_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` | +| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors | + +### SQLite history collection and rate loading + +| Symbol | Role | +| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `collect_history` | One-shot date-range export into SQLite | +| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors | +| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates | +| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration | +| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names | +| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors | +| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame | +| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing | + +Pass `require_existing=True` to rate view resolution helpers when downstream +code must fail instead of receiving a best-guess view name. Multi-series loaders +require existing managed `rate_*__*` views unless `explicit_tables` is supplied. + +See [History Collection (SQLite)](history.md) for schema, view naming, and ER +diagrams. + +### Trading and sizing primitives (generic) + +These helpers implement broker-facing calculations only. They do not encode +strategy entries, exits, Kelly sizing, or signal logic. + +| Symbol | Role | +| -------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views | +| `detect_position_side` | Net long / short / flat from open positions | +| `calculate_spread_ratio` | Relative bid-ask spread | +| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing | +| `determine_order_limits` | SL/TP price levels from ratios | +| `ensure_symbol_selected` | Select/verify Market Watch visibility | +| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) | +| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | +| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | + +`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. + +Order helpers validate broker stop-level distance in `determine_order_limits()` and +raise `Mt5TradingError` when computed SL/TP prices are too close to the entry +quote. Validation uses `trade_stops_level * point` from the current quote and +symbol metadata as a pre-check only; it does not guarantee live order acceptance +after price movement and does not inspect `trade_freeze_level`. Live +`place_market_order()` and SL/TP updates call +`ensure_symbol_selected()` so hidden symbols are added to Market Watch before +sending requests. Failed, malformed, or unknown broker retcodes are fail-closed +and returned as `status="failed"` with normalized `request` / `response` details; +`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`. + +### Errors and MT5 type re-exports + +| Symbol | Role | +| ------------------------------------------------------------------------------------ | ----------------------------------------------- | +| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types | +| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification | +| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience | + +### Additional public exports (secondary) + +The package root also exports schema, storage, and parsing helpers (for example +`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`, +`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export +pipelines and advanced integration. Prefer the stable symbols above for core +infrastructure. + +## CLI commands + +The Typer application in `mt5cli.cli` exposes file-export commands documented in +[CLI Module](cli.md) and the project README. CLI commands: + +- Require `-o/--output` and write CSV, JSON, Parquet, or SQLite. +- Accept global MT5 connection options (`--login`, `--password`, `--server`, + `--path`, `--timeout`). +- Delegate to the same Python APIs described here; they are not duplicated + business logic. + +`order-send` requires `--yes` before placing live trades. + +## Internal helpers (not stable) + +Do not import these for downstream contracts; they may change without a semver +notice: + +| Module | Examples | +| ------------------------ | ------------------------------------------------------------------------- | +| `mt5cli.sdk` | `connected_client`, `_run_with_client`, private coercion helpers | +| `mt5cli.history` | `write_*_dataset`, `deduplicate_history_tables`, `parse_sqlite_timestamp` | +| `mt5cli.retry` | `retry_with_backoff` | +| `mt5cli.cli` | Typer command handlers and Click parameter types | +| Leading-underscore names | Any `_`-prefixed function or method | + +Use the package-root stable exports instead of reaching into submodule +internals. + +## Explicitly out of scope + +mt5cli must **not** implement downstream strategy or research responsibilities. +The following belong in consuming applications, not in mt5cli: + +- Signal detection (for example AR-GARCH or other model-specific triggers) +- Backtesting, walk-forward analysis, or parameter optimization +- Strategy-specific risk policy, position sizing systems, or Kelly fractions +- Entry/exit decision logic or YAML strategy semantics +- Application-specific credential schema keys wired into mt5cli internals + +mt5cli provides connection lifecycle, normalized data access, SQLite history +machinery, closed-bar helpers, generic margin/volume/spread/SL/TP utilities, and +optional order primitives so downstream apps can focus on strategy code behind +their own adapter layer. + +## Contract verification + +`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is +importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading, +account-resolution, and trading-session behaviors, and keeps the contract set +aligned with `__all__`. diff --git a/docs/api/trading.md b/docs/api/trading.md index 957b03e..519181e 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -90,18 +90,65 @@ sell-only exposure, and `None` for no positions or mixed long/short exposure. SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` omits that level. SL/TP prices are rounded with symbol `digits` metadata when -available. `unit_margin_ratio` and `preserved_margin_ratio` for -`calculate_margin_and_volume()` accept `0 <= ratio <= 1`; `unit_margin_ratio=0` -requests one minimum valid unit when the post-reserve margin can afford it. -Negative `margin_free` is clamped to `0.0` before sizing. Execution helpers -return normalized dictionaries containing the request, response, status, -retcode, and `dry_run` flag; `dry_run=True` never sends an order. Market order -helpers mark known non-success MT5 retcodes as `status="failed"` while keeping -the normalized response for inspection. +available. `determine_order_limits()` pre-validates computed SL/TP prices against +available `trade_stops_level * point` metadata when present; violations raise +`Mt5TradingError`. This is a planning helper only: it does not guarantee broker +acceptance because live validation can still depend on price movement, bid/ask +side, freeze levels, and server-side rules, and it does not validate +`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices +still round with `digits=8` and stop-level validation is skipped. +`unit_margin_ratio` and `preserved_margin_ratio` for `calculate_margin_and_volume()` +accept `0 <= ratio <= 1`; `unit_margin_ratio=0` requests one minimum valid unit +when the post-reserve margin can afford it. Negative `margin_free` is clamped to +`0.0` before sizing. Execution helpers return normalized `OrderExecutionResult` +dictionaries containing the request, response, status, retcode, and `dry_run` +flag; `dry_run=True` never sends an order or mutates Market Watch visibility. +`ensure_symbol_selected()` adds hidden symbols to Market Watch before live order +placement and SL/TP updates. Failed, malformed, or unknown broker retcodes are +fail-closed and returned as `status="failed"` while keeping the normalized +response for inspection. -## Migration from mteor-local helpers +## Order planning return contracts -| mteor-local concern | mt5cli replacement | +```python +from mt5cli import MarginVolume, OrderLimits, OrderExecutionResult + +sizing: MarginVolume = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.5, + preserved_margin_ratio=0.2, +) +limits: OrderLimits = determine_order_limits( + client, + "EURUSD", + side="long", + stop_loss_limit_ratio=0.01, + take_profit_limit_ratio=0.02, +) +preview: OrderExecutionResult = place_market_order( + client, + symbol="EURUSD", + volume=sizing["buy_volume"], + order_side="BUY", + sl=limits["stop_loss"], + tp=limits["take_profit"], + dry_run=True, +) +updates: list[OrderExecutionResult] = update_sltp_for_open_positions( + client, + symbol="EURUSD", + stop_loss=limits["stop_loss"], + dry_run=True, +) +``` + +Closes issue #33: strategy-neutral order planning and execution helpers exposed +through the stable package root without embedding entry/exit policy. + +## Migration from application-local helpers + +| Application-local concern | mt5cli replacement | | -------------------------------------------------------- | ----------------------------------------------- | | Manual terminal spawn/kill around trading code | `mt5_trading_session()` | | Local position-side detection | `detect_position_side()` | diff --git a/mkdocs.yml b/mkdocs.yml index 0e112ca..125bba5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - Home: index.md - API Reference: - Overview: api/index.md + - Public API Contract: api/public-contract.md - Client: api/client.md - Schemas: api/schemas.md - Storage: api/storage.md diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index 2c54b5d..a7d82ba 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -1,10 +1,17 @@ -"""mt5cli: Generic MT5 data and execution infrastructure for Python applications.""" +"""mt5cli: Generic MT5 data and execution infrastructure for Python applications. + +Downstream packages should import from this module (``from mt5cli import ...``) +rather than private submodule helpers. See ``docs/api/public-contract.md`` for +the stable SDK contract, CLI surface, internal modules, and out-of-scope +strategy responsibilities. +""" from importlib.metadata import version from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from .client import MT5Client, build_config, mt5_session +from .contract import STABLE_SDK_EXPORTS from .converters import ( ensure_utc, granularity_name, @@ -101,7 +108,11 @@ from .storage import ( ) from .trading import ( POSITION_COLUMNS, + ExecutionStatus, + MarginVolume, + OrderExecutionResult, OrderFillingMode, + OrderLimits, OrderSide, OrderTimeMode, PositionSide, @@ -113,6 +124,7 @@ from .trading import ( create_trading_client, detect_position_side, determine_order_limits, + ensure_symbol_selected, get_account_snapshot, get_positions_frame, get_symbol_snapshot, @@ -136,14 +148,17 @@ __all__ = [ "KNOWN_MT5_TIME_COLUMNS", "POSITION_COLUMNS", "REQUIRED_COLUMNS", + "STABLE_SDK_EXPORTS", "TICK_FLAG_MAP", "TIMEFRAME_MAP", "TIME_COLUMNS", "AccountSpec", "DataKind", "Dataset", + "ExecutionStatus", "IfExists", "MT5Client", + "MarginVolume", "Mt5CliClient", "Mt5CliError", "Mt5Config", @@ -153,7 +168,9 @@ __all__ = [ "Mt5SchemaError", "Mt5TradingClient", "Mt5TradingError", + "OrderExecutionResult", "OrderFillingMode", + "OrderLimits", "OrderSide", "OrderTimeMode", "PositionSide", @@ -185,6 +202,7 @@ __all__ = [ "detect_position_side", "determine_order_limits", "drop_forming_rate_bar", + "ensure_symbol_selected", "ensure_utc", "export_dataframe", "export_dataframe_to_sqlite", diff --git a/mt5cli/contract.py b/mt5cli/contract.py new file mode 100644 index 0000000..34e4acc --- /dev/null +++ b/mt5cli/contract.py @@ -0,0 +1,101 @@ +"""Stable downstream SDK export names for mt5cli.""" + +from __future__ import annotations + +STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ + "AccountSpec", + "MT5Client", + "Mt5CliClient", + "Mt5CliError", + "Mt5Config", + "Mt5ConnectionError", + "Mt5OperationError", + "Mt5RuntimeError", + "Mt5SchemaError", + "Mt5TradingClient", + "Mt5TradingError", + "OrderFillingMode", + "OrderSide", + "OrderTimeMode", + "PositionSide", + "ExecutionStatus", + "MarginVolume", + "OrderExecutionResult", + "OrderLimits", + "RateTarget", + "ThrottledHistoryUpdater", + "account_info", + "build_config", + "build_rate_targets", + "build_rate_view_name", + "calculate_margin_and_volume", + "calculate_new_position_margin_ratio", + "calculate_spread_ratio", + "calculate_volume_by_margin", + "call_with_normalized_errors", + "close_open_positions", + "collect_history", + "collect_latest_closed_rates_by_granularity", + "collect_latest_closed_rates_for_accounts", + "collect_latest_rates", + "collect_latest_rates_for_accounts", + "collect_latest_rates_for_accounts_with_retries", + "copy_rates_from", + "copy_rates_from_pos", + "copy_rates_range", + "copy_ticks_from", + "copy_ticks_range", + "create_trading_client", + "detect_position_side", + "determine_order_limits", + "drop_forming_rate_bar", + "ensure_symbol_selected", + "export_dataframe", + "export_dataframe_to_sqlite", + "fetch_latest_closed_rates", + "get_account_snapshot", + "get_positions_frame", + "get_symbol_snapshot", + "get_tick_snapshot", + "history_deals", + "history_orders", + "is_recoverable_mt5_error", + "last_error", + "latest_rates", + "load_rate_data", + "load_rate_data_from_connection", + "load_rate_series_by_granularity", + "load_rate_series_from_sqlite", + "market_book", + "minimum_margins", + "mt5_session", + "mt5_summary", + "mt5_summary_as_df", + "mt5_trading_session", + "mt5_version", + "normalize_mt5_exception", + "orders", + "place_market_order", + "positions", + "recent_history_deals", + "recent_ticks", + "resolve_account_spec", + "resolve_account_specs", + "resolve_history_datasets", + "resolve_history_tick_flags", + "resolve_history_timeframes", + "resolve_rate_table_name", + "resolve_rate_tables", + "resolve_rate_view_name", + "resolve_rate_view_names", + "substitute_env_placeholders", + "symbol_info", + "symbol_info_tick", + "symbols", + "terminal_info", + "update_history", + "update_history_with_config", + "update_sltp_for_open_positions", +}) + +__all__ = ["STABLE_SDK_EXPORTS"] diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 18ee1c8..2604ce0 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -4,7 +4,8 @@ from __future__ import annotations from contextlib import contextmanager from math import floor, isfinite -from typing import TYPE_CHECKING, Literal, cast +from numbers import Integral +from typing import TYPE_CHECKING, Literal, TypedDict, cast import pandas as pd from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError @@ -19,6 +20,44 @@ PositionSide = Literal["long", "short"] OrderSide = Literal["BUY", "SELL"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"] OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] +ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"] + + +class MarginVolume(TypedDict): + """Affordable volume bounds derived from account margin and symbol constraints.""" + + margin_free: float + available_margin: float + trade_margin: float + buy_volume: float + sell_volume: float + volume_min: float + volume_max: float + volume_step: float + + +class OrderLimits(TypedDict): + """Protective order prices derived from current quotes and ratio parameters.""" + + entry: float + stop_loss: float | None + take_profit: float | None + + +class OrderExecutionResult(TypedDict): + """Normalized result from market-order and position-management helpers.""" + + status: ExecutionStatus + symbol: str + order_side: OrderSide + volume: float + retcode: int | None + comment: str | None + request: dict[str, object] + response: dict[str, object] | None + dry_run: bool + + _ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"}) _ORDER_TIME_MODES: frozenset[str] = frozenset({ "GTC", @@ -76,7 +115,11 @@ POSITION_COLUMNS = ( __all__ = [ "POSITION_COLUMNS", + "ExecutionStatus", + "MarginVolume", + "OrderExecutionResult", "OrderFillingMode", + "OrderLimits", "OrderSide", "OrderTimeMode", "PositionSide", @@ -88,6 +131,7 @@ __all__ = [ "create_trading_client", "detect_position_side", "determine_order_limits", + "ensure_symbol_selected", "get_account_snapshot", "get_positions_frame", "get_symbol_snapshot", @@ -98,6 +142,91 @@ __all__ = [ ] +def _minimum_stop_distance( + symbol_info: dict[str, float | int | str | bool | None], +) -> float: + """Return the minimum SL/TP distance in price units from broker stop level.""" + stops_level = symbol_info.get("trade_stops_level") + point = symbol_info.get("point") + if not isinstance(stops_level, int | float) or not isinstance(point, int | float): + return 0.0 + level = float(stops_level) + pt = float(point) + if level <= 0 or pt <= 0: + return 0.0 + return level * pt + + +def _validate_protective_prices( + *, + symbol: str, + side: PositionSide, + entry: float, + stop_loss: float | None, + take_profit: float | None, + min_distance: float, +) -> None: + """Validate SL/TP distances against broker stop-level constraints. + + Raises: + Mt5TradingError: When a protective price is closer than ``min_distance``. + """ + if min_distance <= 0: + return + if side == "long": + if stop_loss is not None and (entry - stop_loss) < min_distance: + msg = ( + f"Stop loss for {symbol!r} violates broker stop level " + f"(minimum distance {min_distance})." + ) + raise Mt5TradingError(msg) + if take_profit is not None and (take_profit - entry) < min_distance: + msg = ( + f"Take profit for {symbol!r} violates broker stop level " + f"(minimum distance {min_distance})." + ) + raise Mt5TradingError(msg) + return + if stop_loss is not None and (stop_loss - entry) < min_distance: + msg = ( + f"Stop loss for {symbol!r} violates broker stop level " + f"(minimum distance {min_distance})." + ) + raise Mt5TradingError(msg) + if take_profit is not None and (entry - take_profit) < min_distance: + msg = ( + f"Take profit for {symbol!r} violates broker stop level " + f"(minimum distance {min_distance})." + ) + raise Mt5TradingError(msg) + + +def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None: + """Ensure a symbol is visible in Market Watch before sending orders. + + Args: + client: Connected ``Mt5TradingClient`` instance. + symbol: Symbol to select. + + Raises: + Mt5TradingError: If the symbol cannot be selected in Market Watch or + ``symbol_select`` is unavailable on the client. + """ + snapshot = get_symbol_snapshot(client, symbol) + if snapshot.get("visible"): + return + select = getattr(client, "symbol_select", None) + if not callable(select): + msg = "MT5 client is missing required method: symbol_select" + raise Mt5TradingError(msg) + if select(symbol, enable=True): + return + last_error = getattr(client, "last_error", None) + detail = f" ({last_error()})" if callable(last_error) else "" + msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}." + raise Mt5TradingError(msg) + + def _require_unit_ratio(value: float, name: str) -> None: if not 0.0 <= value <= 1.0: msg = f"{name} must be between 0 and 1 inclusive." @@ -207,6 +336,33 @@ def _resolve_mt5_constant( raise Mt5TradingError(msg) from exc +def _parse_digit_string(value: str) -> int | None: + text = value.strip() + if not text: + return None + sign = 1 + if text[0] == "+": + text = text[1:].strip() + elif text[0] == "-": + sign = -1 + text = text[1:].strip() + return sign * int(text) if text.isdigit() else None + + +def _optional_int(value: object) -> int | None: + if value is None or isinstance(value, bool): + return None + if isinstance(value, Integral): + return int(value) + if isinstance(value, str): + return _parse_digit_string(value) + return None + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + def _optional_price(value: object) -> float | None: if value is None: return None @@ -227,10 +383,11 @@ def _success_retcodes(mt5: object) -> frozenset[int]: return frozenset(values) or _SUCCESS_RETCODE_FALLBACKS -def _order_status_from_retcode(mt5: object, retcode: object) -> str: - if retcode is None: - return "executed" - if isinstance(retcode, int) and retcode not in _success_retcodes(mt5): +def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus: + normalized = _optional_int(retcode) + if normalized is None: + return "failed" + if normalized not in _success_retcodes(mt5): return "failed" return "executed" @@ -431,7 +588,7 @@ def calculate_margin_and_volume( symbol: str, unit_margin_ratio: float, preserved_margin_ratio: float, -) -> dict[str, float]: +) -> MarginVolume: """Calculate tradable margin and volumes from account free margin. Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``, @@ -561,7 +718,7 @@ def determine_order_limits( side: PositionSide | str, stop_loss_limit_ratio: float | None = None, take_profit_limit_ratio: float | None = None, -) -> dict[str, float | None]: +) -> OrderLimits: """Derive entry and protective order prices from current market quotes. Args: @@ -579,7 +736,8 @@ def determine_order_limits( Omitted protective levels are returned as ``None``. Raises: - Mt5TradingError: If required tick data is invalid. + Mt5TradingError: If required tick data is invalid or computed SL/TP + prices violate available ``trade_stops_level`` pre-validation. """ stop_loss_ratio = stop_loss_limit_ratio or 0.0 take_profit_ratio = take_profit_limit_ratio or 0.0 @@ -593,9 +751,14 @@ def determine_order_limits( raise Mt5TradingError(msg) entry = float(entry_value) try: - digits = int(get_symbol_snapshot(client, symbol).get("digits") or 8) - except AttributeError: + symbol_info = get_symbol_snapshot(client, symbol) + except (AttributeError, KeyError, TypeError, ValueError): + symbol_info = {} + try: + digits = int(symbol_info.get("digits") or 8) + except (TypeError, ValueError): digits = 8 + min_distance = _minimum_stop_distance(symbol_info) stop_loss: float | None = None if stop_loss_ratio > 0: @@ -613,6 +776,15 @@ def determine_order_limits( take_profit = entry * (1.0 - take_profit_ratio) take_profit = round(take_profit, digits) + _validate_protective_prices( + symbol=symbol, + side=normalized_side, + entry=entry, + stop_loss=stop_loss, + take_profit=take_profit, + min_distance=min_distance, + ) + return { "entry": entry, "stop_loss": stop_loss, @@ -632,7 +804,7 @@ def place_market_order( tp: float | None = None, position: int | None = None, dry_run: bool = False, -) -> dict[str, object]: +) -> OrderExecutionResult: """Place one normalized market order or return a dry-run result. ``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no @@ -650,6 +822,8 @@ def place_market_order( msg = "volume must be positive." raise Mt5TradingError(msg) side = _normalize_order_side(order_side) + if not dry_run: + ensure_symbol_selected(client, symbol) tick = get_tick_snapshot(client, symbol) price = tick["ask"] if side == "BUY" else tick["bid"] if not isinstance(price, int | float) or price <= 0: @@ -690,21 +864,22 @@ def place_market_order( "volume": volume, "retcode": None, "comment": None, - "request": request, + "request": cast("dict[str, object]", request), "response": None, "dry_run": True, } response = client.order_send(request) response_dict = _snapshot_from_value(response, ()) - retcode = response_dict.get("retcode") + raw_retcode = response_dict.get("retcode") + retcode = _optional_int(raw_retcode) return { - "status": _order_status_from_retcode(client.mt5, retcode), + "status": _order_status_from_retcode(client.mt5, raw_retcode), "symbol": symbol, "order_side": side, "volume": volume, "retcode": retcode, - "comment": response_dict.get("comment"), - "request": request, + "comment": _optional_str(response_dict.get("comment")), + "request": cast("dict[str, object]", request), "response": response_dict, "dry_run": False, } @@ -731,7 +906,7 @@ def close_open_positions( symbols: str | list[str] | None = None, tickets: list[int] | None = None, dry_run: bool = False, -) -> list[dict[str, object]]: +) -> list[OrderExecutionResult]: """Close matching open positions. Returns: @@ -742,7 +917,7 @@ def close_open_positions( symbols=symbols, tickets=tickets, ) - results: list[dict[str, object]] = [] + results: list[OrderExecutionResult] = [] for row in positions.to_dict("records"): pos_type = row["type"] side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY" @@ -766,7 +941,7 @@ def update_sltp_for_open_positions( stop_loss: float | None = None, take_profit: float | None = None, dry_run: bool = False, -) -> list[dict[str, object]]: +) -> list[OrderExecutionResult]: """Update SL/TP for matching open positions. Returns: @@ -777,7 +952,7 @@ def update_sltp_for_open_positions( symbols=symbol, tickets=tickets, ) - results: list[dict[str, object]] = [] + results: list[OrderExecutionResult] = [] for row in positions.to_dict("records"): request = { "action": client.mt5.TRADE_ACTION_SLTP, @@ -792,21 +967,29 @@ def update_sltp_for_open_positions( request["tp"] = tp if dry_run: response = None - status = "dry_run" + status: ExecutionStatus = "dry_run" else: + ensure_symbol_selected(client, str(row["symbol"])) response = _snapshot_from_value(client.order_send(request), ()) - status = "executed" + status = _order_status_from_retcode( + client.mt5, + response.get("retcode"), + ) results.append( { "status": status, - "symbol": row["symbol"], + "symbol": str(row["symbol"]), "order_side": "BUY" if row["type"] == client.mt5.POSITION_TYPE_BUY else "SELL", - "volume": row["volume"], - "retcode": None if response is None else response.get("retcode"), - "comment": None if response is None else response.get("comment"), - "request": request, + "volume": float(row["volume"]), + "retcode": None + if response is None + else _optional_int(response.get("retcode")), + "comment": None + if response is None + else _optional_str(response.get("comment")), + "request": cast("dict[str, object]", request), "response": response, "dry_run": dry_run, }, diff --git a/pyproject.toml b/pyproject.toml index 8862751..2e1581a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.8.0" +version = "0.8.1" description = "Generic MT5 data and execution infrastructure for Python applications" authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 7313b49..282f69c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -2,43 +2,66 @@ from __future__ import annotations +import sqlite3 from datetime import UTC, datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, get_type_hints +from unittest.mock import MagicMock import pandas as pd import pytest from pdmt5 import Mt5RuntimeError, Mt5TradingError from pytest_mock import MockerFixture # noqa: TC002 +import mt5cli from mt5cli import ( DEDUP_KEYS, REQUIRED_COLUMNS, + STABLE_SDK_EXPORTS, TIME_COLUMNS, + AccountSpec, DataKind, Dataset, + ExecutionStatus, + MarginVolume, MT5Client, Mt5CliError, Mt5ConnectionError, Mt5OperationError, Mt5SchemaError, + OrderExecutionResult, + OrderLimits, + RateTarget, build_config, + build_rate_targets, + calculate_margin_and_volume, call_with_normalized_errors, detect_format, + drop_forming_rate_bar, + ensure_symbol_selected, ensure_utc, export_dataframe, export_dataframe_to_sqlite, + fetch_latest_closed_rates, granularity_name, is_recoverable_mt5_error, + load_rate_data, + load_rate_series_from_sqlite, mt5_session, + mt5_trading_session, normalize_dataframe, normalize_mt5_exception, normalize_symbol, normalize_symbols, parse_date_range, + place_market_order, recent_window, + resolve_account_spec, + resolve_account_specs, + resolve_rate_view_name, schema_columns, validate_schema, ) +from mt5cli.history import create_rate_compatibility_views from mt5cli.retry import retry_with_backoff from mt5cli.schemas import ensure_utc_columns, normalize_time_columns @@ -510,3 +533,162 @@ def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None: with __import__("sqlite3").connect(output) as conn: count = conn.execute("SELECT COUNT(*) FROM rates").fetchone()[0] assert count == 1 + + +class TestStableSdkContract: + """Tests for the documented stable downstream SDK contract.""" + + def test_stable_exports_are_subset_of_all(self) -> None: + """Every stable export is also listed in the package __all__.""" + missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__)) + assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}" + + @pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS)) + def test_stable_exports_are_importable_from_package_root(self, name: str) -> None: + """Stable SDK names resolve through ``from mt5cli import ...``.""" + assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root" + + def test_drop_forming_rate_bar_from_package_root(self) -> None: + """Closed-bar trimming is available from the stable package surface.""" + frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]}) + closed = drop_forming_rate_bar(frame) + assert list(closed["close"]) == [1.0, 1.1] + assert len(closed) == 2 + + def test_fetch_latest_closed_rates_from_package_root(self) -> None: + """Single-client closed-bar helper drops the forming row.""" + client = MagicMock() + client.latest_rates.return_value = pd.DataFrame( + {"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]}, + ) + + result = fetch_latest_closed_rates( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0) + assert list(result["close"]) == [1.0, 1.1] + + def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None: + """Rate view resolution is importable and honors require_existing.""" + db_path = tmp_path / "rates.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE rates(" + " symbol TEXT, timeframe INTEGER, time TEXT, close REAL)", + ) + conn.execute( + "INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)", + ("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0), + ) + create_rate_compatibility_views(conn) + + assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1" + missing = tmp_path / "missing.db" + with pytest.raises(ValueError, match="SQLite database not found"): + resolve_rate_view_name(missing, "EURUSD", "M1", require_existing=True) + + def test_load_rate_data_from_package_root(self, tmp_path: Path) -> None: + """SQLite rate loading normalizes timestamps through the stable API.""" + db_path = tmp_path / "view.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + 'CREATE VIEW "rate_EURUSD__1" AS' + " SELECT '2024-01-01T00:00:00+00:00' AS time, 1.1 AS close", + ) + + frame = load_rate_data(db_path, "rate_EURUSD__1") + assert frame.index.name == "time" + assert abs(float(frame.iloc[0]["close"]) - 1.1) < 1e-9 + + def test_load_rate_series_from_sqlite_requires_managed_views( + self, + tmp_path: Path, + ) -> None: + """Multi-series loading fails clearly when managed views are absent.""" + db_path = tmp_path / "empty-views.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE rates(" + " symbol TEXT, timeframe INTEGER, time TEXT, close REAL)", + ) + + targets = build_rate_targets(["EURUSD"], ["M1"]) + with pytest.raises(ValueError, match="No rate compatibility view exists"): + load_rate_series_from_sqlite(db_path, targets, count=10) + + assert targets == [RateTarget(symbol="EURUSD", timeframe=1)] + + def test_resolve_account_spec_from_package_root( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Account credential resolution uses generic ${ENV_VAR} placeholders.""" + monkeypatch.setenv("APP_MT5_LOGIN", "555") + monkeypatch.setenv("APP_MT5_PASSWORD", "secret") + account = AccountSpec( + symbols=["EURUSD"], + login="${APP_MT5_LOGIN}", + password="${APP_MT5_PASSWORD}", + server="Broker-Demo", + ) + + resolved = resolve_account_spec(account, timeout=3000) + assert resolved.login == "555" + assert resolved.password == "secret" # noqa: S105 + assert resolved.timeout == 3000 + + batch = resolve_account_specs([account], server="Override") + assert batch[0].server == "Override" + + def test_mt5_trading_session_lifecycle_from_package_root( + self, + mocker: MockerFixture, + ) -> None: + """Trading session helper initializes and always shuts down.""" + mock_client = MagicMock() + mocker.patch( + "mt5cli.trading.Mt5TradingClient", + return_value=mock_client, + ) + + with mt5_trading_session(login=12345, server="Broker-Demo") as client: + assert client is mock_client + mock_client.initialize_and_login_mt5.assert_called_once() + + mock_client.shutdown.assert_called_once() + + def test_trading_order_helpers_importable_from_package_root(self) -> None: + """Order planning helpers resolve through the stable package surface.""" + assert callable(calculate_margin_and_volume) + assert callable(ensure_symbol_selected) + assert callable(place_market_order) + margin_hints = get_type_hints(MarginVolume) + limits_hints = get_type_hints(OrderLimits) + execution_hints = get_type_hints(OrderExecutionResult) + assert margin_hints["buy_volume"] is float + assert limits_hints["stop_loss"] == float | None + assert execution_hints["status"] == ExecutionStatus + + def test_mt5_trading_session_shuts_down_on_exception( + self, + mocker: MockerFixture, + ) -> None: + """Trading session helper shuts down even when the body raises.""" + mock_client = MagicMock() + mocker.patch( + "mt5cli.trading.Mt5TradingClient", + return_value=mock_client, + ) + + message = "strategy error" + with ( + pytest.raises(RuntimeError, match=message), + mt5_trading_session(login=12345, server="Broker-Demo"), + ): + raise RuntimeError(message) + + mock_client.shutdown.assert_called_once() diff --git a/tests/test_trading.py b/tests/test_trading.py index ea4c7d1..06d47dc 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -8,11 +8,15 @@ from unittest.mock import MagicMock import pandas as pd import pytest +from numpy import int64 as np_int64 from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pytest_mock import MockerFixture # noqa: TC002 from mt5cli.sdk import build_config from mt5cli.trading import ( + MarginVolume, + OrderExecutionResult, + OrderLimits, calculate_margin_and_volume, calculate_new_position_margin_ratio, calculate_spread_ratio, @@ -21,6 +25,7 @@ from mt5cli.trading import ( create_trading_client, detect_position_side, determine_order_limits, + ensure_symbol_selected, get_account_snapshot, get_positions_frame, get_symbol_snapshot, @@ -51,8 +56,8 @@ def _assert_close(actual: object, expected: float) -> None: assert abs(float(cast("float", actual)) - expected) < 1e-9 -def _request_from_result(result: dict[str, object]) -> dict[str, object]: - return cast("dict[str, object]", result["request"]) +def _request_from_result(result: OrderExecutionResult) -> dict[str, object]: # noqa: FURB118 + return result["request"] class TestDetectPositionSide: @@ -249,7 +254,7 @@ class TestDetermineOrderLimits: """Test long stop loss and take profit are placed below/above entry.""" client = MagicMock() client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} - client.symbol_info_as_dict.side_effect = AttributeError("missing") + client.symbol_info_as_dict.return_value = {} result = determine_order_limits( client, @@ -269,7 +274,7 @@ class TestDetermineOrderLimits: """Test short stop loss and take profit are placed above/below entry.""" client = MagicMock() client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} - client.symbol_info_as_dict.side_effect = AttributeError("missing") + client.symbol_info_as_dict.return_value = {} result = determine_order_limits( client, @@ -348,6 +353,22 @@ class TestDetermineOrderLimits: """Test order limit rounding falls back when symbol metadata is missing.""" client = MagicMock() client.symbol_info_tick_as_dict.return_value = {"ask": 1.234567891, "bid": 1.0} + client.symbol_info_as_dict.return_value = {"digits": "invalid"} + + result = determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.01, + take_profit_limit_ratio=0.01, + ) + + _assert_close(result["stop_loss"], 1.22222221) + + def test_uses_default_digits_when_symbol_lookup_raises(self) -> None: + """Test order limits fall back when symbol metadata lookup fails.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.234567891, "bid": 1.0} client.symbol_info_as_dict.side_effect = AttributeError("missing") result = determine_order_limits( @@ -385,6 +406,204 @@ class TestDetermineOrderLimits: with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): determine_order_limits(client, "EURUSD", "long") + def test_rejects_stop_loss_inside_broker_stop_level(self) -> None: + """Test stop-loss prices closer than trade_stops_level raise Mt5TradingError.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 100, + "point": 0.0001, + } + + with pytest.raises(Mt5TradingError, match="Stop loss for 'EURUSD'"): + determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.0001, + ) + + def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None: + """Test protective levels exactly at trade_stops_level distance pass.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 100, + "point": 0.0001, + } + + result = determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.01, + take_profit_limit_ratio=0.0, + ) + + _assert_close(result["stop_loss"], 0.99) + + def test_allows_protective_levels_beyond_broker_stop_level(self) -> None: + """Test SL/TP beyond trade_stops_level pass validation.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 10, + "point": 0.0001, + } + + result = determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.05, + take_profit_limit_ratio=0.05, + ) + + _assert_close(result["stop_loss"], 0.95) + _assert_close(result["take_profit"], 1.05) + + def test_rejects_take_profit_inside_broker_stop_level(self) -> None: + """Test long take-profit inside trade_stops_level raises Mt5TradingError.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 100, + "point": 0.0001, + } + + with pytest.raises(Mt5TradingError, match="Take profit for 'EURUSD'"): + determine_order_limits( + client, + "EURUSD", + "long", + take_profit_limit_ratio=0.0001, + ) + + def test_rejects_short_stop_loss_inside_broker_stop_level(self) -> None: + """Test short stop-loss inside trade_stops_level raises Mt5TradingError.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 100, + "point": 0.0001, + } + + with pytest.raises(Mt5TradingError, match="Stop loss for 'EURUSD'"): + determine_order_limits( + client, + "EURUSD", + "short", + stop_loss_limit_ratio=0.0001, + ) + + def test_rejects_short_take_profit_inside_broker_stop_level(self) -> None: + """Test short take-profit inside trade_stops_level raises Mt5TradingError.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 100, + "point": 0.0001, + } + + with pytest.raises(Mt5TradingError, match="Take profit for 'EURUSD'"): + determine_order_limits( + client, + "EURUSD", + "short", + take_profit_limit_ratio=0.0001, + ) + + def test_allows_short_protective_levels_beyond_broker_stop_level(self) -> None: + """Test short SL/TP beyond trade_stops_level pass validation.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 10, + "point": 0.0001, + } + + result = determine_order_limits( + client, + "EURUSD", + "short", + stop_loss_limit_ratio=0.05, + take_profit_limit_ratio=0.05, + ) + + _assert_close(result["stop_loss"], 1.05) + _assert_close(result["take_profit"], 0.95) + + def test_ignores_non_positive_broker_stop_level(self) -> None: + """Test zero trade_stops_level skips stop-distance validation.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99} + client.symbol_info_as_dict.return_value = { + "digits": 2, + "trade_stops_level": 0, + "point": 0.0001, + } + + result = determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.0001, + take_profit_limit_ratio=0.0001, + ) + + assert result["stop_loss"] is not None + assert result["take_profit"] is not None + + """Tests for ensure_symbol_selected.""" + + def test_skips_selection_when_symbol_is_visible(self) -> None: + """Test visible symbols do not call symbol_select.""" + client = MagicMock() + client.symbol_info_as_dict.return_value = {"visible": True} + + ensure_symbol_selected(client, "EURUSD") + + client.symbol_select.assert_not_called() + + def test_selects_hidden_symbol_before_trading(self) -> None: + """Test hidden symbols are selected in Market Watch.""" + client = MagicMock() + client.symbol_info_as_dict.return_value = {"visible": False} + client.symbol_select.return_value = True + + ensure_symbol_selected(client, "EURUSD") + + client.symbol_select.assert_called_once_with("EURUSD", enable=True) + + def test_raises_when_symbol_selection_fails(self) -> None: + """Test failed symbol selection raises Mt5TradingError.""" + client = MagicMock() + client.symbol_info_as_dict.return_value = {"visible": False} + client.symbol_select.return_value = False + client.last_error.return_value = (1, "not found") + + with pytest.raises(Mt5TradingError, match="Failed to select symbol 'EURUSD'"): + ensure_symbol_selected(client, "EURUSD") + + def test_raises_when_symbol_select_is_unavailable(self) -> None: + """Test missing symbol_select raises Mt5TradingError.""" + client = MagicMock() + client.symbol_info_as_dict.return_value = {"visible": False} + del client.symbol_select + + with pytest.raises( + Mt5TradingError, + match="missing required method: symbol_select", + ): + ensure_symbol_selected(client, "EURUSD") + class TestMt5TradingSession: """Tests for the mt5_trading_session context manager.""" @@ -625,6 +844,23 @@ class TestVolumeAndExecution: 0.0, ) + def test_calculate_volume_by_margin_never_returns_nonzero_below_volume_min( + self, + ) -> None: + """Test non-zero affordable volume is never below volume_min.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 1.0, + "volume_step": 0.1, + } + client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} + client.order_calc_margin.return_value = 10.0 + + volume = calculate_volume_by_margin(client, "EURUSD", 35.0, "BUY") + + assert abs(volume) < 1e-9 or volume >= 0.1 + def test_calculate_volume_by_margin_returns_zero_without_margin(self) -> None: """Test non-positive available margin returns zero before MT5 calls.""" client = _mock_trade_client() @@ -943,6 +1179,7 @@ class TestVolumeAndExecution: def test_place_market_order_dry_run_does_not_send(self) -> None: """Test dry-run market orders return a request without sending.""" client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": False} client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} result = place_market_order( @@ -956,6 +1193,7 @@ class TestVolumeAndExecution: assert result["status"] == "dry_run" assert _request_from_result(result)["type"] == client.mt5.ORDER_TYPE_BUY client.order_send.assert_not_called() + client.symbol_select.assert_not_called() def test_place_market_order_supports_limits(self) -> None: """Test optional SL/TP values are included in the request.""" @@ -1093,6 +1331,173 @@ class TestVolumeAndExecution: assert result["status"] == "failed" assert result["retcode"] == 10013 + def test_place_market_order_marks_failed_numpy_retcode(self) -> None: + """Test numpy integer retcodes normalize to failed status.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": np_int64(10013), "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["status"] == "failed" + assert result["retcode"] == 10013 + + def test_place_market_order_rejects_bool_retcode(self) -> None: + """Test bool retcodes are not treated as integer broker codes.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": True, "comment": "weird"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] is None + assert result["status"] == "failed" + + def test_place_market_order_marks_failed_string_retcode(self) -> None: + """Test digit-string failure retcodes normalize to failed status.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": "10013", "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] == 10013 + assert result["status"] == "failed" + + def test_place_market_order_marks_failed_whitespace_string_retcode(self) -> None: + """Test whitespace-padded digit-string retcodes normalize to failed status.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": " 10013 ", "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] == 10013 + assert result["status"] == "failed" + + @pytest.mark.parametrize("retcode", ["+10013", "-10013"]) + def test_place_market_order_marks_signed_string_retcode_as_failed( + self, + retcode: str, + ) -> None: + """Test signed digit-string failure retcodes normalize to failed status.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": retcode, "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + expected = 10013 if retcode.startswith("+") else -10013 + assert result["retcode"] == expected + assert result["status"] == "failed" + + def test_place_market_order_marks_missing_retcode_as_failed(self) -> None: + """Test live responses without retcode are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"comment": "missing retcode"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] is None + assert result["status"] == "failed" + + def test_place_market_order_marks_malformed_retcode_as_failed(self) -> None: + """Test malformed non-None retcodes are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": "invalid", "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] is None + assert result["status"] == "failed" + + def test_place_market_order_marks_empty_string_retcode_as_failed(self) -> None: + """Test empty string retcodes are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": " ", "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] is None + assert result["status"] == "failed" + + def test_place_market_order_marks_object_retcode_as_failed(self) -> None: + """Test unsupported retcode object types are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": object(), "comment": "invalid request"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["retcode"] is None + assert result["status"] == "failed" + def test_close_open_positions_filters_and_dry_runs(self) -> None: """Test close helper filters positions and builds opposite orders.""" client = _mock_trade_client() @@ -1142,6 +1547,7 @@ class TestVolumeAndExecution: def test_update_sltp_filters_and_dry_runs(self) -> None: """Test SL/TP updates filter positions and do not send in dry-run mode.""" client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": False} client.positions_get_as_df.return_value = pd.DataFrame( [ { @@ -1174,6 +1580,34 @@ class TestVolumeAndExecution: assert len(result) == 1 _assert_close(_request_from_result(result[0])["sl"], 1.1) _assert_close(_request_from_result(result[0])["tp"], 1.3) + client.order_send.assert_not_called() + client.symbol_select.assert_not_called() + + def test_update_sltp_selects_hidden_symbol_for_live_send(self) -> None: + """Test live SL/TP updates ensure hidden symbols are selected first.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": False} + client.symbol_select.return_value = True + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10009, "comment": "updated"}], + ) + + update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + client.symbol_select.assert_called_once_with("EURUSD", enable=True) + client.order_send.assert_called_once() def test_update_sltp_sends_and_normalizes_response(self) -> None: """Test live SL/TP updates send requests and normalize responses.""" @@ -1258,6 +1692,232 @@ class TestVolumeAndExecution: mock_client.shutdown.assert_called_once() + def test_place_market_order_selects_hidden_symbol_for_live_send(self) -> None: + """Test live market orders select hidden symbols before reading ticks.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": False} + client.symbol_select.return_value = True + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10009, "comment": "done"}], + ) + call_order: list[str] = [] + + def _record_select(*_args: object, **_kwargs: object) -> bool: + call_order.append("symbol_select") + return True + + def _record_tick(*_args: object, **_kwargs: object) -> dict[str, float]: + call_order.append("tick") + return {"ask": 1.2, "bid": 1.1} + + client.symbol_select.side_effect = _record_select + client.symbol_info_tick_as_dict.side_effect = _record_tick + + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + client.symbol_select.assert_called_once_with("EURUSD", enable=True) + client.symbol_info_tick_as_dict.assert_called_once() + client.order_send.assert_called_once() + assert call_order == ["symbol_select", "tick"] + + def test_place_market_order_reads_ticks_after_hidden_symbol_selection(self) -> None: + """Test live orders can read ticks only after hidden symbols are selected.""" + client = _mock_trade_client() + selected = {"value": False} + + def _symbol_info_side_effect(**_kwargs: object) -> dict[str, bool]: + return {"visible": selected["value"]} + + def _select_symbol(*_args: object, **_kwargs: object) -> bool: + selected["value"] = True + return True + + def _tick_side_effect(**_kwargs: object) -> dict[str, float | None]: + if not selected["value"]: + return {"ask": None, "bid": None} + return {"ask": 1.2, "bid": 1.1} + + client.symbol_info_as_dict.side_effect = _symbol_info_side_effect + client.symbol_select.side_effect = _select_symbol + client.symbol_info_tick_as_dict.side_effect = _tick_side_effect + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10009, "comment": "done"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + assert result["status"] == "executed" + client.symbol_select.assert_called_once_with("EURUSD", enable=True) + client.order_send.assert_called_once() + + def test_update_sltp_marks_failed_retcode(self) -> None: + """Test SL/TP updates normalize failed broker retcodes.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": True} + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10013, "comment": "invalid stops"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + assert result[0]["status"] == "failed" + assert result[0]["retcode"] == 10013 + + def test_update_sltp_marks_failed_numpy_retcode(self) -> None: + """Test numpy integer retcodes normalize to failed SL/TP status.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": True} + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": np_int64(10013), "comment": "invalid stops"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + assert result[0]["status"] == "failed" + assert result[0]["retcode"] == 10013 + + def test_update_sltp_marks_failed_string_retcode(self) -> None: + """Test digit-string failure retcodes normalize to failed SL/TP status.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": True} + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": "10013", "comment": "invalid stops"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + assert result[0]["retcode"] == 10013 + assert result[0]["status"] == "failed" + + def test_update_sltp_marks_malformed_retcode_as_failed(self) -> None: + """Test malformed non-None SL/TP retcodes are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": True} + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": "invalid", "comment": "invalid stops"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + assert result[0]["retcode"] is None + assert result[0]["status"] == "failed" + + def test_update_sltp_marks_missing_retcode_as_failed(self) -> None: + """Test live SL/TP responses without retcode are fail-closed.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = {"visible": True} + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"comment": "missing retcode"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1) + + assert result[0]["retcode"] is None + assert result[0]["status"] == "failed" + + def test_trading_typed_dict_exports(self) -> None: + """Test order-planning TypedDict contracts are importable.""" + margin: MarginVolume = { + "margin_free": 1.0, + "available_margin": 1.0, + "trade_margin": 0.5, + "buy_volume": 0.1, + "sell_volume": 0.1, + "volume_min": 0.1, + "volume_max": 1.0, + "volume_step": 0.1, + } + limits: OrderLimits = { + "entry": 1.0, + "stop_loss": 0.9, + "take_profit": 1.1, + } + execution: OrderExecutionResult = { + "status": "dry_run", + "symbol": "EURUSD", + "order_side": "BUY", + "volume": 0.1, + "retcode": None, + "comment": None, + "request": {"action": 20}, + "response": None, + "dry_run": True, + } + _assert_close(margin["buy_volume"], 0.1) + _assert_close(limits["entry"], 1.0) + assert execution["status"] == "dry_run" + def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None: """Test shutdown is called when the context body raises.""" mock_client = MagicMock() diff --git a/uv.lock b/uv.lock index 48ed61c..af48da2 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.8.0" +version = "0.8.1" source = { editable = "." } dependencies = [ { name = "click" },