From c4a4253fbc1e85fa83679792d3a77981f61e5245 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:02:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20stable=20SDK=20helpers=20for=20volume,?= =?UTF-8?q?=20margin,=20and=20closed=20bars=20(#39=E2=80=93#41)=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add stable SDK helpers for volume, margin, and closed bars (#39, #40, #41) Expose generic trading utilities in the stable downstream SDK so applications like mteor can drop local MT5 adapter code: - normalize_order_volume() for broker step/min/max sizing - estimate_order_margin() and calculate_positions_margin() for margin totals - fetch_latest_closed_rates_for_trading_client() for closed bars from Mt5TradingClient Update STABLE_SDK_EXPORTS, package-root exports, docs, and unit tests. Co-authored-by: Daichi Narushima * chore: bump version to 0.8.3 Co-authored-by: Daichi Narushima * fix: address PR review feedback on volume cap, rate time, and margin grouping - Re-apply volume_max after step normalization in normalize_order_volume() - Drop misleading non-time index reset branch in _ensure_rate_time_column() - Group positions by (symbol, side) before margin estimation - Add branch-coverage tests for tick price validation and volume cap edge case Co-authored-by: Daichi Narushima * fix: address remaining PR review threads on docs and DatetimeIndex - Rename unnamed DatetimeIndex column to time after reset_index() - Guard estimate_order_margin example on positive normalized volume - Document calculate_positions_margin skip vs error propagation behavior - Add test for unnamed DatetimeIndex branch coverage Co-authored-by: Daichi Narushima * fix: harden stable SDK margin, rate fetch, and volume normalization - Wrap order_calc_margin conversion and reject None/non-numeric results - Validate fetched rate objects are DataFrames before time normalization - Return 0.0 for non-finite volume inputs and constraints in normalize_order_volume Co-authored-by: Daichi Narushima * fix: reject non-finite volumes in margin estimation helpers Use _is_positive_finite_number() in estimate_order_margin() and calculate_positions_margin() so NaN/inf volumes never reach broker calls. Add focused tests and document non-finite volume skipping in trading.md. Co-authored-by: Daichi Narushima * fix: guard symbol filter in calculate_positions_margin for empty frames Return 0.0 before filtering when positions are empty or lack a symbol column. Add regression tests for filtered calls on malformed position frames. Co-authored-by: Daichi Narushima --------- Co-authored-by: Cursor Agent Co-authored-by: Daichi Narushima --- docs/api/public-contract.md | 2 + docs/api/sdk.md | 7 +- docs/api/trading.md | 29 ++ mt5cli/__init__.py | 8 + mt5cli/contract.py | 4 + mt5cli/trading.py | 207 ++++++++++- pyproject.toml | 2 +- tests/test_contracts.py | 42 +++ tests/test_trading.py | 699 ++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 996 insertions(+), 6 deletions(-) diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index bd8244d..0d16674 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -60,6 +60,7 @@ timestamp normalization in downstream apps. | ------------------------------------------------ | ------------------------------------------------------------ | | `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data | | `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar | +| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session | | `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` | @@ -96,6 +97,7 @@ strategy entries, exits, Kelly sizing, or signal logic. | `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 | | `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) | diff --git a/docs/api/sdk.md b/docs/api/sdk.md index 9621099..d051f87 100644 --- a/docs/api/sdk.md +++ b/docs/api/sdk.md @@ -31,9 +31,10 @@ 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 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 +row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; 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 oldest-to-newest and may contain fewer than `count` rows only when MT5 returns fewer closed bars. diff --git a/docs/api/trading.md b/docs/api/trading.md index 519181e..747ec77 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -40,15 +40,19 @@ betting logic, or scheduling code in downstream applications. ```python from mt5cli import ( + calculate_positions_margin, calculate_spread_ratio, calculate_margin_and_volume, close_open_positions, detect_position_side, determine_order_limits, + estimate_order_margin, + fetch_latest_closed_rates_for_trading_client, get_account_snapshot, get_positions_frame, get_symbol_snapshot, get_tick_snapshot, + normalize_order_volume, place_market_order, ) @@ -58,6 +62,22 @@ 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") +volume = normalize_order_volume( + 0.15, + volume_min=symbol["volume_min"], + volume_max=symbol["volume_max"], + volume_step=symbol["volume_step"], +) +buy_margin = ( + estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0 +) +open_margin = calculate_positions_margin(client, symbols=["EURUSD"]) +closed_bars = fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=100, +) sizing = calculate_margin_and_volume( client, "EURUSD", @@ -87,6 +107,12 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True) 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. +`normalize_order_volume()` returns `0.0` for invalid constraints or +sub-minimum requests; check the result before calling `estimate_order_margin()`, +which requires a positive finite volume. `calculate_positions_margin()` silently +skips rows with missing symbols, non-positive volumes, non-finite volumes, or +unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row +encounters invalid tick data or margin results from the broker. SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` omits that level. SL/TP prices are rounded with symbol `digits` metadata when @@ -153,6 +179,9 @@ through the stable package root without embedding entry/exit policy. | Manual terminal spawn/kill around trading code | `mt5_trading_session()` | | Local position-side detection | `detect_position_side()` | | Local margin/volume sizing | `calculate_margin_and_volume()` | +| Local broker volume step normalization | `normalize_order_volume()` | +| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` | +| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()` | | Local SL/TP price derivation | `determine_order_limits()` | | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` | diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index a7d82ba..bd66dd7 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -118,6 +118,7 @@ from .trading import ( PositionSide, calculate_margin_and_volume, calculate_new_position_margin_ratio, + calculate_positions_margin, calculate_spread_ratio, calculate_volume_by_margin, close_open_positions, @@ -125,11 +126,14 @@ from .trading import ( detect_position_side, determine_order_limits, ensure_symbol_selected, + estimate_order_margin, + fetch_latest_closed_rates_for_trading_client, get_account_snapshot, get_positions_frame, get_symbol_snapshot, get_tick_snapshot, mt5_trading_session, + normalize_order_volume, place_market_order, update_sltp_for_open_positions, ) @@ -182,6 +186,7 @@ __all__ = [ "build_rate_view_name", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", + "calculate_positions_margin", "calculate_spread_ratio", "calculate_volume_by_margin", "call_with_normalized_errors", @@ -204,9 +209,11 @@ __all__ = [ "drop_forming_rate_bar", "ensure_symbol_selected", "ensure_utc", + "estimate_order_margin", "export_dataframe", "export_dataframe_to_sqlite", "fetch_latest_closed_rates", + "fetch_latest_closed_rates_for_trading_client", "get_account_snapshot", "get_positions_frame", "get_symbol_snapshot", @@ -230,6 +237,7 @@ __all__ = [ "mt5_version", "normalize_dataframe", "normalize_mt5_exception", + "normalize_order_volume", "normalize_symbol", "normalize_symbols", "normalize_time_columns", diff --git a/mt5cli/contract.py b/mt5cli/contract.py index 34e4acc..6fb410f 100644 --- a/mt5cli/contract.py +++ b/mt5cli/contract.py @@ -30,6 +30,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "build_rate_view_name", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", + "calculate_positions_margin", "calculate_spread_ratio", "calculate_volume_by_margin", "call_with_normalized_errors", @@ -50,9 +51,11 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "determine_order_limits", "drop_forming_rate_bar", "ensure_symbol_selected", + "estimate_order_margin", "export_dataframe", "export_dataframe_to_sqlite", "fetch_latest_closed_rates", + "fetch_latest_closed_rates_for_trading_client", "get_account_snapshot", "get_positions_frame", "get_symbol_snapshot", @@ -74,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "mt5_trading_session", "mt5_version", "normalize_mt5_exception", + "normalize_order_volume", "orders", "place_market_order", "positions", diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 2604ce0..a286b42 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast import pandas as pd from pdmt5 import Mt5Config, 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 + from collections.abc import Iterator, Sequence PositionSide = Literal["long", "short"] OrderSide = Literal["BUY", "SELL"] @@ -125,6 +127,7 @@ __all__ = [ "PositionSide", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", + "calculate_positions_margin", "calculate_spread_ratio", "calculate_volume_by_margin", "close_open_positions", @@ -132,11 +135,14 @@ __all__ = [ "detect_position_side", "determine_order_limits", "ensure_symbol_selected", + "estimate_order_margin", + "fetch_latest_closed_rates_for_trading_client", "get_account_snapshot", "get_positions_frame", "get_symbol_snapshot", "get_tick_snapshot", "mt5_trading_session", + "normalize_order_volume", "place_market_order", "update_sltp_for_open_positions", ] @@ -276,6 +282,48 @@ def _normalize_order_side(side: str) -> OrderSide: raise ValueError(msg) +def _is_finite_number(value: object) -> bool: + return isinstance(value, int | float) and isfinite(value) + + +def _is_positive_finite_number(value: object) -> bool: + if not _is_finite_number(value): + return False + return float(cast("float | int", value)) > 0 + + +def normalize_order_volume( + volume: float, + *, + volume_min: float, + volume_max: float, + volume_step: float, +) -> float: + """Normalize a requested order volume to broker volume constraints. + + Returns: + Volume floored to the nearest valid broker step from ``volume_min``, + capped at ``volume_max`` when finite and positive, and rounded + deterministically. Returns ``0.0`` when inputs or constraints are + invalid, non-finite, or the capped request is below ``volume_min``. + """ + if not _is_finite_number(volume): + return 0.0 + if not _is_positive_finite_number(volume_min): + return 0.0 + if not _is_positive_finite_number(volume_step): + return 0.0 + has_volume_cap = _is_positive_finite_number(volume_max) + capped = min(volume, volume_max) if has_volume_cap else volume + if capped < volume_min: + return 0.0 + steps = floor(((capped - volume_min) / volume_step) + 1e-12) + normalized = volume_min + max(0, steps) * volume_step + if has_volume_cap: + normalized = min(normalized, volume_max) + return round(normalized, 10) + + def _position_side_from_order_side(side: str) -> PositionSide: normalized = side.lower() if normalized in {"long", "buy"}: @@ -531,6 +579,108 @@ def get_positions_frame( return frame +def _order_side_from_position_type( + client: Mt5TradingClient, + position_type: object, +) -> OrderSide | None: + if position_type == client.mt5.POSITION_TYPE_BUY: + return "BUY" + if position_type == client.mt5.POSITION_TYPE_SELL: + return "SELL" + return None + + +def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame: + if frame.empty or "time" in frame.columns: + return frame + if frame.index.name == "time" or isinstance(frame.index, pd.DatetimeIndex): + normalized = frame.reset_index() + if "time" not in normalized.columns and not normalized.empty: + normalized = normalized.rename(columns={normalized.columns[0]: "time"}) + return normalized + return frame + + +def estimate_order_margin( + client: Mt5TradingClient, + symbol: str, + order_side: OrderSide | str, + volume: float, +) -> float: + """Estimate required margin for one order at the current market price. + + Returns: + Positive finite margin required for the order at the current quote. + + Raises: + Mt5TradingError: If volume, tick data, or margin estimation is invalid. + """ + if not _is_positive_finite_number(volume): + msg = "Volume must be a positive finite number to estimate order margin." + raise Mt5TradingError(msg) + 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 or not isfinite(price): + 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 + ) + raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price)) + try: + margin = float(raw_margin) + except (TypeError, ValueError) as exc: + msg = f"Margin estimate is invalid for {symbol!r}." + raise Mt5TradingError(msg) from exc + if margin <= 0 or not isfinite(margin): + msg = f"Margin estimate is invalid for {symbol!r}." + raise Mt5TradingError(msg) + return margin + + +def calculate_positions_margin( + client: Mt5TradingClient, + *, + symbols: Sequence[str] | None = None, +) -> float: + """Return the sum of estimated current margin for open positions. + + Args: + client: Connected ``Mt5TradingClient`` instance. + symbols: Optional symbol filter. When omitted, all open positions are + included. + + Returns: + Total estimated margin, or ``0.0`` when no matching positions exist. + """ + frame = get_positions_frame(client) + if frame.empty or "symbol" not in frame.columns: + return 0.0 + if symbols is not None: + frame = frame[frame["symbol"].isin(list(symbols))] + if frame.empty: + return 0.0 + grouped_volumes: dict[tuple[str, OrderSide], float] = {} + for _, row in frame.iterrows(): + symbol = row.get("symbol") + if not isinstance(symbol, str) or not symbol: + continue + volume = row.get("volume") + if not _is_positive_finite_number(volume): + continue + order_side = _order_side_from_position_type(client, row.get("type")) + if order_side is None: + continue + key = (symbol, order_side) + finite_volume = float(cast("float | int", volume)) + grouped_volumes[key] = grouped_volumes.get(key, 0.0) + finite_volume + total = 0.0 + for (symbol, order_side), volume in grouped_volumes.items(): + total += estimate_order_margin(client, symbol, order_side, volume) + return total + + def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float: """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick. @@ -997,6 +1147,61 @@ def update_sltp_for_open_positions( return results +def fetch_latest_closed_rates_for_trading_client( + client: Mt5TradingClient, + *, + symbol: str, + granularity: str, + count: int, +) -> pd.DataFrame: + """Fetch the latest closed bars from a connected trading client. + + Returns: + Up to ``count`` closed bars ordered oldest to newest. + + Raises: + ValueError: If ``count`` is not positive, rate data is empty or + malformed, or the ``time`` column is missing. + Mt5TradingError: If the trading client cannot fetch rate data. + """ + if count <= 0: + 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 isinstance(fetched, pd.DataFrame): + msg = ( + f"Malformed rate data for {symbol!r} at granularity {granularity!r}: " + "expected a DataFrame." + ) + raise ValueError(msg) # noqa: TRY004 + frame = fetched + frame = _ensure_rate_time_column(frame) + if "time" not in frame.columns: + msg = f"Rate data is missing a time column for {symbol!r}." + raise ValueError(msg) + 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) + + @contextmanager def mt5_trading_session( config: Mt5Config | None = None, diff --git a/pyproject.toml b/pyproject.toml index 9cfbf68..096c4ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.8.2" +version = "0.8.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 282f69c..554e8da 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -34,6 +34,7 @@ from mt5cli import ( build_config, build_rate_targets, calculate_margin_and_volume, + calculate_positions_margin, call_with_normalized_errors, detect_format, drop_forming_rate_bar, @@ -42,6 +43,7 @@ from mt5cli import ( export_dataframe, export_dataframe_to_sqlite, fetch_latest_closed_rates, + fetch_latest_closed_rates_for_trading_client, granularity_name, is_recoverable_mt5_error, load_rate_data, @@ -50,6 +52,7 @@ from mt5cli import ( mt5_trading_session, normalize_dataframe, normalize_mt5_exception, + normalize_order_volume, normalize_symbol, normalize_symbols, parse_date_range, @@ -572,6 +575,45 @@ class TestStableSdkContract: client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0) assert list(result["close"]) == [1.0, 1.1] + def test_fetch_latest_closed_rates_for_trading_client_from_package_root( + self, + ) -> None: + """Trading-client closed-bar helper is importable from the stable surface.""" + client = MagicMock() + client.fetch_latest_rates_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, + ) + + assert list(result["close"]) == [1.0, 1.1] + + def test_normalize_order_volume_from_package_root(self) -> None: + """Volume normalization helper is importable from the stable surface.""" + result = normalize_order_volume( + 0.25, + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ) + assert abs(result - 0.2) < 1e-9 + + def test_calculate_positions_margin_from_package_root(self) -> None: + """Position margin helper is importable from the stable surface.""" + 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.positions_get_as_df.return_value = pd.DataFrame() + + assert calculate_positions_margin(client) == 0 + 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 06d47dc..aa77b19 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -19,6 +19,7 @@ from mt5cli.trading import ( OrderLimits, calculate_margin_and_volume, calculate_new_position_margin_ratio, + calculate_positions_margin, calculate_spread_ratio, calculate_volume_by_margin, close_open_positions, @@ -26,11 +27,14 @@ from mt5cli.trading import ( detect_position_side, determine_order_limits, ensure_symbol_selected, + estimate_order_margin, + fetch_latest_closed_rates_for_trading_client, get_account_snapshot, get_positions_frame, get_symbol_snapshot, get_tick_snapshot, mt5_trading_session, + normalize_order_volume, place_market_order, update_sltp_for_open_positions, ) @@ -773,6 +777,474 @@ class TestSnapshotsAndState: calculate_spread_ratio(client, "EURUSD") +class TestNormalizeOrderVolume: + """Tests for normalize_order_volume.""" + + def test_returns_exact_minimum_volume(self) -> None: + """Test exact volume_min is returned unchanged.""" + _assert_close( + normalize_order_volume( + 0.1, + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ), + 0.1, + ) + + def test_floors_to_step_between_boundaries(self) -> None: + """Test volume between steps floors down to the nearest valid step.""" + _assert_close( + normalize_order_volume( + 0.25, + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ), + 0.2, + ) + + def test_clamps_to_volume_max(self) -> None: + """Test positive volume_max caps the normalized result.""" + _assert_close( + normalize_order_volume( + 0.9, + volume_min=0.1, + volume_max=0.5, + volume_step=0.1, + ), + 0.5, + ) + + def test_returns_zero_below_volume_min(self) -> None: + """Test sub-minimum requests return zero volume.""" + _assert_close( + normalize_order_volume( + 0.05, + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ), + 0.0, + ) + + def test_returns_zero_for_invalid_volume_min(self) -> None: + """Test non-positive volume_min returns zero volume.""" + _assert_close( + normalize_order_volume( + 1.0, + volume_min=0.0, + volume_max=1.0, + volume_step=0.1, + ), + 0.0, + ) + + def test_returns_zero_for_invalid_volume_step(self) -> None: + """Test non-positive volume_step returns zero volume.""" + _assert_close( + normalize_order_volume( + 1.0, + volume_min=0.1, + volume_max=1.0, + volume_step=0.0, + ), + 0.0, + ) + + def test_treats_non_positive_volume_max_as_no_cap(self) -> None: + """Test volume_max <= 0 disables the maximum cap.""" + _assert_close( + normalize_order_volume( + 2.5, + volume_min=0.1, + volume_max=0.0, + volume_step=0.1, + ), + 2.5, + ) + + def test_reapplies_volume_max_after_step_normalization(self) -> None: + """Test post-step normalization cannot exceed volume_max.""" + _assert_close( + normalize_order_volume( + 0.5, + volume_min=0.1, + volume_max=0.34, + volume_step=0.12, + ), + 0.34, + ) + + def test_returns_zero_for_non_finite_volume(self) -> None: + """Test NaN or infinite requested volume returns zero.""" + _assert_close( + normalize_order_volume( + float("nan"), + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ), + 0.0, + ) + _assert_close( + normalize_order_volume( + float("inf"), + volume_min=0.1, + volume_max=1.0, + volume_step=0.1, + ), + 0.0, + ) + + def test_returns_zero_for_non_finite_constraints(self) -> None: + """Test NaN or infinite volume_min/volume_step returns zero.""" + _assert_close( + normalize_order_volume( + 1.0, + volume_min=float("nan"), + volume_max=1.0, + volume_step=0.1, + ), + 0.0, + ) + _assert_close( + normalize_order_volume( + 1.0, + volume_min=0.1, + volume_max=1.0, + volume_step=float("inf"), + ), + 0.0, + ) + + def test_treats_non_finite_volume_max_as_no_cap(self) -> None: + """Test non-finite volume_max disables the maximum cap.""" + _assert_close( + normalize_order_volume( + 2.5, + volume_min=0.1, + volume_max=float("nan"), + volume_step=0.1, + ), + 2.5, + ) + + +class TestEstimateOrderMargin: + """Tests for estimate_order_margin.""" + + def test_estimates_buy_margin_at_ask(self) -> None: + """Test buy margin uses ask price and buy order type.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 12.5 + + margin = estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + _assert_close(margin, 12.5) + client.order_calc_margin.assert_called_once_with(10, "EURUSD", 0.1, 1.1010) + + def test_estimates_sell_margin_at_bid(self) -> None: + """Test sell margin uses bid price and sell order type.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 12.4 + + margin = estimate_order_margin(client, "EURUSD", "SELL", 0.1) + + _assert_close(margin, 12.4) + client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1000) + + def test_accepts_long_and_short_aliases(self) -> None: + """Test long/short aliases normalize to buy/sell pricing.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.side_effect = [12.5, 12.4] + + estimate_order_margin(client, "EURUSD", "long", 0.1) + estimate_order_margin(client, "EURUSD", "short", 0.1) + + client.order_calc_margin.assert_any_call(10, "EURUSD", 0.1, 1.1010) + client.order_calc_margin.assert_any_call(11, "EURUSD", 0.1, 1.1000) + + def test_rejects_invalid_side(self) -> None: + """Test unsupported order side raises ValueError.""" + client = _mock_trade_client() + + with pytest.raises(ValueError, match="Unsupported order side"): + estimate_order_margin(client, "EURUSD", "HOLD", 0.1) + + def test_rejects_non_positive_volume(self) -> None: + """Test non-positive volume raises Mt5TradingError.""" + client = _mock_trade_client() + + with pytest.raises(Mt5TradingError, match="positive finite number"): + estimate_order_margin(client, "EURUSD", "BUY", 0.0) + + def test_rejects_nan_volume(self) -> None: + """Test NaN volume raises Mt5TradingError without broker calls.""" + client = _mock_trade_client() + + with pytest.raises(Mt5TradingError, match="positive finite number"): + estimate_order_margin(client, "EURUSD", "BUY", float("nan")) + + client.symbol_info_tick_as_dict.assert_not_called() + client.order_calc_margin.assert_not_called() + + def test_rejects_infinite_volume(self) -> None: + """Test infinite volume raises Mt5TradingError without broker calls.""" + client = _mock_trade_client() + + with pytest.raises(Mt5TradingError, match="positive finite number"): + estimate_order_margin(client, "EURUSD", "BUY", float("inf")) + + client.symbol_info_tick_as_dict.assert_not_called() + client.order_calc_margin.assert_not_called() + + def test_rejects_missing_tick_prices(self) -> None: + """Test missing tick prices raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000} + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_non_positive_tick_price(self) -> None: + """Test non-positive tick prices raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000} + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_non_finite_tick_price(self) -> None: + """Test non-finite tick prices raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = { + "ask": float("inf"), + "bid": 1.1000, + } + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_invalid_margin_result(self) -> None: + """Test non-positive margin estimates raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 0.0 + + with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_non_finite_margin_result(self) -> None: + """Test non-finite margin estimates raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = float("inf") + + with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_none_margin_result(self) -> None: + """Test None margin results raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = None + + with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + def test_rejects_non_numeric_margin_result(self) -> None: + """Test non-numeric margin results raise Mt5TradingError.""" + client = _mock_trade_client() + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = "invalid" + + with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"): + estimate_order_margin(client, "EURUSD", "BUY", 0.1) + + +class TestCalculatePositionsMargin: + """Tests for calculate_positions_margin.""" + + def test_returns_zero_for_empty_positions(self) -> None: + """Test empty positions return zero total margin.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame() + + _assert_close(calculate_positions_margin(client), 0.0) + + def test_filters_by_symbols(self) -> None: + """Test optional symbol filter limits summed positions.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"symbol": "USDJPY", "type": 1, "volume": 0.2}, + ], + ) + client.symbol_info_tick_as_dict.side_effect = [ + {"ask": 1.1010, "bid": 1.1000}, + {"ask": 110.0, "bid": 109.0}, + ] + client.order_calc_margin.side_effect = [12.5, 20.0] + + margin = calculate_positions_margin(client, symbols=["EURUSD"]) + + _assert_close(margin, 12.5) + assert client.order_calc_margin.call_count == 1 + + def test_sums_mixed_buy_and_sell_positions(self) -> None: + """Test mixed buy/sell exposure sums each side independently.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"symbol": "EURUSD", "type": 1, "volume": 0.2}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.side_effect = [12.5, 24.8] + + margin = calculate_positions_margin(client) + + _assert_close(margin, 37.3) + + def test_groups_positions_by_symbol_and_side(self) -> None: + """Test repeated symbol/side pairs use one margin call with summed volume.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"symbol": "EURUSD", "type": 0, "volume": 0.2}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 37.5 + + margin = calculate_positions_margin(client) + + _assert_close(margin, 37.5) + client.order_calc_margin.assert_called_once() + args = client.order_calc_margin.call_args[0] + assert args[0] == 10 + assert args[1] == "EURUSD" + _assert_close(args[2], 0.3) + _assert_close(args[3], 1.1010) + + def test_sums_multiple_symbols(self) -> None: + """Test positions across symbols are all included.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + {"symbol": "GBPUSD", "type": 1, "volume": 0.3}, + ], + ) + client.symbol_info_tick_as_dict.side_effect = [ + {"ask": 1.1010, "bid": 1.1000}, + {"ask": 1.3010, "bid": 1.3000}, + ] + client.order_calc_margin.side_effect = [12.5, 30.0] + + margin = calculate_positions_margin(client) + + _assert_close(margin, 42.5) + + def test_propagates_invalid_tick_or_margin_errors(self) -> None: + """Test invalid tick or margin data raises Mt5TradingError.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"symbol": "EURUSD", "type": 0, "volume": 0.1}], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000} + + with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): + calculate_positions_margin(client) + + def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None: + """Test malformed position rows are ignored when summing margin.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "", "type": 0, "volume": 0.1}, + {"symbol": "EURUSD", "type": 0, "volume": 0.0}, + {"symbol": "EURUSD", "type": 2, "volume": 0.1}, + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 12.5 + + margin = calculate_positions_margin(client) + + _assert_close(margin, 12.5) + client.order_calc_margin.assert_called_once() + + def test_skips_rows_with_non_finite_volume(self) -> None: + """Test NaN and infinite position volumes are ignored.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": float("nan")}, + {"symbol": "EURUSD", "type": 0, "volume": float("inf")}, + {"symbol": "EURUSD", "type": 0, "volume": 0.1}, + ], + ) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000} + client.order_calc_margin.return_value = 12.5 + + margin = calculate_positions_margin(client) + + _assert_close(margin, 12.5) + client.order_calc_margin.assert_called_once() + + def test_returns_zero_when_all_volumes_are_non_finite(self) -> None: + """Test all-invalid non-finite volumes return zero without broker calls.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [ + {"symbol": "EURUSD", "type": 0, "volume": float("nan")}, + {"symbol": "EURUSD", "type": 1, "volume": float("inf")}, + ], + ) + + _assert_close(calculate_positions_margin(client), 0.0) + client.order_calc_margin.assert_not_called() + client.symbol_info_tick_as_dict.assert_not_called() + + def test_returns_zero_when_symbol_filter_matches_nothing(self) -> None: + """Test filtered symbol lists with no matches return zero.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"symbol": "EURUSD", "type": 0, "volume": 0.1}], + ) + + _assert_close(calculate_positions_margin(client, symbols=["GBPUSD"]), 0.0) + client.order_calc_margin.assert_not_called() + + def test_returns_zero_for_empty_positions_with_symbol_filter(self) -> None: + """Test empty positions with a symbol filter return zero.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame() + + _assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0) + + def test_returns_zero_for_positions_without_symbol_column_with_symbol_filter( + self, + ) -> None: + """Test positions missing a symbol column return zero when filtered.""" + client = _mock_trade_client() + client.positions_get_as_df.return_value = pd.DataFrame( + [{"type": 0, "volume": 0.1}], + ) + + _assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0) + client.order_calc_margin.assert_not_called() + + class TestVolumeAndExecution: """Tests for order planning and execution helpers.""" @@ -1928,3 +2400,230 @@ class TestVolumeAndExecution: raise RuntimeError(body_error) mock_client.shutdown.assert_called_once() + + +class TestFetchLatestClosedRatesForTradingClient: + """Tests for fetch_latest_closed_rates_for_trading_client.""" + + def test_fetches_extra_bar_and_drops_forming_row(self) -> None: + """Test trading-client helper hides the forming bar.""" + client = MagicMock() + client.fetch_latest_rates_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.fetch_latest_rates_as_df.assert_called_once_with("EURUSD", "M1", 3) + 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() + client.fetch_latest_rates_as_df.return_value = pd.DataFrame( + { + "time": [1700000000, 1700000060, 1700000120], + "close": [1.0, 1.1, 1.2], + }, + ) + + result = fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + assert list(result["time"]) == [1700000000, 1700000060] + + def test_accepts_timezone_aware_timestamps_from_index(self) -> None: + """Test timezone-aware timestamps in the index are exposed as a column.""" + client = MagicMock() + frame = pd.DataFrame( + { + "close": [1.0, 1.1, 1.2], + }, + index=pd.to_datetime( + [ + "2024-01-01T00:00:00Z", + "2024-01-01T00:01:00Z", + "2024-01-01T00:02:00Z", + ], + utc=True, + ), + ) + frame.index.name = "time" + client.fetch_latest_rates_as_df.return_value = frame + + result = fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + assert "time" in result.columns + assert len(result) == 2 + assert result["close"].tolist() == [1.0, 1.1] + + def test_accepts_unnamed_datetime_index(self) -> None: + """Test unnamed DatetimeIndex values are exposed as a time column.""" + client = MagicMock() + frame = pd.DataFrame( + {"close": [1.0, 1.1, 1.2]}, + index=pd.to_datetime( + [ + "2024-01-01T00:00:00Z", + "2024-01-01T00:01:00Z", + "2024-01-01T00:02:00Z", + ], + utc=True, + ), + ) + client.fetch_latest_rates_as_df.return_value = frame + + result = fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + assert "time" in result.columns + assert len(result) == 2 + + def test_accepts_named_non_time_index(self) -> None: + """Test non-time named indexes are left unchanged before validation.""" + client = MagicMock() + frame = pd.DataFrame( + { + "close": [1.0, 1.1, 1.2], + "bar_id": [1, 2, 3], + }, + ).set_index("bar_id") + client.fetch_latest_rates_as_df.return_value = frame + + with pytest.raises(ValueError, match="missing a time column"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + def test_raises_when_trading_client_cannot_fetch_rates(self) -> None: + """Test missing rate-fetch methods raise Mt5TradingError.""" + client = MagicMock(spec=[]) + + with pytest.raises(Mt5TradingError, match="cannot fetch rate data"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=1, + ) + + def test_raises_when_time_column_is_missing(self) -> None: + """Test malformed rate data without time raises ValueError.""" + client = MagicMock() + client.fetch_latest_rates_as_df.return_value = pd.DataFrame( + {"close": [1.0, 1.1, 1.2]}, + ) + + with pytest.raises(ValueError, match="missing a time column"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + def test_raises_when_no_closed_bars_are_available(self) -> None: + """Test empty closed-bar results raise an actionable ValueError.""" + client = MagicMock() + client.fetch_latest_rates_as_df.return_value = pd.DataFrame( + {"time": [1], "close": [1.0]}, + ) + + with pytest.raises(ValueError, match="Rate data is empty"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=1, + ) + + def test_raises_when_fetch_returns_none(self) -> None: + """Test None fetch results raise a malformed rate data error.""" + client = MagicMock() + client.fetch_latest_rates_as_df.return_value = None + + with pytest.raises(ValueError, match="Malformed rate data"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + def test_raises_when_fetch_returns_non_dataframe(self) -> None: + """Test non-DataFrame fetch results raise a malformed rate data error.""" + client = MagicMock() + client.fetch_latest_rates_as_df.return_value = [{"time": 1, "close": 1.0}] + + with pytest.raises(ValueError, match="Malformed rate data"): + fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + 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_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=0, + ) + + client.fetch_latest_rates_as_df.assert_not_called() diff --git a/uv.lock b/uv.lock index 17604fb..fddd2f6 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.8.2" +version = "0.8.3" source = { editable = "." } dependencies = [ { name = "click" },