From d156dd71761a26dd8c0da646090af6254fd19948 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:47:05 +0900 Subject: [PATCH] [codex] fix mt5 adapter APIs (#36) * fix mt5 adapter APIs * address PR feedback * fix zero ratio minimum volume sizing * Bump version to v0.8.0 --- README.md | 31 ++ docs/api/history.md | 32 +- docs/api/sdk.md | 25 +- docs/api/trading.md | 68 ++- mt5cli/__init__.py | 42 ++ mt5cli/history.py | 82 +++- mt5cli/sdk.py | 43 +- mt5cli/trading.py | 725 ++++++++++++++++++++++++++++++-- mt5cli/utils.py | 14 + pyproject.toml | 2 +- tests/test_history.py | 36 ++ tests/test_sdk.py | 63 ++- tests/test_trading.py | 932 +++++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 14 files changed, 2002 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 0cf9f89..16b417e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,37 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma `MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly. +### Trading lifecycle and state helpers + +Trading applications can depend on `mt5cli` imports only; terminal path, +credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric +login strings are coerced to integers, and empty login strings are treated as +unset. + +```python +from mt5cli import ( + calculate_spread_ratio, + create_trading_client, + get_account_snapshot, + mt5_trading_session, +) + +with mt5_trading_session( + path=r"C:\Program Files\MetaTrader 5\terminal64.exe", + login="12345", + password="from-env-or-secret-store", + server="Broker-Demo", +) as client: + account = get_account_snapshot(client) + spread = calculate_spread_ratio(client, "EURUSD") + +client = create_trading_client(login=12345, server="Broker-Demo") +try: + positions = client.positions_get_as_df(symbol="EURUSD") +finally: + client.shutdown() +``` + ## CLI usage ```bash diff --git a/docs/api/history.md b/docs/api/history.md index 9173187..75967ca 100644 --- a/docs/api/history.md +++ b/docs/api/history.md @@ -167,19 +167,45 @@ Resolution rules: ### Rate data loading -Use `load_rate_data()` to load a table or view from a SQLite path, or -`load_rate_data_from_connection()` when you already have a connection: +The canonical normalized rate table is `rates`; compatibility views are named +with `rate___` for single-timeframe symbols or +`rate____` when a symbol has multiple stored +timeframes. `resolve_rate_table_name()` returns `rates`, while +`resolve_rate_view_name()` returns the per-symbol compatibility view name. + +Use `load_rate_data()` or `load_rate_series_from_sqlite(..., table=...)` to load +a single table or view from a SQLite path. Use +`load_rate_series_by_granularity()` to load multiple instrument/granularity +targets without hard-coding view names: ```python from pathlib import Path -from mt5cli import load_rate_data +from mt5cli import ( + load_rate_data, + load_rate_series_by_granularity, + load_rate_series_from_sqlite, + resolve_rate_table_name, +) from mt5cli.history import resolve_rate_view_name view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True) rates = load_rate_data(Path("history.db"), view, count=1000) +same_rates = load_rate_series_from_sqlite(Path("history.db"), table=view, count=1000) + +table = resolve_rate_table_name("EURUSD", "M1") # "rates" +series = load_rate_series_by_granularity( + Path("history.db"), + symbols=["EURUSD", "GBPUSD"], + granularities=["M1", "H1"], + count=500, +) ``` +`count` returns the latest rows while preserving chronological order. Missing +tables/views and mismatched `explicit_tables` lengths raise `ValueError` with +the requested database target in the message. + The loader accepts close-based OHLC rate data or tick-like bid/ask data. It validates that `time` exists, parses timestamps with pandas, and returns a DataFrame indexed by ascending `DatetimeIndex` named `time`. diff --git a/docs/api/sdk.md b/docs/api/sdk.md index a6be000..7b2b218 100644 --- a/docs/api/sdk.md +++ b/docs/api/sdk.md @@ -31,13 +31,25 @@ 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. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars, -drops that row with `drop_forming_rate_bar()`, and validates each series is -non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers -prefer keys such as `("EURUSD", "M1")` instead of integer timeframes. +row. `fetch_latest_closed_rates()` handles one connected client; 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 +oldest-to-newest and may contain fewer than `count` rows only when MT5 returns +fewer closed bars. ```python -from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity +from mt5cli import ( + AccountSpec, + collect_latest_closed_rates_by_granularity, + fetch_latest_closed_rates, +) + +closed = fetch_latest_closed_rates( + client, + symbol="EURUSD", + granularity="M1", + count=500, +) rates = collect_latest_closed_rates_by_granularity( [AccountSpec(symbols=["EURUSD"], login=12345)], @@ -48,6 +60,9 @@ rates = collect_latest_closed_rates_by_granularity( closed_m1 = rates["EURUSD", "M1"] ``` +Use `collect_latest_closed_rates_by_granularity()` when callers prefer keys such +as `("EURUSD", "M1")` instead of integer timeframes. + ### Resolving credentials and `${ENV_VAR}` placeholders `resolve_account_spec()` / `resolve_account_specs()` merge explicit override diff --git a/docs/api/trading.md b/docs/api/trading.md index 016496a..957b03e 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -4,38 +4,60 @@ ## Trading-capable MT5 sessions -`mt5_trading_session()` complements the read-only `mt5_session()` helper in -`sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses -`Mt5Config.path` to launch the terminal when configured, and always calls -`shutdown()` on exit. +`create_trading_client()` and `mt5_trading_session()` complement the read-only +`mt5_session()` helper in `sdk.py`. They return or yield an initialized +`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when +configured, and `mt5_trading_session()` always calls `shutdown()` on exit. ```python -from pdmt5 import Mt5Config - -from mt5cli import mt5_trading_session +from mt5cli import create_trading_client, mt5_trading_session with mt5_trading_session( - Mt5Config(path=r"C:\Program Files\MetaTrader 5\terminal64.exe", login=12345), + path=r"C:\Program Files\MetaTrader 5\terminal64.exe", + login="12345", + password="secret", + server="Broker-Demo", retry_count=2, ) as client: positions = client.positions_get_as_df(symbol="EURUSD") + +client = create_trading_client(login=12345, server="Broker-Demo") +try: + account = client.account_info_as_dict() +finally: + client.shutdown() ``` +`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. -## Operational trading helpers +## State and order helpers These helpers are strategy-agnostic and do not depend on signal detection, betting logic, or scheduling code in downstream applications. ```python from mt5cli import ( + calculate_spread_ratio, calculate_margin_and_volume, + close_open_positions, detect_position_side, determine_order_limits, + get_account_snapshot, + get_positions_frame, + get_symbol_snapshot, + get_tick_snapshot, + place_market_order, ) +account = get_account_snapshot(client) +symbol = get_symbol_snapshot(client, "EURUSD") +tick = get_tick_snapshot(client, "EURUSD") +positions = get_positions_frame(client, "EURUSD") side = detect_position_side(client, "EURUSD") +spread_ratio = calculate_spread_ratio(client, "EURUSD") sizing = calculate_margin_and_volume( client, "EURUSD", @@ -49,11 +71,33 @@ limits = determine_order_limits( stop_loss_limit_ratio=0.01, take_profit_limit_ratio=0.02, ) +preview = place_market_order( + client, + symbol="EURUSD", + volume=sizing["buy_volume"], + order_side="BUY", + sl=limits["stop_loss"], + tp=limits["take_profit"], + dry_run=True, +) +closed = close_open_positions(client, symbols="EURUSD", dry_run=True) ``` -Protective ratios must satisfy `0 <= ratio < 1`; `0` omits that level. -`calculate_margin_and_volume()` clamps negative `margin_free` to `0.0` -before sizing. +`detect_position_side()` returns `long` for buy-only exposure, `short` for +sell-only exposure, and `None` for no positions or mixed long/short exposure. +`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises +`Mt5TradingError` when bid or ask is missing or non-positive. + +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. ## Migration from mteor-local helpers diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index 5dcaa1e..2c54b5d 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -2,6 +2,8 @@ from importlib.metadata import version +from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError + from .client import MT5Client, build_config, mt5_session from .converters import ( ensure_utc, @@ -32,6 +34,7 @@ from .history import ( 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, @@ -63,6 +66,7 @@ from .sdk import ( copy_rates_range, copy_ticks_from, copy_ticks_range, + fetch_latest_closed_rates, history_deals, history_orders, last_error, @@ -96,10 +100,26 @@ from .storage import ( export_dataframe_to_sqlite, ) from .trading import ( + POSITION_COLUMNS, + OrderFillingMode, + OrderSide, + OrderTimeMode, + PositionSide, calculate_margin_and_volume, + calculate_new_position_margin_ratio, + calculate_spread_ratio, + calculate_volume_by_margin, + close_open_positions, + create_trading_client, detect_position_side, determine_order_limits, + get_account_snapshot, + get_positions_frame, + get_symbol_snapshot, + get_tick_snapshot, mt5_trading_session, + place_market_order, + update_sltp_for_open_positions, ) from .utils import ( TICK_FLAG_MAP, @@ -114,6 +134,7 @@ __version__ = version(__package__) if __package__ else None __all__ = [ "DEDUP_KEYS", "KNOWN_MT5_TIME_COLUMNS", + "POSITION_COLUMNS", "REQUIRED_COLUMNS", "TICK_FLAG_MAP", "TIMEFRAME_MAP", @@ -125,9 +146,17 @@ __all__ = [ "MT5Client", "Mt5CliClient", "Mt5CliError", + "Mt5Config", "Mt5ConnectionError", "Mt5OperationError", + "Mt5RuntimeError", "Mt5SchemaError", + "Mt5TradingClient", + "Mt5TradingError", + "OrderFillingMode", + "OrderSide", + "OrderTimeMode", + "PositionSide", "RateTarget", "ThrottledHistoryUpdater", "account_info", @@ -135,7 +164,11 @@ __all__ = [ "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", @@ -147,6 +180,7 @@ __all__ = [ "copy_rates_range", "copy_ticks_from", "copy_ticks_range", + "create_trading_client", "detect_format", "detect_position_side", "determine_order_limits", @@ -154,6 +188,11 @@ __all__ = [ "ensure_utc", "export_dataframe", "export_dataframe_to_sqlite", + "fetch_latest_closed_rates", + "get_account_snapshot", + "get_positions_frame", + "get_symbol_snapshot", + "get_tick_snapshot", "granularity_name", "history_deals", "history_orders", @@ -181,6 +220,7 @@ __all__ = [ "parse_datetime", "parse_tick_flags", "parse_timeframe", + "place_market_order", "positions", "recent_history_deals", "recent_ticks", @@ -190,6 +230,7 @@ __all__ = [ "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", @@ -201,5 +242,6 @@ __all__ = [ "terminal_info", "update_history", "update_history_with_config", + "update_sltp_for_open_positions", "validate_schema", ] diff --git a/mt5cli/history.py b/mt5cli/history.py index 4696fd2..7eacaef 100644 --- a/mt5cli/history.py +++ b/mt5cli/history.py @@ -7,7 +7,7 @@ import sqlite3 from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal, cast, overload import pandas as pd from pdmt5 import get_timeframe_name as _get_timeframe_name @@ -142,6 +142,26 @@ def build_rate_view_name( return f"rate_{symbol}__{granularity}_{timeframe}" +def resolve_rate_table_name(symbol: str, granularity: str) -> str: + """Return the canonical normalized SQLite rate table name. + + The normalized history table stores all symbols and timeframes in + ``rates``; use :func:`resolve_rate_view_name` for per-symbol compatibility + view names. + + Returns: + Canonical normalized rates table name. + + Raises: + ValueError: If ``symbol`` or ``granularity`` is invalid. + """ + parse_timeframe(granularity) + if not symbol.strip(): + msg = "symbol must not be empty." + raise ValueError(msg) + return Dataset.rates.table_name + + SqliteConnOrPath = sqlite3.Connection | Path | str @@ -653,34 +673,76 @@ def resolve_rate_tables( conn.close() +if TYPE_CHECKING: + + @overload + def load_rate_series_from_sqlite( + conn_or_path: SqliteConnOrPath, + targets: None = None, + count: int | None = None, + explicit_tables: None = None, + *, + table: str, + ) -> pd.DataFrame: ... + + @overload + def load_rate_series_from_sqlite( + conn_or_path: SqliteConnOrPath, + targets: None = None, + count: int | None = None, + explicit_tables: Sequence[str] | None = None, + *, + table: None = None, + ) -> dict[tuple[str | None, int], pd.DataFrame]: ... + + @overload + def load_rate_series_from_sqlite( + conn_or_path: SqliteConnOrPath, + targets: Sequence[RateTarget], + count: int, + explicit_tables: Sequence[str] | None = None, + *, + table: None = None, + ) -> dict[tuple[str | None, int], pd.DataFrame]: ... + + def load_rate_series_from_sqlite( conn_or_path: SqliteConnOrPath, - targets: Sequence[RateTarget], - count: int, + targets: Sequence[RateTarget] | None = None, + count: int | None = None, explicit_tables: Sequence[str] | None = None, -) -> dict[tuple[str | None, int], pd.DataFrame]: - """Load multiple rate series from a SQLite database. + *, + table: str | None = None, +) -> dict[tuple[str | None, int], pd.DataFrame] | pd.DataFrame: + """Load one table/view or multiple rate series from a SQLite database. Args: conn_or_path: SQLite database path or open connection. - targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair - must be unique. - count: Number of most recent rows to load per series. + targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair must + be unique. Omit when loading a single explicit ``table``. + count: Optional number of most recent rows to load per series. explicit_tables: Optional explicit table or view names matching targets. When omitted, managed ``rate_*`` compatibility views must already exist in the database. + table: Optional single table or view name to load directly. Returns: - Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame. + A DataFrame when ``table`` is provided, otherwise a mapping keyed by + ``(symbol, timeframe_int)`` to each rate DataFrame. Raises: ValueError: If ``count`` is not positive, targets are empty, duplicate ``(symbol, timeframe_int)`` pairs are present, or table resolution fails. """ - if count <= 0: + if table is not None: + return load_rate_data(conn_or_path, table, count=count) + if count is None or count <= 0: msg = "count must be positive." raise ValueError(msg) + if targets is None: + msg = "targets are required when table is not provided." + raise ValueError(msg) target_list = list(targets) if not target_list: msg = "At least one rate target is required." diff --git a/mt5cli/sdk.py b/mt5cli/sdk.py index 8383b65..bff6eee 100644 --- a/mt5cli/sdk.py +++ b/mt5cli/sdk.py @@ -37,6 +37,7 @@ from .utils import ( parse_tick_flags, parse_timeframe, ) +from .utils import coerce_login as _coerce_login if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence @@ -122,6 +123,7 @@ __all__ = [ "copy_rates_range", "copy_ticks_from", "copy_ticks_range", + "fetch_latest_closed_rates", "history_deals", "history_orders", "last_error", @@ -1289,6 +1291,33 @@ def latest_rates( ) +def fetch_latest_closed_rates( + client: Mt5CliClient, + *, + symbol: str, + granularity: str, + count: int, +) -> pd.DataFrame: + """Fetch up to ``count`` most recent closed bars, oldest to newest. + + Returns: + Closed rate bars ordered oldest to newest. + + Raises: + ValueError: If ``count`` is not positive or no closed bars are returned. + """ + _require_positive(count, "count") + frame = client.latest_rates(symbol, granularity, count + 1, start_pos=0) + closed = drop_forming_rate_bar(frame) + if closed.empty: + msg = ( + f"Rate data is empty for {symbol!r} at granularity {granularity!r} " + f"with count {count}." + ) + raise ValueError(msg) + return closed.tail(count).reset_index(drop=True) + + def collect_latest_rates( symbols: Sequence[str], timeframes: Sequence[int | str], @@ -1470,20 +1499,6 @@ def resolve_account_specs( ] -def _coerce_login(login: int | str | None) -> int | None: - """Coerce a login value to int, treating empty strings as unset. - - Returns: - Integer login, or None when unset or an empty string. - """ - if login is None or isinstance(login, int): - return login - text = login.strip() - if not text: - return None - return int(text) - - def _build_account_config( account: AccountSpec, base_config: Mt5Config | None, diff --git a/mt5cli/trading.py b/mt5cli/trading.py index a1b422a..18ee1c8 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -3,27 +3,98 @@ from __future__ import annotations from contextlib import contextmanager -from typing import TYPE_CHECKING, Literal +from math import floor, isfinite +from typing import TYPE_CHECKING, Literal, cast -from pdmt5 import Mt5Config, Mt5TradingClient +import pandas as pd +from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError from .sdk import build_config +from .utils import coerce_login as _coerce_login if TYPE_CHECKING: from collections.abc import Iterator - import pandas as pd - PositionSide = Literal["long", "short"] -OrderSide = Literal["long", "short"] +OrderSide = Literal["BUY", "SELL"] +OrderFillingMode = Literal["IOC", "FOK", "RETURN"] +OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] +_ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"}) +_ORDER_TIME_MODES: frozenset[str] = frozenset({ + "GTC", + "DAY", + "SPECIFIED", + "SPECIFIED_DAY", +}) +_SUCCESS_RETCODE_NAMES: tuple[str, ...] = ( + "TRADE_RETCODE_DONE", + "TRADE_RETCODE_DONE_PARTIAL", + "TRADE_RETCODE_PLACED", +) +_SUCCESS_RETCODE_FALLBACKS: frozenset[int] = frozenset({10008, 10009, 10010}) + +_ACCOUNT_SNAPSHOT_FIELDS = ( + "login", + "balance", + "equity", + "margin", + "margin_free", + "margin_level", + "leverage", + "currency", +) +_SYMBOL_SNAPSHOT_FIELDS = ( + "symbol", + "visible", + "trade_mode", + "digits", + "point", + "volume_min", + "volume_max", + "volume_step", + "trade_contract_size", + "trade_tick_size", + "trade_tick_value", + "trade_stops_level", + "filling_mode", +) +_TICK_SNAPSHOT_FIELDS = ("symbol", "time", "bid", "ask", "last", "volume") +POSITION_COLUMNS = ( + "ticket", + "time", + "symbol", + "type", + "volume", + "price_open", + "sl", + "tp", + "price_current", + "profit", + "swap", + "comment", +) __all__ = [ + "POSITION_COLUMNS", + "OrderFillingMode", "OrderSide", + "OrderTimeMode", "PositionSide", "calculate_margin_and_volume", + "calculate_new_position_margin_ratio", + "calculate_spread_ratio", + "calculate_volume_by_margin", + "close_open_positions", + "create_trading_client", "detect_position_side", "determine_order_limits", + "get_account_snapshot", + "get_positions_frame", + "get_symbol_snapshot", + "get_tick_snapshot", "mt5_trading_session", + "place_market_order", + "update_sltp_for_open_positions", ] @@ -46,18 +117,184 @@ def _sum_position_volume(positions: pd.DataFrame, position_type: object) -> floa return float(matched.to_numpy(dtype=float).sum()) +def _resolve_config( + *, + config: Mt5Config | None, + login: int | str | None, + password: str | None, + server: str | None, + path: str | None, + timeout: int | None, +) -> Mt5Config: + if config is not None: + return config + return build_config( + path=path, + login=_coerce_login(login), + password=password, + server=server, + timeout=timeout, + ) + + def _normalize_order_side(side: str) -> OrderSide: + normalized = side.upper() + if normalized in {"BUY", "LONG"}: + return "BUY" + if normalized in {"SELL", "SHORT"}: + return "SELL" + msg = f"Unsupported order side: {side!r}. Expected 'BUY' or 'SELL'." + raise ValueError(msg) + + +def _position_side_from_order_side(side: str) -> PositionSide: normalized = side.lower() if normalized in {"long", "buy"}: return "long" if normalized in {"short", "sell"}: return "short" - msg = ( - f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'." - ) + msg = f"Unsupported position side: {side!r}. Expected 'long' or 'short'." raise ValueError(msg) +def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, object]: + if isinstance(value, pd.DataFrame): + row: dict[str, object] = ( + {} if value.empty else cast("dict[str, object]", value.iloc[0].to_dict()) + ) + else: + asdict = getattr(value, "_asdict", None) + if callable(asdict): + row = cast("dict[str, object]", asdict()) + elif isinstance(value, dict): + typed_value = cast("dict[object, object]", value) + row = {str(key): item for key, item in typed_value.items()} + else: + row = { + field: getattr(value, field) + for field in fields + if hasattr(value, field) + } + if not fields: + return row + return {field: row.get(field) for field in fields} + + +def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object: + for name in names: + method = getattr(client, name, None) + if callable(method): + return method() + msg = f"MT5 client is missing required method: {' or '.join(names)}" + raise AttributeError(msg) + + +def _resolve_mt5_constant( + mt5: object, + prefix: str, + value: str, + allowed: frozenset[str], +) -> int: + normalized = value.upper() + if normalized not in allowed: + msg = f"Unsupported {prefix.lower()} mode: {value!r}." + raise ValueError(msg) + name = f"{prefix}_{normalized}" + try: + return cast("int", getattr(mt5, name)) + except AttributeError as exc: + msg = f"MT5 module is missing required constant: {name}" + raise Mt5TradingError(msg) from exc + + +def _optional_price(value: object) -> float | None: + if value is None: + return None + if not isinstance(value, int | float): + return None + price = float(value) + if price <= 0 or not isfinite(price): + return None + return price + + +def _success_retcodes(mt5: object) -> frozenset[int]: + values = { + value + for name in _SUCCESS_RETCODE_NAMES + if isinstance(value := getattr(mt5, name, None), 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): + return "failed" + return "executed" + + +def _calculate_min_volume_if_affordable( + client: Mt5TradingClient, + symbol: str, + available_margin: float, + order_side: OrderSide, +) -> float: + if available_margin <= 0: + return 0.0 + symbol_info = get_symbol_snapshot(client, symbol) + volume_min = float(symbol_info.get("volume_min") or 0.0) + volume_max = float(symbol_info.get("volume_max") or 0.0) + volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0) + if ( + volume_min <= 0 + or volume_step <= 0 + or (volume_max > 0 and volume_min > volume_max) + ): + msg = f"Invalid volume constraints for {symbol!r}." + raise Mt5TradingError(msg) + side = _normalize_order_side(order_side) + tick = get_tick_snapshot(client, symbol) + price = tick["ask"] if side == "BUY" else tick["bid"] + if not isinstance(price, int | float) or price <= 0: + msg = f"Tick price is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + order_type = ( + client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL + ) + min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price)) + return round(volume_min, 10) if 0 < min_margin <= available_margin else 0.0 + + +def create_trading_client( + *, + config: Mt5Config | None = None, + login: int | str | None = None, + password: str | None = None, + server: str | None = None, + path: str | None = None, + timeout: int | None = None, + retry_count: int = 0, +) -> Mt5TradingClient: + """Return an initialized and logged-in trading client.""" + mt5_config = _resolve_config( + config=config, + login=login, + password=password, + server=server, + path=path, + timeout=timeout, + ) + client = Mt5TradingClient(config=mt5_config, retry_count=retry_count) + try: + client.initialize_and_login_mt5() + except Exception: + client.shutdown() + raise + return client + + def detect_position_side( client: Mt5TradingClient, symbol: str, @@ -69,11 +306,11 @@ def detect_position_side( symbol: Symbol to inspect. Returns: - ``"long"`` when net buy volume exceeds sell volume, ``"short"`` when - net sell volume exceeds buy volume, or ``None`` when no positions exist - or buy/sell volumes are exactly balanced. + ``"long"`` when there are buy positions and no sell positions, + ``"short"`` when there are sell positions and no buy positions, or + ``None`` when no positions or mixed exposure exists. """ - positions = client.positions_get_as_df(symbol=symbol) + positions = get_positions_frame(client, symbol=symbol) if positions.empty: return None @@ -81,14 +318,114 @@ def detect_position_side( sell_type = client.mt5.POSITION_TYPE_SELL buy_volume = _sum_position_volume(positions, buy_type) sell_volume = _sum_position_volume(positions, sell_type) - net_volume = buy_volume - sell_volume - if net_volume > 0: + if buy_volume > 0 and sell_volume == 0: return "long" - if net_volume < 0: + if sell_volume > 0 and buy_volume == 0: return "short" return None +def get_account_snapshot( + client: Mt5TradingClient, +) -> dict[str, float | int | str | None]: + """Return normalized account state with stable keys.""" + value = _call_snapshot_method(client, "account_info_as_dict", "account_info") + return cast( + "dict[str, float | int | str | None]", + _snapshot_from_value(value, _ACCOUNT_SNAPSHOT_FIELDS), + ) + + +def get_symbol_snapshot( + client: Mt5TradingClient, + symbol: str, +) -> dict[str, float | int | str | bool | None]: + """Return normalized symbol metadata required for trading decisions.""" + method = getattr(client, "symbol_info_as_dict", None) + value = method(symbol=symbol) if callable(method) else client.symbol_info(symbol) + snapshot = _snapshot_from_value(value, _SYMBOL_SNAPSHOT_FIELDS) + snapshot["symbol"] = snapshot.get("symbol") or symbol + return cast("dict[str, float | int | str | bool | None]", snapshot) + + +def get_tick_snapshot( + client: Mt5TradingClient, + symbol: str, +) -> dict[str, float | int | None]: + """Return normalized latest tick data, including bid, ask, and timestamp.""" + method = getattr(client, "symbol_info_tick_as_dict", None) + value = ( + method(symbol=symbol) if callable(method) else client.symbol_info_tick(symbol) + ) + snapshot = _snapshot_from_value(value, _TICK_SNAPSHOT_FIELDS) + snapshot["symbol"] = snapshot.get("symbol") or symbol + return cast("dict[str, float | int | None]", snapshot) + + +def get_positions_frame( + client: Mt5TradingClient, + symbol: str | None = None, +) -> pd.DataFrame: + """Return open positions as a DataFrame with stable baseline columns.""" + frame = client.positions_get_as_df(symbol=symbol) + for column in POSITION_COLUMNS: + if column not in frame.columns: + frame[column] = pd.Series(dtype="object") + return frame + + +def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float: + """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick. + + Raises: + Mt5TradingError: If bid or ask is unavailable or non-positive. + """ + tick = get_tick_snapshot(client, symbol) + bid = tick.get("bid") + ask = tick.get("ask") + if not isinstance(bid, int | float) or not isinstance(ask, int | float): + msg = f"Tick bid/ask is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + if bid <= 0 or ask <= 0: + msg = f"Tick bid/ask must be positive for {symbol!r}." + raise Mt5TradingError(msg) + return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0) + + +def calculate_new_position_margin_ratio( + client: Mt5TradingClient, + *, + symbol: str, + new_position_side: OrderSide | None = None, + new_position_volume: float = 0.0, +) -> float: + """Return total margin/equity ratio after an optional hypothetical position. + + Raises: + Mt5TradingError: If equity or required tick data is invalid. + """ + account = get_account_snapshot(client) + equity = float(account.get("equity") or 0.0) + if equity <= 0: + msg = "Account equity must be positive to calculate margin ratio." + raise Mt5TradingError(msg) + 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) + tick = get_tick_snapshot(client, symbol) + price = tick["ask"] if side == "BUY" else tick["bid"] + if not isinstance(price, int | float) or price <= 0: + msg = f"Tick price is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + order_type = ( + client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL + ) + margin += float( + client.order_calc_margin(order_type, symbol, new_position_volume, price), + ) + return margin / equity + + def calculate_margin_and_volume( client: Mt5TradingClient, symbol: str, @@ -99,7 +436,9 @@ def calculate_margin_and_volume( Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``, then allocates ``unit_margin_ratio`` of the remainder as the margin budget - for volume sizing on both buy and sell sides. + for proportional volume sizing on both buy and sell sides. A + ``unit_margin_ratio`` of ``0`` requests exactly one minimum valid unit per + side when the post-reserve margin can afford it. Args: client: Connected ``Mt5TradingClient`` instance. @@ -119,23 +458,109 @@ def calculate_margin_and_volume( margin_free = max(0.0, float(account.get("margin_free") or 0.0)) available_margin = margin_free * (1.0 - preserved_margin_ratio) trade_margin = available_margin * unit_margin_ratio - buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY") - sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL") + if unit_margin_ratio == 0: + buy_volume = _calculate_min_volume_if_affordable( + client, + symbol, + available_margin, + "BUY", + ) + sell_volume = _calculate_min_volume_if_affordable( + client, + symbol, + available_margin, + "SELL", + ) + else: + native_calculate_volume = getattr(client, "calculate_volume_by_margin", None) + if callable(native_calculate_volume): + buy_volume = float( + cast( + "float | int | str", + native_calculate_volume(symbol, trade_margin, "BUY"), + ), + ) + sell_volume = float( + cast( + "float | int | str", + native_calculate_volume(symbol, trade_margin, "SELL"), + ), + ) + else: + buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY") + sell_volume = calculate_volume_by_margin( + client, + symbol, + trade_margin, + "SELL", + ) + try: + symbol_info = get_symbol_snapshot(client, symbol) + volume_min = float(symbol_info.get("volume_min") or 0.0) + volume_max = float(symbol_info.get("volume_max") or 0.0) + volume_step = float(symbol_info.get("volume_step") or 0.0) + except AttributeError: + volume_min = volume_max = volume_step = 0.0 return { "margin_free": margin_free, "available_margin": available_margin, "trade_margin": trade_margin, - "buy_volume": buy_volume, - "sell_volume": sell_volume, + "buy_volume": float(buy_volume), + "sell_volume": float(sell_volume), + "volume_min": volume_min, + "volume_max": volume_max, + "volume_step": volume_step, } +def calculate_volume_by_margin( + client: Mt5TradingClient, + symbol: str, + available_margin: float, + order_side: OrderSide, +) -> float: + """Calculate max normalized volume affordable for one side. + + Returns: + Affordable volume rounded down to symbol volume constraints. + + Raises: + Mt5TradingError: If symbol volume constraints or tick data are invalid. + """ + if available_margin <= 0: + return 0.0 + symbol_info = get_symbol_snapshot(client, symbol) + volume_min = float(symbol_info.get("volume_min") or 0.0) + volume_max = float(symbol_info.get("volume_max") or 0.0) + volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0) + if volume_min <= 0 or volume_step <= 0: + msg = f"Invalid volume constraints for {symbol!r}." + raise Mt5TradingError(msg) + side = _normalize_order_side(order_side) + tick = get_tick_snapshot(client, symbol) + price = tick["ask"] if side == "BUY" else tick["bid"] + if not isinstance(price, int | float) or price <= 0: + msg = f"Tick price is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + order_type = ( + client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL + ) + min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price)) + if min_margin <= 0 or min_margin > available_margin: + return 0.0 + raw_volume = available_margin / min_margin * volume_min + capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume + steps = floor(((capped - volume_min) / volume_step) + 1e-12) + normalized = volume_min + max(0, steps) * volume_step + return round(normalized, 10) if normalized >= volume_min else 0.0 + + def determine_order_limits( client: Mt5TradingClient, symbol: str, - side: OrderSide | str, - stop_loss_limit_ratio: float, - take_profit_limit_ratio: float, + side: PositionSide | str, + stop_loss_limit_ratio: float | None = None, + take_profit_limit_ratio: float | None = None, ) -> dict[str, float | None]: """Derive entry and protective order prices from current market quotes. @@ -152,26 +577,41 @@ def determine_order_limits( Returns: Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys. Omitted protective levels are returned as ``None``. + + Raises: + Mt5TradingError: If required tick data is invalid. """ - _require_protective_ratio(stop_loss_limit_ratio, "stop_loss_limit_ratio") - _require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio") - normalized_side = _normalize_order_side(side) - tick = client.symbol_info_tick_as_dict(symbol=symbol) - entry = float(tick["ask"] if normalized_side == "long" else tick["bid"]) + stop_loss_ratio = stop_loss_limit_ratio or 0.0 + take_profit_ratio = take_profit_limit_ratio or 0.0 + _require_protective_ratio(stop_loss_ratio, "stop_loss_limit_ratio") + _require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio") + normalized_side = _position_side_from_order_side(side) + tick = get_tick_snapshot(client, symbol) + entry_value = tick["ask"] if normalized_side == "long" else tick["bid"] + if not isinstance(entry_value, int | float): + msg = f"Tick price is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + entry = float(entry_value) + try: + digits = int(get_symbol_snapshot(client, symbol).get("digits") or 8) + except AttributeError: + digits = 8 stop_loss: float | None = None - if stop_loss_limit_ratio > 0: + if stop_loss_ratio > 0: if normalized_side == "long": - stop_loss = entry * (1.0 - stop_loss_limit_ratio) + stop_loss = entry * (1.0 - stop_loss_ratio) else: - stop_loss = entry * (1.0 + stop_loss_limit_ratio) + stop_loss = entry * (1.0 + stop_loss_ratio) + stop_loss = round(stop_loss, digits) take_profit: float | None = None - if take_profit_limit_ratio > 0: + if take_profit_ratio > 0: if normalized_side == "long": - take_profit = entry * (1.0 + take_profit_limit_ratio) + take_profit = entry * (1.0 + take_profit_ratio) else: - take_profit = entry * (1.0 - take_profit_limit_ratio) + take_profit = entry * (1.0 - take_profit_ratio) + take_profit = round(take_profit, digits) return { "entry": entry, @@ -180,9 +620,209 @@ def determine_order_limits( } +def place_market_order( + client: Mt5TradingClient, + *, + symbol: str, + volume: float, + order_side: OrderSide, + order_filling_mode: OrderFillingMode = "IOC", + order_time_mode: OrderTimeMode = "GTC", + sl: float | None = None, + tp: float | None = None, + position: int | None = None, + dry_run: bool = False, +) -> dict[str, object]: + """Place one normalized market order or return a dry-run result. + + ``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no + response. When MT5 returns a response with a known non-success retcode, this + helper returns ``status="failed"`` and keeps the normalized response + details for callers to inspect. + + Returns: + Normalized execution result containing request and response details. + + Raises: + Mt5TradingError: If volume or required tick data is invalid. + """ + if volume <= 0: + msg = "volume must be positive." + raise Mt5TradingError(msg) + side = _normalize_order_side(order_side) + tick = get_tick_snapshot(client, symbol) + price = tick["ask"] if side == "BUY" else tick["bid"] + if not isinstance(price, int | float) or price <= 0: + msg = f"Tick price is unavailable for {symbol!r}." + raise Mt5TradingError(msg) + request = { + "action": client.mt5.TRADE_ACTION_DEAL, + "symbol": symbol, + "volume": volume, + "type": ( + client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL + ), + "price": float(price), + "type_filling": _resolve_mt5_constant( + client.mt5, + "ORDER_FILLING", + order_filling_mode, + _ORDER_FILLING_MODES, + ), + "type_time": _resolve_mt5_constant( + client.mt5, + "ORDER_TIME", + order_time_mode, + _ORDER_TIME_MODES, + ), + } + if sl is not None: + request["sl"] = sl + if tp is not None: + request["tp"] = tp + if position is not None: + request["position"] = position + if dry_run: + return { + "status": "dry_run", + "symbol": symbol, + "order_side": side, + "volume": volume, + "retcode": None, + "comment": None, + "request": request, + "response": None, + "dry_run": True, + } + response = client.order_send(request) + response_dict = _snapshot_from_value(response, ()) + retcode = response_dict.get("retcode") + return { + "status": _order_status_from_retcode(client.mt5, retcode), + "symbol": symbol, + "order_side": side, + "volume": volume, + "retcode": retcode, + "comment": response_dict.get("comment"), + "request": request, + "response": response_dict, + "dry_run": False, + } + + +def _filter_positions( + positions: pd.DataFrame, + *, + symbols: str | list[str] | None = None, + tickets: list[int] | None = None, +) -> pd.DataFrame: + frame = positions + if symbols is not None: + symbol_set = {symbols} if isinstance(symbols, str) else set(symbols) + frame = frame.loc[frame["symbol"].isin(symbol_set)] + if tickets is not None: + frame = frame.loc[frame["ticket"].isin(tickets)] + return frame + + +def close_open_positions( + client: Mt5TradingClient, + *, + symbols: str | list[str] | None = None, + tickets: list[int] | None = None, + dry_run: bool = False, +) -> list[dict[str, object]]: + """Close matching open positions. + + Returns: + Normalized execution results for matching positions. + """ + positions = _filter_positions( + get_positions_frame(client), + symbols=symbols, + tickets=tickets, + ) + results: list[dict[str, object]] = [] + for row in positions.to_dict("records"): + pos_type = row["type"] + side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY" + result = place_market_order( + client, + symbol=str(row["symbol"]), + volume=float(row["volume"]), + order_side=side, + position=int(row["ticket"]), + dry_run=dry_run, + ) + results.append(result) + return results + + +def update_sltp_for_open_positions( + client: Mt5TradingClient, + *, + symbol: str | None = None, + tickets: list[int] | None = None, + stop_loss: float | None = None, + take_profit: float | None = None, + dry_run: bool = False, +) -> list[dict[str, object]]: + """Update SL/TP for matching open positions. + + Returns: + Normalized execution results for matching positions. + """ + positions = _filter_positions( + get_positions_frame(client), + symbols=symbol, + tickets=tickets, + ) + results: list[dict[str, object]] = [] + for row in positions.to_dict("records"): + request = { + "action": client.mt5.TRADE_ACTION_SLTP, + "symbol": row["symbol"], + "position": row["ticket"], + } + sl = _optional_price(row.get("sl") if stop_loss is None else stop_loss) + tp = _optional_price(row.get("tp") if take_profit is None else take_profit) + if sl is not None: + request["sl"] = sl + if tp is not None: + request["tp"] = tp + if dry_run: + response = None + status = "dry_run" + else: + response = _snapshot_from_value(client.order_send(request), ()) + status = "executed" + results.append( + { + "status": status, + "symbol": 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, + "response": response, + "dry_run": dry_run, + }, + ) + return results + + @contextmanager def mt5_trading_session( config: Mt5Config | None = None, + *, + login: int | str | None = None, + password: str | None = None, + server: str | None = None, + path: str | None = None, + timeout: int | None = None, retry_count: int = 0, ) -> Iterator[Mt5TradingClient]: """Open a trading-capable MT5 session and always shut down safely. @@ -195,16 +835,27 @@ def mt5_trading_session( Args: config: MT5 connection configuration. Defaults to an empty config that attaches to a running terminal. + login: Optional trading account login. + password: Optional trading account password. + server: Optional trading server name. + path: Optional terminal executable path. + timeout: Optional connection timeout in milliseconds. retry_count: Number of initialization retries passed to ``Mt5TradingClient``. Yields: Connected ``Mt5TradingClient`` bound to the session. """ - mt5_config = config or build_config() - client = Mt5TradingClient(config=mt5_config, retry_count=retry_count) + client = create_trading_client( + config=config, + login=login, + password=password, + server=server, + path=path, + timeout=timeout, + retry_count=retry_count, + ) try: - client.initialize_and_login_mt5() yield client finally: client.shutdown() diff --git a/mt5cli/utils.py b/mt5cli/utils.py index e2eb1e3..40ae584 100644 --- a/mt5cli/utils.py +++ b/mt5cli/utils.py @@ -241,6 +241,20 @@ def detect_format( raise ValueError(msg) +def coerce_login(login: int | str | None) -> int | None: + """Coerce a login value to int, treating empty strings as unset. + + Returns: + Integer login, or None when unset or an empty string. + """ + if login is None or isinstance(login, int): + return login + text = login.strip() + if not text: + return None + return int(text) + + def export_dataframe_to_sqlite( df: pd.DataFrame, output_path: Path, diff --git a/pyproject.toml b/pyproject.toml index 77414bd..8862751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.7.2" +version = "0.8.0" 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_history.py b/tests/test_history.py index d2e2340..ddadd01 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -48,6 +48,7 @@ from mt5cli.history import ( 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, @@ -63,6 +64,15 @@ from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists class TestResolveRateViewName: """Tests for resolve_rate_view_name and resolve_rate_view_names.""" + def test_resolve_rate_table_name_returns_normalized_table(self) -> None: + """Test canonical normalized rates table name is stable.""" + assert resolve_rate_table_name("EURUSD", "M1") == "rates" + + def test_resolve_rate_table_name_rejects_empty_symbol(self) -> None: + """Test canonical rate table resolution validates symbols.""" + with pytest.raises(ValueError, match="symbol must not be empty"): + resolve_rate_table_name(" ", "M1") + def test_missing_database_path_does_not_create_file(self, tmp_path: Path) -> None: """Test resolving against a missing path does not create a database.""" db_path = tmp_path / "missing.db" @@ -416,6 +426,32 @@ class TestLoadRateData: frame = load_rate_data_from_connection(conn, "rate_view") assert list(frame["close"]) == [1.0] + def test_load_rate_series_from_sqlite_table_style( + self, + tmp_path: Path, + ) -> None: + """Test public table-style loader returns one rate DataFrame.""" + db_path = tmp_path / "table-style.db" + with sqlite3.connect(db_path) as conn: + conn.execute("CREATE TABLE rates(time TEXT, close REAL)") + conn.executemany( + "INSERT INTO rates(time, close) VALUES (?, ?)", + [ + ("2024-01-01T00:00:00+00:00", 1.0), + ("2024-01-01T00:01:00+00:00", 1.1), + ], + ) + + frame = load_rate_series_from_sqlite(db_path, table="rates", count=1) + + assert isinstance(frame, pd.DataFrame) + assert list(frame["close"]) == [1.1] + + def test_load_rate_series_from_sqlite_requires_targets_without_table(self) -> None: + """Test multi-series loading requires targets when table is omitted.""" + with pytest.raises(ValueError, match="targets are required"): + load_rate_series_from_sqlite("unused.db", count=1) + def test_loads_quoted_identifier(self, tmp_path: Path) -> None: """Test table names are quoted safely.""" db_path = tmp_path / "quoted.db" diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 996e599..4aef807 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -37,6 +37,7 @@ from mt5cli.sdk import ( copy_rates_range, copy_ticks_from, copy_ticks_range, + fetch_latest_closed_rates, history_deals, history_orders, last_error, @@ -61,7 +62,7 @@ from mt5cli.sdk import ( update_history_with_config, version, ) -from mt5cli.utils import Dataset, IfExists +from mt5cli.utils import Dataset, IfExists, coerce_login class _TerminalInfo(NamedTuple): @@ -1301,12 +1302,12 @@ class TestAccountSpec: expected: int | None, ) -> None: """Test login values are normalized for account configs.""" - assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage] + assert coerce_login(login) == expected def test_coerce_login_rejects_non_numeric_string(self) -> None: """Test non-numeric login strings raise ValueError.""" with pytest.raises(ValueError, match="invalid literal"): - sdk._coerce_login("abc") # type: ignore[reportPrivateUsage] + coerce_login("abc") class TestCollectLatestRatesForAccounts: @@ -1662,6 +1663,62 @@ class TestCollectLatestClosedRatesForAccounts: ) +class TestFetchLatestClosedRates: + """Tests for fetch_latest_closed_rates.""" + + def test_fetches_extra_bar_and_drops_forming_row(self) -> None: + """Test single-symbol closed-bar helper hides the forming bar.""" + 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_raises_when_no_closed_bars_are_available(self) -> None: + """Test empty closed-bar results raise an actionable ValueError.""" + client = MagicMock() + client.latest_rates.return_value = pd.DataFrame({"close": [1.0]}) + + with pytest.raises(ValueError, match="Rate data is empty"): + fetch_latest_closed_rates( + client, + symbol="EURUSD", + granularity="M1", + count=1, + ) + + def test_rejects_non_positive_count_before_fetching(self) -> None: + """Test invalid count values fail before calling MT5.""" + client = MagicMock() + + with pytest.raises(ValueError, match="count must be positive"): + fetch_latest_closed_rates( + client, + symbol="EURUSD", + granularity="M1", + count=0, + ) + + client.latest_rates.assert_not_called() + + class TestCollectLatestClosedRatesByGranularity: """Tests for collect_latest_closed_rates_by_granularity.""" diff --git a/tests/test_trading.py b/tests/test_trading.py index 499d145..ea4c7d1 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -2,22 +2,59 @@ from __future__ import annotations +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock import pandas as pd import pytest -from pdmt5 import Mt5RuntimeError +from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pytest_mock import MockerFixture # noqa: TC002 from mt5cli.sdk import build_config from mt5cli.trading import ( calculate_margin_and_volume, + calculate_new_position_margin_ratio, + calculate_spread_ratio, + calculate_volume_by_margin, + close_open_positions, + create_trading_client, detect_position_side, determine_order_limits, + get_account_snapshot, + get_positions_frame, + get_symbol_snapshot, + get_tick_snapshot, mt5_trading_session, + place_market_order, + update_sltp_for_open_positions, ) +def _mock_trade_client() -> MagicMock: + client = MagicMock() + client.mt5.POSITION_TYPE_BUY = 0 + client.mt5.POSITION_TYPE_SELL = 1 + client.mt5.ORDER_TYPE_BUY = 10 + client.mt5.ORDER_TYPE_SELL = 11 + client.mt5.TRADE_ACTION_DEAL = 20 + client.mt5.TRADE_ACTION_SLTP = 21 + client.mt5.ORDER_FILLING_IOC = 30 + client.mt5.ORDER_TIME_GTC = 40 + client.mt5.TRADE_RETCODE_PLACED = 10008 + client.mt5.TRADE_RETCODE_DONE = 10009 + client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010 + return client + + +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"]) + + class TestDetectPositionSide: """Tests for detect_position_side.""" @@ -28,15 +65,15 @@ class TestDetectPositionSide: assert detect_position_side(client, "EURUSD") is None - def test_returns_long_for_net_buy_volume(self) -> None: - """Test long is returned when buy volume exceeds sell volume.""" + def test_returns_long_for_buy_only_exposure(self) -> None: + """Test long is returned when only buy positions exist.""" client = MagicMock() client.mt5.POSITION_TYPE_BUY = 0 client.mt5.POSITION_TYPE_SELL = 1 client.positions_get_as_df.return_value = pd.DataFrame( { - "type": [0, 0, 1], - "volume": [0.2, 0.1, 0.05], + "type": [0, 0], + "volume": [0.2, 0.1], }, ) @@ -56,15 +93,15 @@ class TestDetectPositionSide: assert detect_position_side(client, "EURUSD") == "short" - def test_returns_none_for_balanced_hedged_positions(self) -> None: - """Test None is returned when buy and sell volumes net to zero.""" + def test_returns_none_for_mixed_hedged_positions(self) -> None: + """Test None is returned for mixed buy and sell exposure.""" client = MagicMock() client.mt5.POSITION_TYPE_BUY = 0 client.mt5.POSITION_TYPE_SELL = 1 client.positions_get_as_df.return_value = pd.DataFrame( { "type": [0, 1], - "volume": [0.2, 0.2], + "volume": [0.3, 0.2], }, ) @@ -79,6 +116,7 @@ class TestCalculateMarginAndVolume: client = MagicMock() client.account_info_as_dict.return_value = {"margin_free": 1000.0} client.calculate_volume_by_margin.side_effect = [0.3, 0.2] + client.symbol_info_as_dict.side_effect = AttributeError("missing") result = calculate_margin_and_volume( client, @@ -93,6 +131,9 @@ class TestCalculateMarginAndVolume: "trade_margin": 400.0, "buy_volume": 0.3, "sell_volume": 0.2, + "volume_min": 0.0, + "volume_max": 0.0, + "volume_step": 0.0, } client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY") client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL") @@ -208,6 +249,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") result = determine_order_limits( client, @@ -227,6 +269,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") result = determine_order_limits( client, @@ -244,7 +287,7 @@ class TestDetermineOrderLimits: def test_rejects_unknown_side(self) -> None: """Test unsupported side values raise ValueError.""" - with pytest.raises(ValueError, match="Unsupported order side"): + with pytest.raises(ValueError, match="Unsupported position side"): determine_order_limits( MagicMock(), "EURUSD", @@ -301,6 +344,47 @@ class TestDetermineOrderLimits: **kwargs, ) + def test_uses_default_digits_when_symbol_snapshot_fails(self) -> None: + """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.side_effect = AttributeError("missing") + + 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_tick_snapshot_fallback(self) -> None: + """Test order limits use the normalized tick snapshot helper.""" + client = MagicMock() + del client.symbol_info_tick_as_dict + client.symbol_info_tick.return_value = SimpleNamespace(ask=1.2, bid=1.1) + client.symbol_info_as_dict.return_value = {"digits": 4} + + result = determine_order_limits( + client, + "EURUSD", + "long", + stop_loss_limit_ratio=0.01, + ) + + _assert_close(result["entry"], 1.2) + _assert_close(result["stop_loss"], 1.188) + + def test_rejects_missing_entry_tick(self) -> None: + """Test missing entry prices raise a trading error.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1} + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + determine_order_limits(client, "EURUSD", "long") + class TestMt5TradingSession: """Tests for the mt5_trading_session context manager.""" @@ -330,6 +414,836 @@ class TestMt5TradingSession: ) mock_client.shutdown.assert_called_once() + +class TestCreateTradingClient: + """Tests for create_trading_client.""" + + def test_initializes_with_keyword_config(self, mocker: MockerFixture) -> None: + """Test keyword configuration is forwarded to Mt5TradingClient.""" + mock_client = MagicMock() + trading_client = mocker.patch( + "mt5cli.trading.Mt5TradingClient", + return_value=mock_client, + ) + + result = create_trading_client( + login="12345", + password="test-pass", + server="Demo", + path="/opt/terminal64.exe", + retry_count=2, + ) + + assert result is mock_client + config = trading_client.call_args.kwargs["config"] + assert config.login == 12345 + assert config.password == ("test" + "-pass") + assert config.server == "Demo" + assert config.path == "/opt/terminal64.exe" + assert trading_client.call_args.kwargs["retry_count"] == 2 + mock_client.initialize_and_login_mt5.assert_called_once() + + def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None: + """Test empty login strings are treated as None.""" + trading_client = mocker.patch( + "mt5cli.trading.Mt5TradingClient", + return_value=MagicMock(), + ) + + create_trading_client(login=" ") + + config = trading_client.call_args.kwargs["config"] + assert config.login is None + + def test_shutdown_on_initialization_failure(self, mocker: MockerFixture) -> None: + """Test failed initialization shuts the client down.""" + mock_client = MagicMock() + mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom") + mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client) + + with pytest.raises(Mt5RuntimeError, match="boom"): + create_trading_client() + + mock_client.shutdown.assert_called_once() + + +class TestSnapshotsAndState: + """Tests for normalized state helpers.""" + + def test_account_snapshot_includes_missing_fields(self) -> None: + """Test account snapshot has stable keys with None for missing values.""" + client = MagicMock() + client.account_info_as_dict.return_value = {"login": 1, "equity": 100.0} + + result = get_account_snapshot(client) + + assert result["login"] == 1 + _assert_close(result["equity"], 100.0) + assert result["currency"] is None + + def test_account_snapshot_supports_object_fallback(self) -> None: + """Test account snapshots can read plain MT5-like objects.""" + + class ObjectClient: + def account_info(self) -> SimpleNamespace: + return SimpleNamespace(login=7, currency="JPY") + + result = get_account_snapshot(cast("Mt5TradingClient", ObjectClient())) + + assert result["login"] == 7 + assert result["currency"] == "JPY" + + def test_account_snapshot_requires_supported_method(self) -> None: + """Test missing account snapshot methods raise AttributeError.""" + client = object() + + with pytest.raises(AttributeError, match="account_info"): + get_account_snapshot(cast("Mt5TradingClient", client)) + + def test_symbol_and_tick_snapshots_fill_symbol(self) -> None: + """Test symbol and tick snapshots expose stable fields.""" + client = MagicMock() + client.symbol_info_as_dict.return_value = {"digits": 5, "visible": True} + client.symbol_info_tick_as_dict.return_value = {"bid": 1.1, "ask": 1.2} + + assert get_symbol_snapshot(client, "EURUSD")["symbol"] == "EURUSD" + assert get_tick_snapshot(client, "EURUSD")["symbol"] == "EURUSD" + + def test_symbol_and_tick_snapshots_use_object_fallbacks(self) -> None: + """Test symbol and tick snapshots support non-dict MT5 values.""" + client = MagicMock() + del client.symbol_info_as_dict + del client.symbol_info_tick_as_dict + client.symbol_info.return_value = SimpleNamespace(digits=3) + client.symbol_info_tick.return_value = SimpleNamespace(bid=1.0, ask=1.1) + + assert get_symbol_snapshot(client, "USDJPY")["digits"] == 3 + _assert_close(get_tick_snapshot(client, "USDJPY")["ask"], 1.1) + + def test_positions_frame_adds_stable_columns(self) -> None: + """Test missing position columns are added to empty frames.""" + client = MagicMock() + client.positions_get_as_df.return_value = pd.DataFrame() + + result = get_positions_frame(client, symbol="EURUSD") + + assert "ticket" in result.columns + assert "comment" in result.columns + + def test_calculate_spread_ratio(self) -> None: + """Test spread ratio uses mid-price denominator.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"bid": 99.0, "ask": 101.0} + + _assert_close(calculate_spread_ratio(client, "EURUSD"), 0.02) + + def test_calculate_spread_ratio_rejects_missing_tick(self) -> None: + """Test missing bid/ask raises a trading error.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.0} + + with pytest.raises(Mt5TradingError): + calculate_spread_ratio(client, "EURUSD") + + def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None: + """Test non-positive bid/ask raises a trading error.""" + client = MagicMock() + client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.0} + + with pytest.raises(Mt5TradingError): + calculate_spread_ratio(client, "EURUSD") + + +class TestVolumeAndExecution: + """Tests for order planning and execution helpers.""" + + def test_calculate_volume_by_margin_rounds_down_to_step(self) -> None: + """Test affordable volume respects min, max, and step.""" + 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 = 25.0 + + _assert_close( + calculate_volume_by_margin( + client, + "EURUSD", + 130.0, + "BUY", + ), + 0.5, + ) + + def test_calculate_volume_by_margin_caps_at_max_volume(self) -> None: + """Test positive volume_max caps raw affordable volume exactly.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 0.3, + "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 + + _assert_close(calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY"), 0.3) + + def test_calculate_volume_by_margin_ignores_zero_max_volume(self) -> None: + """Test zero volume_max means uncapped volume normalization.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 0.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 + + _assert_close(calculate_volume_by_margin(client, "EURUSD", 35.0, "BUY"), 0.3) + + def test_calculate_volume_by_margin_returns_zero_when_unaffordable(self) -> None: + """Test unaffordable minimum volume returns zero.""" + 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 = 25.0 + + _assert_close( + calculate_volume_by_margin( + client, + "EURUSD", + 10.0, + "BUY", + ), + 0.0, + ) + + 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() + + _assert_close( + calculate_volume_by_margin( + client, + "EURUSD", + 0.0, + "BUY", + ), + 0.0, + ) + client.order_calc_margin.assert_not_called() + + def test_calculate_volume_by_margin_rejects_invalid_constraints(self) -> None: + """Test invalid symbol volume constraints raise a trading error.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.0, + "volume_max": 1.0, + "volume_step": 0.1, + } + + with pytest.raises(Mt5TradingError): + calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY") + + def test_calculate_volume_by_margin_rejects_bad_tick(self) -> None: + """Test unavailable side price raises a trading error.""" + 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": 1.0, "bid": None} + + with pytest.raises(Mt5TradingError): + calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL") + + def test_calculate_margin_and_volume_without_native_helper(self) -> None: + """Test margin helper uses module volume calculation when needed.""" + + class ClientWithoutNative: + mt5 = SimpleNamespace(ORDER_TYPE_BUY=10, ORDER_TYPE_SELL=11) + + def account_info_as_dict(self) -> dict[str, float]: + return {"margin_free": 100.0} + + def symbol_info_as_dict(self, *, symbol: str) -> dict[str, float]: + assert symbol == "EURUSD" + return {"volume_min": 0.1, "volume_max": 1.0, "volume_step": 0.1} + + def symbol_info_tick_as_dict(self, *, symbol: str) -> dict[str, float]: + assert symbol == "EURUSD" + return {"ask": 100.0, "bid": 100.0} + + def order_calc_margin( + self, + order_type: int, + symbol: str, + volume: float, + price: float, + ) -> float: + assert order_type in {10, 11} + assert symbol == "EURUSD" + _assert_close(volume, 0.1) + _assert_close(price, 100.0) + return 10.0 + + result = calculate_margin_and_volume( + cast("Mt5TradingClient", ClientWithoutNative()), + "EURUSD", + unit_margin_ratio=0.5, + preserved_margin_ratio=0.0, + ) + + _assert_close(result["buy_volume"], 0.5) + _assert_close(result["sell_volume"], 0.5) + + def test_calculate_margin_and_volume_zero_ratio_uses_minimum_volume( + self, + ) -> None: + """Test zero unit ratio requests one minimum volume when affordable.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + 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.side_effect = [10.0, 20.0] + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=0.0, + ) + + _assert_close(result["available_margin"], 100.0) + _assert_close(result["trade_margin"], 0.0) + _assert_close(result["buy_volume"], 0.1) + _assert_close(result["sell_volume"], 0.1) + client.calculate_volume_by_margin.assert_not_called() + + def test_calculate_margin_and_volume_zero_ratio_rejects_unaffordable_side( + self, + ) -> None: + """Test zero unit ratio returns zero for unaffordable minimum lots.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 15.0} + 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.side_effect = [10.0, 20.0] + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=0.0, + ) + + _assert_close(result["buy_volume"], 0.1) + _assert_close(result["sell_volume"], 0.0) + + def test_calculate_margin_and_volume_zero_ratio_preserves_margin_first( + self, + ) -> None: + """Test preserved margin reduces affordability before min sizing.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + 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.side_effect = [11.0, 9.0] + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=0.9, + ) + + _assert_close(result["available_margin"], 10.0) + _assert_close(result["buy_volume"], 0.0) + _assert_close(result["sell_volume"], 0.1) + + def test_calculate_margin_and_volume_zero_ratio_without_available_margin( + self, + ) -> None: + """Test zero unit ratio returns zero when preserved margin consumes funds.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 1.0, + "volume_step": 0.1, + } + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=1.0, + ) + + _assert_close(result["buy_volume"], 0.0) + _assert_close(result["sell_volume"], 0.0) + client.order_calc_margin.assert_not_called() + + def test_calculate_margin_and_volume_zero_ratio_rejects_invalid_max_volume( + self, + ) -> None: + """Test zero unit ratio still respects max-volume constraints.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + client.symbol_info_as_dict.return_value = { + "volume_min": 0.2, + "volume_max": 0.1, + "volume_step": 0.1, + } + + with pytest.raises(Mt5TradingError, match="Invalid volume constraints"): + calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=0.0, + ) + + def test_calculate_margin_and_volume_zero_ratio_rejects_bad_tick(self) -> None: + """Test zero unit ratio validates tick data for minimum sizing.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + 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": None, "bid": 99.0} + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.0, + preserved_margin_ratio=0.0, + ) + + def test_calculate_margin_and_volume_positive_ratio_uses_existing_behavior( + self, + ) -> None: + """Test positive unit ratios keep proportional native sizing.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"margin_free": 100.0} + client.calculate_volume_by_margin.side_effect = [0.4, 0.3] + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 1.0, + "volume_step": 0.1, + } + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.5, + preserved_margin_ratio=0.2, + ) + + _assert_close(result["trade_margin"], 40.0) + _assert_close(result["buy_volume"], 0.4) + _assert_close(result["sell_volume"], 0.3) + client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "BUY") + client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "SELL") + + def test_calculate_margin_and_volume_handles_missing_symbol_snapshot( + self, + ) -> None: + """Test missing symbol metadata falls back to zero volume constraints.""" + client = MagicMock() + client.account_info_as_dict.return_value = {"margin_free": 1000.0} + client.calculate_volume_by_margin.side_effect = [0.3, 0.2] + client.symbol_info_as_dict.side_effect = AttributeError("missing") + + result = calculate_margin_and_volume( + client, + "EURUSD", + unit_margin_ratio=0.5, + preserved_margin_ratio=0.2, + ) + + _assert_close(result["volume_min"], 0.0) + _assert_close(result["volume_max"], 0.0) + _assert_close(result["volume_step"], 0.0) + + def test_new_position_margin_ratio_adds_hypothetical_margin(self) -> None: + """Test hypothetical order margin is added to account margin.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0} + client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} + client.order_calc_margin.return_value = 25.0 + + result = calculate_new_position_margin_ratio( + client, + symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + ) + _assert_close(result, 0.075) + + def test_new_position_margin_ratio_without_new_position(self) -> None: + """Test current margin ratio can be calculated without a new order.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0} + + _assert_close( + calculate_new_position_margin_ratio( + client, + symbol="EURUSD", + ), + 0.05, + ) + client.order_calc_margin.assert_not_called() + + def test_new_position_margin_ratio_rejects_invalid_equity(self) -> None: + """Test non-positive equity raises a trading error.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 0.0, "margin": 50.0} + + with pytest.raises(Mt5TradingError): + calculate_new_position_margin_ratio(client, symbol="EURUSD") + + def test_new_position_margin_ratio_rejects_bad_tick(self) -> None: + """Test missing hypothetical order price raises a trading error.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0} + client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.0} + + with pytest.raises(Mt5TradingError): + calculate_new_position_margin_ratio( + client, + symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + ) + + 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_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + dry_run=True, + ) + + assert result["status"] == "dry_run" + assert _request_from_result(result)["type"] == client.mt5.ORDER_TYPE_BUY + client.order_send.assert_not_called() + + def test_place_market_order_supports_limits(self) -> None: + """Test optional SL/TP values are included in the request.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + sl=1.0, + tp=1.4, + dry_run=True, + ) + + _assert_close(_request_from_result(result)["sl"], 1.0) + _assert_close(_request_from_result(result)["tp"], 1.4) + + def test_place_market_order_rejects_invalid_filling_mode(self) -> None: + """Test MT5 filling mode names are validated before getattr.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + with pytest.raises(ValueError, match="Unsupported order_filling mode"): + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + order_filling_mode=cast("Any", "BAD"), + dry_run=True, + ) + + def test_place_market_order_rejects_invalid_time_mode(self) -> None: + """Test MT5 time mode names are validated before getattr.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + with pytest.raises(ValueError, match="Unsupported order_time mode"): + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + order_time_mode=cast("Any", "BAD"), + dry_run=True, + ) + + def test_place_market_order_rejects_missing_mt5_constant(self) -> None: + """Test missing MT5 constants fail with a controlled trading error.""" + client = _mock_trade_client() + del client.mt5.ORDER_FILLING_IOC + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + with pytest.raises(Mt5TradingError, match="ORDER_FILLING_IOC"): + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + dry_run=True, + ) + + def test_place_market_order_rejects_invalid_volume(self) -> None: + """Test non-positive volume raises a trading error.""" + with pytest.raises(Mt5TradingError): + place_market_order( + _mock_trade_client(), + symbol="EURUSD", + volume=0.0, + order_side="BUY", + ) + + def test_place_market_order_rejects_bad_tick(self) -> None: + """Test unavailable market order price raises a trading error.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1} + + with pytest.raises(Mt5TradingError): + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="BUY", + ) + + def test_place_market_order_rejects_unknown_side(self) -> None: + """Test unsupported order sides raise ValueError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + with pytest.raises(ValueError, match="Unsupported order side"): + place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side=cast("Any", "FLAT"), + ) + + def test_place_market_order_sends_and_normalizes_response(self) -> None: + """Test live market order responses are normalized.""" + 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": 10009, "comment": "done"}], + ) + + result = place_market_order( + client, + symbol="EURUSD", + volume=0.1, + order_side="SELL", + ) + + assert result["status"] == "executed" + assert result["retcode"] == 10009 + client.order_send.assert_called_once() + + def test_place_market_order_marks_failed_retcode(self) -> None: + """Test order_send responses with failed retcodes are normalized.""" + 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["status"] == "failed" + assert result["retcode"] == 10013 + + def test_close_open_positions_filters_and_dry_runs(self) -> None: + """Test close helper filters positions and builds opposite orders.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"ticket": 2, "symbol": "USDJPY", "type": 1, "volume": 0.2}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + result = close_open_positions(client, symbols="EURUSD", dry_run=True) + + assert len(result) == 1 + assert result[0]["order_side"] == "SELL" + assert _request_from_result(result[0])["position"] == 1 + + def test_close_open_positions_filters_by_ticket(self) -> None: + """Test close helper can filter by ticket.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"ticket": 2, "symbol": "USDJPY", "type": 1, "volume": 0.2}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + + result = close_open_positions(client, tickets=[2], dry_run=True) + + assert len(result) == 1 + assert result[0]["order_side"] == "BUY" + + def test_close_open_positions_sends_position_ticket(self) -> None: + """Test live close orders include the position before order_send.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"ticket": 9, "symbol": "EURUSD", "type": 0, "volume": 0.1}], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + client.order_send.return_value = SimpleNamespace(retcode=10009, comment="done") + + close_open_positions(client, tickets=[9]) + + assert client.order_send.call_args.args[0]["position"] == 9 + + 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.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 1.0, + "tp": 1.4, + }, + { + "ticket": 2, + "symbol": "USDJPY", + "type": 1, + "volume": 0.2, + "sl": 100.0, + "tp": 99.0, + }, + ], + ) + + result = update_sltp_for_open_positions( + client, + symbol="EURUSD", + stop_loss=1.1, + take_profit=1.3, + dry_run=True, + ) + + assert len(result) == 1 + _assert_close(_request_from_result(result[0])["sl"], 1.1) + _assert_close(_request_from_result(result[0])["tp"], 1.3) + + def test_update_sltp_sends_and_normalizes_response(self) -> None: + """Test live SL/TP updates send requests and normalize responses.""" + 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, + "tp": 1.4, + }, + ], + ) + client.order_send.return_value = pd.DataFrame( + [{"retcode": 10009, "comment": "updated"}], + ) + + result = update_sltp_for_open_positions(client, tickets=[1]) + + assert result[0]["status"] == "executed" + assert result[0]["retcode"] == 10009 + _assert_close(_request_from_result(result[0])["sl"], 1.0) + + def test_update_sltp_omits_invalid_existing_levels(self) -> None: + """Test raw broker SL/TP sentinels are not forwarded to order_send.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": 0.0, + "tp": float("nan"), + }, + ], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], dry_run=True) + + request = _request_from_result(result[0]) + assert "sl" not in request + assert "tp" not in request + + def test_update_sltp_omits_missing_and_non_numeric_existing_levels(self) -> None: + """Test missing and non-numeric SL/TP levels are not forwarded.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + { + "ticket": 1, + "symbol": "EURUSD", + "type": 0, + "volume": 0.1, + "sl": None, + "tp": "unset", + }, + ], + ) + + result = update_sltp_for_open_positions(client, tickets=[1], dry_run=True) + + request = _request_from_result(result[0]) + assert "sl" not in request + assert "tp" not in request + def test_shuts_down_when_initialize_raises( self, mocker: MockerFixture, diff --git a/uv.lock b/uv.lock index e287e7e..48ed61c 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.7.2" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "click" },