diff --git a/README.md b/README.md index 0942232..3052204 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pip install -U mt5cli MetaTrader5 ## Python API (downstream packages) -Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. +Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. ```python from datetime import UTC, datetime @@ -250,7 +250,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. - **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. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged. +- **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`. - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate. - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit. - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns. @@ -317,7 +317,7 @@ finally: client.shutdown() ``` -Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes. +Read-only collectors can keep using `mt5_session()` and `MT5Client`. ## Development diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index 15bfa04..3b6777a 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -8,20 +8,29 @@ 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. +...`) and use the public tier sets in `mt5cli.contract` to distinguish API +stability. CLI commands mirror the same behavior but are not importable Python +APIs. + +## Public API tiers + +mt5cli classifies package-root imports by intended downstream use: + +| Tier | Contract set | Meaning | +| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. | +| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. | ## 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. +`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). ### Session lifecycle and configuration | Symbol | Role | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `MT5Client`, `Mt5CliClient` | 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 | | `mt5_session` | Context manager: initialize, login, yield client, shutdown | | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle | @@ -40,23 +49,6 @@ Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are **never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies. Default is `False` to preserve backward compatibility. -### 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 @@ -71,7 +63,6 @@ timestamp normalization in downstream apps. | `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) | | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper | | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` | -| `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 @@ -99,20 +90,24 @@ diagrams. 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 | -| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals | -| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) | -| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) | -| `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 | +| Symbol | Role | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views | +| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings | +| `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 | +| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals | +| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) | +| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) | +| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure | +| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure | +| `determine_order_limits` | SL/TP price levels from ratios | +| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan | +| `ensure_symbol_selected` | Select/verify Market Watch visibility | +| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_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. @@ -135,13 +130,43 @@ and returned as `status="failed"` with normalized `request` / `response` details | `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) +## Secondary public exports -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. +These names remain importable from `mt5cli` and are covered by +`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema +integrations, parsing, and lower-level MT5 access rather than the stable core +SDK surface. Prefer the stable symbols above for downstream infrastructure +adapters. + +### Read-only MT5 data wrappers + +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` | +| Multi-account rates | `collect_latest_rates_for_accounts` | + +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. + +### Schema, export, and parser helpers + +| Area | Symbols | +| -------------------- | ------------------------------------------------------------------------------------------------------------- | +| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` | +| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` | +| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` | +| Symbol parsing | `normalize_symbol`, `normalize_symbols` | +| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` | +| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` | +| Trading data shapes | `POSITION_COLUMNS` | ## CLI commands @@ -190,7 +215,7 @@ 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__`. +`tests/test_contracts.py` asserts that every name in the stable and secondary +tier sets is importable from `mt5cli`, documents key closed-bar, rate-view, +SQLite loading, account-resolution, and trading-session behaviors, and keeps the +tier sets aligned with `__all__`. diff --git a/docs/api/sdk.md b/docs/api/sdk.md index e4d8b63..9d975ef 100644 --- a/docs/api/sdk.md +++ b/docs/api/sdk.md @@ -31,7 +31,7 @@ rates = collect_latest_rates_for_accounts_with_retries( ### Latest closed rate bars MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last -row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use +row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use `fetch_latest_closed_rates_for_trading_client()` from an active `Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered @@ -170,5 +170,5 @@ resulting `ValueError` is suppressed along with other recoverable errors. ## Trading-capable sessions For order placement and trading calculations, use the dedicated -[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()` -helpers in this module are unchanged. +[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only +collection. diff --git a/docs/api/trading.md b/docs/api/trading.md index a2ff4d2..c4b8805 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -31,7 +31,7 @@ finally: `login` accepts `int`, numeric `str`, or an empty string; empty strings are treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to `pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default. -The read-only `Mt5CliClient` / `mt5_session()` API is unchanged. +Use `mt5_session()` / `MT5Client` for read-only data collection. ## State and order helpers @@ -194,6 +194,6 @@ through the stable package root without embedding entry/exit policy. | Local SL/TP price derivation | `determine_order_limits()` | | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` | -Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use +Keep read-only data collection on `mt5_session()` / `MT5Client`; use `mt5_trading_session()` only where order placement or trading calculations are required. diff --git a/docs/index.md b/docs/index.md index 9fb9bd4..457e2cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ pip install mt5cli ## Python API for downstream packages -Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. +Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. ```python from datetime import UTC, datetime diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index ac6c2b2..5cb9289 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -11,7 +11,11 @@ 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 .contract import ( + PUBLIC_EXPORT_TIERS, + SECONDARY_PUBLIC_EXPORTS, + STABLE_SDK_EXPORTS, +) from .converters import ( ensure_utc, granularity_name, @@ -59,7 +63,6 @@ from .schemas import ( ) from .sdk import ( AccountSpec, - Mt5CliClient, ThrottledHistoryUpdater, account_info, collect_history, @@ -121,7 +124,10 @@ from .trading import ( calculate_positions_margin, calculate_positions_margin_by_symbol, calculate_positions_margin_safe, + calculate_projected_margin_ratio, calculate_spread_ratio, + calculate_symbol_group_margin_ratio, + calculate_trailing_stop_updates, calculate_volume_by_margin, close_open_positions, create_trading_client, @@ -129,6 +135,7 @@ from .trading import ( determine_order_limits, ensure_symbol_selected, estimate_order_margin, + extract_tick_price, fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_indexed, get_account_snapshot, @@ -139,6 +146,7 @@ from .trading import ( normalize_order_volume, place_market_order, update_sltp_for_open_positions, + update_trailing_stop_loss_for_open_positions, ) from .utils import ( TICK_FLAG_MAP, @@ -154,7 +162,9 @@ __all__ = [ "DEDUP_KEYS", "KNOWN_MT5_TIME_COLUMNS", "POSITION_COLUMNS", + "PUBLIC_EXPORT_TIERS", "REQUIRED_COLUMNS", + "SECONDARY_PUBLIC_EXPORTS", "STABLE_SDK_EXPORTS", "TICK_FLAG_MAP", "TIMEFRAME_MAP", @@ -166,7 +176,6 @@ __all__ = [ "IfExists", "MT5Client", "MarginVolume", - "Mt5CliClient", "Mt5CliError", "Mt5Config", "Mt5ConnectionError", @@ -192,7 +201,10 @@ __all__ = [ "calculate_positions_margin", "calculate_positions_margin_by_symbol", "calculate_positions_margin_safe", + "calculate_projected_margin_ratio", "calculate_spread_ratio", + "calculate_symbol_group_margin_ratio", + "calculate_trailing_stop_updates", "calculate_volume_by_margin", "call_with_normalized_errors", "close_open_positions", @@ -217,6 +229,7 @@ __all__ = [ "estimate_order_margin", "export_dataframe", "export_dataframe_to_sqlite", + "extract_tick_price", "fetch_latest_closed_rates", "fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_indexed", @@ -275,5 +288,6 @@ __all__ = [ "update_history", "update_history_with_config", "update_sltp_for_open_positions", + "update_trailing_stop_loss_for_open_positions", "validate_schema", ] diff --git a/mt5cli/client.py b/mt5cli/client.py index 958c839..0a4921a 100644 --- a/mt5cli/client.py +++ b/mt5cli/client.py @@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient): """Public client for generic MT5 data access and order primitives. Extends the read-only SDK client with optional order check/send helpers and - exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`. - Downstream applications such as private trading packages should prefer this - type over the legacy ``Mt5CliClient`` name. + exposes the same connection lifecycle as :func:`mt5_session`. mt5cli intentionally exposes minimal execution primitives only. Trading decisions, signals, strategies, backtests, and optimization remain the diff --git a/mt5cli/contract.py b/mt5cli/contract.py index 8f094f5..eec3c2b 100644 --- a/mt5cli/contract.py +++ b/mt5cli/contract.py @@ -1,11 +1,10 @@ -"""Stable downstream SDK export names for mt5cli.""" +"""Downstream SDK export tiers for mt5cli.""" from __future__ import annotations STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "AccountSpec", "MT5Client", - "Mt5CliClient", "Mt5CliError", "Mt5Config", "Mt5ConnectionError", @@ -24,38 +23,32 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "OrderLimits", "RateTarget", "ThrottledHistoryUpdater", - "account_info", "build_config", "build_rate_targets", "build_rate_view_name", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", + "calculate_projected_margin_ratio", "calculate_positions_margin", "calculate_positions_margin_by_symbol", "calculate_positions_margin_safe", "calculate_spread_ratio", + "calculate_symbol_group_margin_ratio", + "calculate_trailing_stop_updates", "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", "estimate_order_margin", - "export_dataframe", - "export_dataframe_to_sqlite", + "extract_tick_price", "fetch_latest_closed_rates", "fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_indexed", @@ -63,29 +56,16 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "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", "normalize_order_volume", - "orders", "place_market_order", - "positions", - "recent_history_deals", - "recent_ticks", "resolve_account_spec", "resolve_account_specs", "resolve_history_datasets", @@ -96,13 +76,73 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "resolve_rate_view_name", "resolve_rate_view_names", "substitute_env_placeholders", + "update_history", + "update_history_with_config", + "update_sltp_for_open_positions", + "update_trailing_stop_loss_for_open_positions", +}) + +SECONDARY_PUBLIC_EXPORTS: frozenset[str] = frozenset({ + "DEDUP_KEYS", + "DataKind", + "Dataset", + "IfExists", + "KNOWN_MT5_TIME_COLUMNS", + "POSITION_COLUMNS", + "REQUIRED_COLUMNS", + "TICK_FLAG_MAP", + "TIMEFRAME_MAP", + "TIME_COLUMNS", + "account_info", + "collect_latest_rates", + "collect_latest_rates_for_accounts", + "copy_rates_from", + "copy_rates_from_pos", + "copy_rates_range", + "copy_ticks_from", + "copy_ticks_range", + "detect_format", + "ensure_utc", + "export_dataframe", + "export_dataframe_to_sqlite", + "granularity_name", + "history_deals", + "history_orders", + "last_error", + "latest_rates", + "market_book", + "minimum_margins", + "mt5_summary", + "mt5_summary_as_df", + "mt5_version", + "normalize_dataframe", + "normalize_symbol", + "normalize_symbols", + "normalize_time_columns", + "orders", + "parse_date_range", + "parse_datetime", + "parse_tick_flags", + "parse_timeframe", + "positions", + "recent_history_deals", + "recent_ticks", + "recent_window", + "schema_columns", "symbol_info", "symbol_info_tick", "symbols", "terminal_info", - "update_history", - "update_history_with_config", - "update_sltp_for_open_positions", + "validate_schema", }) -__all__ = ["STABLE_SDK_EXPORTS"] +PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = { + "stable": STABLE_SDK_EXPORTS, + "secondary": SECONDARY_PUBLIC_EXPORTS, +} + +__all__ = [ + "PUBLIC_EXPORT_TIERS", + "SECONDARY_PUBLIC_EXPORTS", + "STABLE_SDK_EXPORTS", +] diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 974f4eb..35356eb 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -14,7 +14,6 @@ from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from .history import drop_forming_rate_bar from .sdk import build_config from .utils import coerce_login as _coerce_login -from .utils import parse_timeframe if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence @@ -133,7 +132,10 @@ __all__ = [ "calculate_positions_margin", "calculate_positions_margin_by_symbol", "calculate_positions_margin_safe", + "calculate_projected_margin_ratio", "calculate_spread_ratio", + "calculate_symbol_group_margin_ratio", + "calculate_trailing_stop_updates", "calculate_volume_by_margin", "close_open_positions", "create_trading_client", @@ -141,6 +143,7 @@ __all__ = [ "determine_order_limits", "ensure_symbol_selected", "estimate_order_margin", + "extract_tick_price", "fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_indexed", "get_account_snapshot", @@ -151,6 +154,7 @@ __all__ = [ "normalize_order_volume", "place_market_order", "update_sltp_for_open_positions", + "update_trailing_stop_loss_for_open_positions", ] @@ -428,7 +432,7 @@ def _optional_price(value: object) -> float | None: return price -def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None: +def extract_tick_price(tick: Mapping[str, object], key: str) -> float | None: """Return a positive finite float from tick[key], or None if invalid. Accepts int, float, or numeric string values. Returns None when the key is @@ -490,7 +494,7 @@ def _calculate_min_volume_if_affordable( msg = f"Invalid volume constraints for {symbol!r}." raise Mt5TradingError(msg) side = _normalize_order_side(order_side) - price = _valid_tick_price( + price = extract_tick_price( get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" ) if price is None: @@ -651,7 +655,7 @@ def estimate_order_margin( raise Mt5TradingError(msg) side = _normalize_order_side(order_side) tick = get_tick_snapshot(client, symbol) - price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid") + price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") if price is None: msg = f"Tick price is unavailable for {symbol!r}." raise Mt5TradingError(msg) @@ -785,8 +789,8 @@ def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float: Mt5TradingError: If bid or ask is unavailable. """ tick = get_tick_snapshot(client, symbol) - bid = _valid_tick_price(tick, "bid") - ask = _valid_tick_price(tick, "ask") + bid = extract_tick_price(tick, "bid") + ask = extract_tick_price(tick, "ask") if bid is None or ask is None: msg = f"Tick bid/ask is unavailable for {symbol!r}." raise Mt5TradingError(msg) @@ -813,7 +817,7 @@ def calculate_new_position_margin_ratio( margin = float(account.get("margin") or 0.0) if new_position_side is not None and new_position_volume > 0: side = _normalize_order_side(new_position_side) - price = _valid_tick_price( + price = extract_tick_price( get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" ) if price is None: @@ -828,6 +832,102 @@ def calculate_new_position_margin_ratio( return margin / equity +def _account_equity(client: Mt5TradingClient) -> float: + account = get_account_snapshot(client) + try: + equity = float(account.get("equity") or 0.0) + except (TypeError, ValueError) as exc: + msg = "Account equity must be positive to calculate margin ratio." + raise Mt5TradingError(msg) from exc + if equity <= 0 or not isfinite(equity): + msg = "Account equity must be positive to calculate margin ratio." + raise Mt5TradingError(msg) + return equity + + +def calculate_projected_margin_ratio( + client: Mt5TradingClient, + *, + symbol: str, + new_position_side: OrderSide | None = None, + new_position_volume: float = 0.0, +) -> float: + """Return estimated current plus optional new-position margin over equity. + + Current exposure is estimated from open positions with + :func:`calculate_positions_margin`. Optional projected exposure is added via + :func:`estimate_order_margin`. Thresholds and guard actions are intentionally + left to downstream applications. + + Account equity, position margin, and optional projected margin errors from + the composed MT5 helpers propagate to the caller. + """ + equity = _account_equity(client) + margin = calculate_positions_margin(client, symbols=[symbol]) + if new_position_side is not None and new_position_volume > 0: + margin += estimate_order_margin( + client, + symbol, + new_position_side, + new_position_volume, + ) + return margin / equity + + +def calculate_symbol_group_margin_ratio( + client: Mt5TradingClient, + *, + symbols: Sequence[str], + new_symbol: str | None = None, + new_position_side: OrderSide | None = None, + new_position_volume: float = 0.0, + suppress_errors: bool = True, +) -> float: + """Return estimated symbol-group margin over account equity. + + Per-symbol current exposure is summed with + :func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside + the input symbol group, optional projected order margin is added for that + symbol. Invalid equity always raises to fail closed. + + Raises: + AttributeError: When symbol margin lookup or projected margin lookup + fails and ``suppress_errors`` is ``False``. + Mt5RuntimeError: When symbol margin lookup or projected margin lookup + fails and ``suppress_errors`` is ``False``. + Mt5TradingError: When account equity is invalid, or when symbol margin + lookup or projected margin lookup fails and ``suppress_errors`` is + ``False``. + """ + equity = _account_equity(client) + unique_symbols = list(dict.fromkeys(symbols)) + margin = sum( + calculate_positions_margin_by_symbol( + client, + symbols=unique_symbols, + suppress_errors=suppress_errors, + ).values(), + 0.0, + ) + if ( + new_symbol in unique_symbols + and new_position_side is not None + and new_position_volume > 0 + ): + try: + margin += estimate_order_margin( + client, + new_symbol, + new_position_side, + new_position_volume, + ) + except (Mt5TradingError, Mt5RuntimeError, AttributeError): + if not suppress_errors: + raise + _logger.warning("Skipping projected margin for %r.", new_symbol) + return margin / equity + + def calculate_margin_and_volume( client: Mt5TradingClient, symbol: str, @@ -921,7 +1021,7 @@ def calculate_volume_by_margin( msg = f"Invalid volume constraints for {symbol!r}." raise Mt5TradingError(msg) side = _normalize_order_side(order_side) - price = _valid_tick_price( + price = extract_tick_price( get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" ) if price is None: @@ -1001,7 +1101,7 @@ def determine_order_limits( normalized_side = _position_side_from_order_side(side) tick = get_tick_snapshot(client, symbol) entry_key = "ask" if normalized_side == "long" else "bid" - entry = _valid_tick_price(tick, entry_key) + entry = extract_tick_price(tick, entry_key) if entry is None: msg = f"Tick price is unavailable for {symbol!r}." raise Mt5TradingError(msg) @@ -1080,7 +1180,7 @@ def place_market_order( if not dry_run: ensure_symbol_selected(client, symbol) tick = get_tick_snapshot(client, symbol) - price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid") + price = extract_tick_price(tick, "ask" if side == "BUY" else "bid") if price is None: msg = f"Tick price is unavailable for {symbol!r}." raise Mt5TradingError(msg) @@ -1188,6 +1288,125 @@ def close_open_positions( return results +def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None: + try: + raw_digits = get_symbol_snapshot(client, symbol).get("digits") + if raw_digits is None: + return None + digits = int(raw_digits) + except (AttributeError, TypeError, ValueError): + return None + return digits if digits >= 0 else None + + +def _position_ticket(value: object) -> int | None: + ticket = _optional_int(value) + return ticket if ticket is not None and ticket > 0 else None + + +def _current_stop_loss(value: object) -> float | None: + return _optional_price(value) + + +def _trailing_stop_loss( + client: Mt5TradingClient, + *, + position_type: object, + current_sl: float | None, + bid: float | None, + ask: float | None, + digits: int, + trailing_stop_ratio: float, +) -> float | None: + next_sl: float | None = None + if position_type == client.mt5.POSITION_TYPE_BUY: + if bid is not None: + next_sl = round(bid * (1.0 - trailing_stop_ratio), digits) + if current_sl is not None and current_sl >= next_sl: + next_sl = None + elif position_type == client.mt5.POSITION_TYPE_SELL and ask is not None: + next_sl = round(ask * (1.0 + trailing_stop_ratio), digits) + if current_sl is not None and current_sl <= next_sl: + next_sl = None + return next_sl + + +def calculate_trailing_stop_updates( + client: Mt5TradingClient, + *, + symbol: str, + trailing_stop_ratio: float, +) -> dict[int, float]: + """Return per-ticket trailing stop-loss updates for open symbol positions. + + Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``. + Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``. + Existing stop losses are preserved when they are already more favorable. + Missing symbol metadata returns an empty update map. Positions with a + missing side-specific tick price are skipped. + """ + _require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio") + positions = get_positions_frame(client, symbol=symbol) + if positions.empty: + return {} + tick = get_tick_snapshot(client, symbol) + bid = extract_tick_price(tick, "bid") + ask = extract_tick_price(tick, "ask") + digits = _symbol_digits(client, symbol) + if digits is None: + return {} + + updates: dict[int, float] = {} + for row in positions.to_dict("records"): + ticket = _position_ticket(row.get("ticket")) + if ticket is None: + continue + next_sl = _trailing_stop_loss( + client, + position_type=row.get("type"), + current_sl=_current_stop_loss(row.get("sl")), + bid=bid, + ask=ask, + digits=digits, + trailing_stop_ratio=trailing_stop_ratio, + ) + if next_sl is None: + continue + updates[ticket] = next_sl + return updates + + +def update_trailing_stop_loss_for_open_positions( + client: Mt5TradingClient, + *, + symbol: str, + trailing_stop_ratio: float, + dry_run: bool = False, +) -> list[OrderExecutionResult]: + """Update open positions whose trailing stop loss should move favorably. + + Returns: + Normalized execution results for positions that need an SL update. + """ + updates = calculate_trailing_stop_updates( + client, + symbol=symbol, + trailing_stop_ratio=trailing_stop_ratio, + ) + results: list[OrderExecutionResult] = [] + for ticket, stop_loss in updates.items(): + results.extend( + update_sltp_for_open_positions( + client, + symbol=symbol, + tickets=[ticket], + stop_loss=stop_loss, + dry_run=dry_run, + ), + ) + return results + + def update_sltp_for_open_positions( client: Mt5TradingClient, *, @@ -1273,19 +1492,10 @@ def fetch_latest_closed_rates_for_trading_client( msg = "count must be positive." raise ValueError(msg) fetch_method = getattr(client, "fetch_latest_rates_as_df", None) - if callable(fetch_method): - fetched = fetch_method(symbol, granularity, count + 1) - else: - copy_method = getattr(client, "copy_rates_from_pos_as_df", None) - if not callable(copy_method): - msg = "MT5 trading client cannot fetch rate data." - raise Mt5TradingError(msg) - fetched = copy_method( - symbol=symbol, - timeframe=parse_timeframe(granularity), - start_pos=0, - count=count + 1, - ) + if not callable(fetch_method): + msg = "MT5 trading client cannot fetch rate data." + raise Mt5TradingError(msg) + fetched = fetch_method(symbol, granularity, count + 1) if not isinstance(fetched, pd.DataFrame): msg = ( f"Malformed rate data for {symbol!r} at granularity {granularity!r}: " diff --git a/pyproject.toml b/pyproject.toml index a419ae1..56141c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.9.2" +version = "0.9.3" 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 4e932c6..fd28ff0 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -2,9 +2,11 @@ from __future__ import annotations +import re import sqlite3 from datetime import UTC, datetime -from typing import TYPE_CHECKING, get_type_hints +from pathlib import Path +from typing import get_type_hints from unittest.mock import MagicMock import pandas as pd @@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002 import mt5cli from mt5cli import ( DEDUP_KEYS, + PUBLIC_EXPORT_TIERS, REQUIRED_COLUMNS, + SECONDARY_PUBLIC_EXPORTS, STABLE_SDK_EXPORTS, TIME_COLUMNS, AccountSpec, @@ -35,6 +39,9 @@ from mt5cli import ( build_rate_targets, calculate_margin_and_volume, calculate_positions_margin, + calculate_projected_margin_ratio, + calculate_symbol_group_margin_ratio, + calculate_trailing_stop_updates, call_with_normalized_errors, detect_format, drop_forming_rate_bar, @@ -42,6 +49,7 @@ from mt5cli import ( ensure_utc, export_dataframe, export_dataframe_to_sqlite, + extract_tick_price, fetch_latest_closed_rates, fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_indexed, @@ -69,9 +77,6 @@ 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 -if TYPE_CHECKING: - from pathlib import Path - def _sample_frame(kind: DataKind) -> pd.DataFrame: if kind is DataKind.rates: @@ -547,11 +552,69 @@ class TestStableSdkContract: missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__)) assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}" + def test_public_export_tiers_are_disjoint_and_complete(self) -> None: + """Documented public tiers do not overlap and classify root exports.""" + assert PUBLIC_EXPORT_TIERS == { + "stable": STABLE_SDK_EXPORTS, + "secondary": SECONDARY_PUBLIC_EXPORTS, + } + assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS) + tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS + root_exports = set(mt5cli.__all__) + + missing_from_root = sorted(tiered_exports - root_exports) + assert not missing_from_root, ( + f"Tiered exports missing from __all__: {missing_from_root}" + ) + + tier_metadata_exports = { + "PUBLIC_EXPORT_TIERS", + "SECONDARY_PUBLIC_EXPORTS", + "STABLE_SDK_EXPORTS", + } + unclassified_root_exports = sorted( + root_exports - tiered_exports - tier_metadata_exports, + ) + assert not unclassified_root_exports, ( + f"Root exports missing from public API tiers: {unclassified_root_exports}" + ) + + def test_stable_docs_do_not_document_nonstable_exports(self) -> None: + """Stable docs do not promote secondary root exports.""" + docs_path = Path("docs/api/public-contract.md") + docs = docs_path.read_text(encoding="utf-8") + stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[ + 1 + ].split( + "## Secondary public exports", + maxsplit=1, + )[0] + documented_symbols = set( + re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section) + ) + nonstable_exports = SECONDARY_PUBLIC_EXPORTS + + wrongly_stable = sorted(documented_symbols & nonstable_exports) + assert not wrongly_stable, ( + f"Non-stable exports documented in stable section: {wrongly_stable}" + ) + @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" + @pytest.mark.parametrize( + "name", + sorted(SECONDARY_PUBLIC_EXPORTS), + ) + def test_secondary_exports_are_importable( + self, + name: str, + ) -> None: + """Non-stable public names remain available from the package root.""" + 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]}) @@ -615,6 +678,15 @@ class TestStableSdkContract: assert calculate_positions_margin(client) == 0 + def test_generic_trading_helpers_from_package_root(self) -> None: + """New generic trading helpers resolve through the stable surface.""" + price = extract_tick_price({"bid": "1.2"}, "bid") + assert price is not None + assert abs(price - 1.2) < 1e-9 + assert callable(calculate_trailing_stop_updates) + assert callable(calculate_projected_margin_ratio) + assert callable(calculate_symbol_group_margin_ratio) + 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" diff --git a/tests/test_trading.py b/tests/test_trading.py index 8ddec7a..b2ae726 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -19,13 +19,15 @@ from mt5cli.trading import ( MarginVolume, OrderExecutionResult, OrderLimits, - _valid_tick_price, # type: ignore[reportPrivateUsage] calculate_margin_and_volume, calculate_new_position_margin_ratio, calculate_positions_margin, calculate_positions_margin_by_symbol, calculate_positions_margin_safe, + calculate_projected_margin_ratio, calculate_spread_ratio, + calculate_symbol_group_margin_ratio, + calculate_trailing_stop_updates, calculate_volume_by_margin, close_open_positions, create_trading_client, @@ -33,6 +35,7 @@ from mt5cli.trading import ( determine_order_limits, ensure_symbol_selected, estimate_order_margin, + extract_tick_price, fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_indexed, get_account_snapshot, @@ -43,6 +46,7 @@ from mt5cli.trading import ( normalize_order_volume, place_market_order, update_sltp_for_open_positions, + update_trailing_stop_loss_for_open_positions, ) @@ -1831,6 +1835,197 @@ class TestVolumeAndExecution: new_position_volume=0.1, ) + def test_projected_margin_ratio_empty_positions(self) -> None: + """Test no current or projected exposure returns zero ratio.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.positions_get_as_df.return_value = pd.DataFrame() + + _assert_close(calculate_projected_margin_ratio(client, symbol="EURUSD"), 0.0) + + def test_projected_margin_ratio_current_exposure(self) -> None: + """Test current position margin is divided by account equity.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.positions_get_as_df.return_value = pd.DataFrame( + [{"symbol": "EURUSD", "type": 0, "volume": 0.2}], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 50.0 + + _assert_close(calculate_projected_margin_ratio(client, symbol="EURUSD"), 0.05) + + def test_projected_margin_ratio_adds_buy_exposure(self) -> None: + """Test projected buy margin is added to current symbol exposure.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.positions_get_as_df.return_value = pd.DataFrame() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 25.0 + + result = calculate_projected_margin_ratio( + client, + symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + ) + + _assert_close(result, 0.025) + client.order_calc_margin.assert_called_once_with(10, "EURUSD", 0.1, 1.101) + + def test_projected_margin_ratio_adds_sell_exposure(self) -> None: + """Test projected sell margin uses bid pricing.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.positions_get_as_df.return_value = pd.DataFrame() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 24.0 + + result = calculate_projected_margin_ratio( + client, + symbol="EURUSD", + new_position_side="SELL", + new_position_volume=0.1, + ) + + _assert_close(result, 0.024) + client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1) + + def test_symbol_group_margin_ratio_sums_group_exposure( + self, + mocker: MockerFixture, + ) -> None: + """Test symbol-group exposure sums current per-symbol margins.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 25.0, "GBPUSD": 35.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD", "GBPUSD"], + ) + + _assert_close(result, 0.06) + + def test_symbol_group_margin_ratio_adds_projected_group_exposure( + self, + mocker: MockerFixture, + ) -> None: + """Test projected order margin is added when the symbol is in the group.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 15.0 + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 25.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + ) + + _assert_close(result, 0.04) + + def test_symbol_group_margin_ratio_suppresses_per_symbol_failures( + self, + mocker: MockerFixture, + ) -> None: + """Test suppressible per-symbol failures are skipped by the safe map.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin", + side_effect=[Mt5TradingError("bad tick"), 30.0], + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD", "GBPUSD"], + suppress_errors=True, + ) + + _assert_close(result, 0.03) + + def test_symbol_group_margin_ratio_rejects_invalid_equity(self) -> None: + """Test invalid equity fails closed for exposure helpers.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 0.0} + + with pytest.raises(Mt5TradingError, match="Account equity"): + calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"]) + + def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None: + """Test nonnumeric equity fails closed for exposure helpers.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": "invalid"} + + with pytest.raises(Mt5TradingError, match="Account equity"): + calculate_projected_margin_ratio(client, symbol="EURUSD") + + def test_symbol_group_margin_ratio_suppresses_projected_failure( + self, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Test projected margin failures can be skipped for safe group reads.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={}, + ) + mocker.patch( + "mt5cli.trading.estimate_order_margin", + side_effect=Mt5TradingError("bad tick"), + ) + + with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + suppress_errors=True, + ) + + _assert_close(result, 0.0) + assert "Skipping projected margin" in caplog.text + + def test_symbol_group_margin_ratio_reraises_projected_failure( + self, + mocker: MockerFixture, + ) -> None: + """Test projected margin failures raise when suppression is disabled.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={}, + ) + mocker.patch( + "mt5cli.trading.estimate_order_margin", + side_effect=Mt5TradingError("bad tick"), + ) + + with pytest.raises(Mt5TradingError, match="bad tick"): + calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + suppress_errors=False, + ) + 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() @@ -2199,6 +2394,283 @@ class TestVolumeAndExecution: assert client.order_send.call_args.args[0]["position"] == 9 + def test_calculate_trailing_stop_updates_no_positions(self) -> None: + """Test empty position sets produce no trailing updates.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame() + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.02, + ) + == {} + ) + + def test_calculate_trailing_stop_updates_buy_positions(self) -> None: + """Test buy trailing stops use bid and improve only upward.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}, + { + "ticket": 2, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.19, + }, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + + assert result == {1: 1.188} + + def test_calculate_trailing_stop_updates_buy_positions_ignore_invalid_ask( + self, + ) -> None: + """Test buy trailing stops do not require an ask price.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 0.0} + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + + assert result == {1: 1.188} + + def test_calculate_trailing_stop_updates_sell_positions(self) -> None: + """Test sell trailing stops use ask and improve only downward.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 3, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3}, + { + "ticket": 4, + "symbol": "EURUSD", + "type": 1, + "volume": 0.1, + "sl": 1.21, + }, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.198, "ask": 1.2} + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + + assert result == {3: 1.212} + + def test_calculate_trailing_stop_updates_sell_positions_ignore_invalid_bid( + self, + ) -> None: + """Test sell trailing stops do not require a bid price.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 3, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.2} + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + + assert result == {3: 1.212} + + def test_calculate_trailing_stop_updates_invalid_bid_or_ask(self) -> None: + """Test invalid side-specific tick prices fail safely without updates.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}, + {"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3}, + ], + ) + client.symbol_info_as_dict.return_value = {"digits": 4} + + client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": None} + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + == {} + ) + + def test_calculate_trailing_stop_updates_mixed_positions_skip_invalid_side( + self, + ) -> None: + """Test one invalid side price does not block the valid side.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}, + {"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3}, + ], + ) + client.symbol_info_as_dict.return_value = {"digits": 4} + + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 0.0} + assert calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) == {1: 1.188} + + client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.2} + assert calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) == {2: 1.212} + + def test_calculate_trailing_stop_updates_invalid_symbol_digits(self) -> None: + """Test invalid symbol metadata fails safely without updates.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {"digits": "bad"} + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + == {} + ) + + def test_calculate_trailing_stop_updates_missing_symbol_digits(self) -> None: + """Test missing symbol digits fail safely without rounded updates.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {} + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + == {} + ) + + client.symbol_info_as_dict.return_value = {"digits": None} + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + == {} + ) + + def test_calculate_trailing_stop_updates_skips_invalid_rows(self) -> None: + """Test invalid tickets and unknown position types are ignored.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": None, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + }, + { + "ticket": "5", + "symbol": "EURUSD", + "type": "unknown", + "volume": 0.1, + "sl": 1.0, + }, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {"digits": 4} + + assert ( + calculate_trailing_stop_updates( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + == {} + ) + + def test_update_trailing_stop_loss_dry_run(self) -> None: + """Test trailing-stop update wrapper supports dry-run requests.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = update_trailing_stop_loss_for_open_positions( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + dry_run=True, + ) + + assert len(result) == 1 + assert result[0]["status"] == "dry_run" + _assert_close(_request_from_result(result[0])["sl"], 1.188) + client.order_send.assert_not_called() + + def test_update_trailing_stop_loss_sends_changed_sl(self) -> None: + """Test trailing-stop wrapper sends normalized SL/TP updates.""" + client = _mock_trade_client() + 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}], + ) + client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201} + client.symbol_info_as_dict.return_value = {"digits": 4, "visible": True} + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10009, "comment": "updated"}], + ) + + result = update_trailing_stop_loss_for_open_positions( + client, + symbol="EURUSD", + trailing_stop_ratio=0.01, + ) + + assert result[0]["status"] == "executed" + _assert_close(_request_from_result(result[0])["sl"], 1.188) + client.order_send.assert_called_once() + 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() @@ -2609,32 +3081,6 @@ class TestFetchLatestClosedRatesForTradingClient: assert list(result["close"]) == [1.0, 1.1] assert list(result["time"]) == [1, 2] - def test_falls_back_to_copy_rates_from_pos_as_df(self) -> None: - """Test legacy trading clients without fetch helper still work.""" - client = MagicMock(spec=["copy_rates_from_pos_as_df", "mt5"]) - del client.fetch_latest_rates_as_df - client.copy_rates_from_pos_as_df.return_value = pd.DataFrame( - { - "time": [1, 2, 3], - "close": [1.0, 1.1, 1.2], - }, - ) - - result = fetch_latest_closed_rates_for_trading_client( - client, - symbol="EURUSD", - granularity="M1", - count=2, - ) - - client.copy_rates_from_pos_as_df.assert_called_once_with( - symbol="EURUSD", - timeframe=1, - start_pos=0, - count=3, - ) - assert list(result["close"]) == [1.0, 1.1] - def test_accepts_numeric_epoch_timestamps(self) -> None: """Test numeric epoch timestamps are preserved in output.""" client = MagicMock() @@ -2811,24 +3257,6 @@ class TestFetchLatestClosedRatesForTradingClient: client.fetch_latest_rates_as_df.assert_not_called() - def test_returns_range_index_and_time_column_for_backward_compat(self) -> None: - """Test original helper returns RangeIndex with a time column.""" - client = MagicMock() - client.fetch_latest_rates_as_df.return_value = pd.DataFrame( - {"time": [1700000000, 1700003600, 1700007200], "close": [1.1, 1.2, 1.3]}, - ) - - result = fetch_latest_closed_rates_for_trading_client( - client, - symbol="EURUSD", - granularity="M1", - count=2, - ) - - assert isinstance(result.index, pd.RangeIndex) - assert "time" in result.columns - assert len(result) == 2 - class TestFetchLatestClosedRatesIndexed: """Tests for fetch_latest_closed_rates_indexed.""" @@ -3078,58 +3506,62 @@ class TestFetchLatestClosedRatesIndexed: assert isinstance(result.index, pd.DatetimeIndex) -class TestValidTickPrice: - """Tests for the _valid_tick_price internal helper (#49).""" +class TestExtractTickPrice: + """Tests for the public extract_tick_price helper.""" def test_valid_positive_float(self) -> None: """Returns a positive float value unchanged.""" - _assert_close(_valid_tick_price({"bid": 1.1000}, "bid"), 1.1000) + _assert_close(extract_tick_price({"bid": 1.1000}, "bid"), 1.1000) def test_valid_positive_int(self) -> None: """Accepts an integer value and returns it as float.""" - result = _valid_tick_price({"bid": 2}, "bid") + result = extract_tick_price({"bid": 2}, "bid") _assert_close(result, 2.0) assert isinstance(result, float) def test_valid_numeric_string(self) -> None: """Accepts a numeric string and returns the parsed float.""" - _assert_close(_valid_tick_price({"bid": "1.5"}, "bid"), 1.5) + _assert_close(extract_tick_price({"bid": "1.5"}, "bid"), 1.5) def test_missing_key(self) -> None: """Returns None when the key is absent from the tick dict.""" - assert _valid_tick_price({}, "bid") is None + assert extract_tick_price({}, "bid") is None def test_none_value(self) -> None: """Returns None when the stored value is None.""" - assert _valid_tick_price({"bid": None}, "bid") is None + assert extract_tick_price({"bid": None}, "bid") is None + + def test_bool_value(self) -> None: + """Returns None for bool values even though bool is int-like.""" + assert extract_tick_price({"bid": True}, "bid") is None def test_invalid_string(self) -> None: """Returns None for a non-numeric string.""" - assert _valid_tick_price({"bid": "not_a_number"}, "bid") is None + assert extract_tick_price({"bid": "not_a_number"}, "bid") is None def test_nan(self) -> None: """Returns None for a NaN float.""" - assert _valid_tick_price({"bid": float("nan")}, "bid") is None + assert extract_tick_price({"bid": float("nan")}, "bid") is None def test_positive_infinity(self) -> None: """Returns None for positive infinity.""" - assert _valid_tick_price({"bid": float("inf")}, "bid") is None + assert extract_tick_price({"bid": float("inf")}, "bid") is None def test_negative_infinity(self) -> None: """Returns None for negative infinity.""" - assert _valid_tick_price({"bid": float("-inf")}, "bid") is None + assert extract_tick_price({"bid": float("-inf")}, "bid") is None def test_zero(self) -> None: """Returns None for zero (not a valid price).""" - assert _valid_tick_price({"bid": 0.0}, "bid") is None + assert extract_tick_price({"bid": 0.0}, "bid") is None def test_negative_value(self) -> None: """Returns None for a negative price.""" - assert _valid_tick_price({"bid": -1.0}, "bid") is None + assert extract_tick_price({"bid": -1.0}, "bid") is None def test_unsupported_type(self) -> None: """Returns None for unsupported value types such as list.""" - assert _valid_tick_price({"bid": [1.0]}, "bid") is None + assert extract_tick_price({"bid": [1.0]}, "bid") is None class TestCalculatePositionsMarginBySymbol: diff --git a/uv.lock b/uv.lock index 6fc3172..c9e1bc3 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.9.2" +version = "0.9.3" source = { editable = "." } dependencies = [ { name = "click" },