diff --git a/README.md b/README.md index b09f281..2f8ec4b 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,13 @@ python -m mt5cli -o account.csv account-info | `recent-history-deals` | Export historical deals from a recent trailing window | | `mt5-summary` | Export terminal/account status summary | | `order-check` | Check funds sufficiency for a trade request | -| `order-send` | Send a trade request to the trade server (`--yes` required) | +| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) | +| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) | | `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database | Use `order-check` to validate a request payload before running `order-send --yes`. +`close-positions` is the safer high-level alternative that builds correct close +requests automatically. At least one `--symbol` or `--ticket` must be provided. ### `collect-history` diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index a0840a6..1922abf 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -111,6 +111,14 @@ strategy entries, exits, Kelly sizing, or signal logic. | `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) | | `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | | `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | +| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection | + +`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode` +parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to +subtract current exposure for `new_symbol` before adding the candidate margin — +useful for reversal-style projections. mt5cli only calculates broker-facing +exposure; downstream applications own thresholds, risk guard actions, and +strategy policy. `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. @@ -182,7 +190,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in - Delegate to the same Python APIs described here; they are not duplicated business logic. -`order-send` requires `--yes` before placing live trades. +`order-send` is the expert raw-request path; it requires `--yes` and a fully +constructed request payload. `close-positions` is the safer high-level helper +that closes open positions by `--symbol` or `--ticket` using +`close_open_positions()`. Both `order-send --yes` and `close-positions --yes` +are live execution paths. `close-positions --dry-run` previews close orders +without placing them and does not require `--yes`. ## Internal helpers (not stable) diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index ce184c6..70829a7 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -120,6 +120,7 @@ from .trading import ( OrderSide, OrderTimeMode, PositionSide, + ProjectionMode, calculate_account_projected_margin_ratio, calculate_margin_and_volume, calculate_new_position_margin_ratio, @@ -192,6 +193,7 @@ __all__ = [ "OrderSide", "OrderTimeMode", "PositionSide", + "ProjectionMode", "RateTarget", "ThrottledHistoryUpdater", "account_info", diff --git a/mt5cli/cli.py b/mt5cli/cli.py index adf0db0..51d36ea 100644 --- a/mt5cli/cli.py +++ b/mt5cli/cli.py @@ -2,17 +2,20 @@ from __future__ import annotations +import json import logging from dataclasses import dataclass from datetime import datetime # noqa: TC003 from pathlib import Path # noqa: TC003 from typing import TYPE_CHECKING, Annotated, Any, cast +import pandas as pd import typer from pdmt5 import Mt5Config from . import sdk from .client import MT5Client +from .trading import OrderExecutionResult, close_open_positions, create_trading_client from .utils import ( DATETIME_TYPE, REQUEST_TYPE, @@ -29,8 +32,6 @@ from .utils import ( if TYPE_CHECKING: from collections.abc import Callable - import pandas as pd - logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -600,6 +601,96 @@ def order_send( _export_command(ctx, lambda client: client.order_send(request)) +_EXECUTION_RESULT_COLUMNS: list[str] = [ + "status", + "symbol", + "order_side", + "volume", + "retcode", + "comment", + "request", + "response", + "dry_run", +] + + +def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame: + if not results: + return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS) + rows = [ + { + **r, + "request": json.dumps(r["request"]), + "response": json.dumps(r["response"]), + } + for r in results + ] + return pd.DataFrame(rows) + + +@app.command() +def close_positions( + ctx: typer.Context, + symbol: Annotated[ + list[str] | None, + typer.Option( + "--symbol", + "-s", + help="Symbol to close (repeat for multiple symbols).", + ), + ] = None, + ticket: Annotated[ + list[int] | None, + typer.Option( + "--ticket", + "-t", + help="Position ticket to close (repeat for multiple tickets).", + ), + ] = None, + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Preview close orders without executing them."), + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", help="Confirm live position closing."), + ] = False, +) -> None: + """Close open positions by symbol or ticket. + + Delegates to :func:`mt5cli.trading.close_open_positions`. At least one + ``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing + all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is + required for live execution. + + ``order-send`` is the expert raw-request path. ``close-positions`` is the + safer high-level helper that builds correct close requests automatically. + + Raises: + typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given, + or if ``--yes`` is missing for a live (non-dry-run) run. + """ + if not symbol and not ticket: + msg = "Provide at least one --symbol or --ticket to close positions." + raise typer.BadParameter(msg) + if not dry_run and not yes: + msg = "Pass --yes to close live positions." + raise typer.BadParameter(msg, param_hint="--yes") + export_ctx = _get_export_context(ctx) + client = create_trading_client(config=export_ctx.config) + try: + results = close_open_positions( + client, + symbols=list(symbol) if symbol else None, + tickets=list(ticket) if ticket else None, + dry_run=dry_run, + ) + finally: + client.shutdown() + df = _execution_results_to_df(results) + _execute_export(ctx, lambda: df) + + @app.command() def collect_history( ctx: typer.Context, diff --git a/mt5cli/contract.py b/mt5cli/contract.py index 2915183..447e23d 100644 --- a/mt5cli/contract.py +++ b/mt5cli/contract.py @@ -17,6 +17,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "OrderSide", "OrderTimeMode", "PositionSide", + "ProjectionMode", "ExecutionStatus", "MarginVolume", "OrderExecutionResult", diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 9d3ac6e..39f402e 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -25,6 +25,7 @@ OrderSide = Literal["BUY", "SELL"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"] OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"] +ProjectionMode = Literal["add", "replace_symbol"] class MarginVolume(TypedDict): @@ -127,6 +128,7 @@ __all__ = [ "OrderSide", "OrderTimeMode", "PositionSide", + "ProjectionMode", "calculate_account_projected_margin_ratio", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", @@ -920,6 +922,16 @@ def calculate_projected_margin_ratio( return margin / equity +def _validate_projection_mode(projection_mode: str) -> ProjectionMode: + if projection_mode not in {"add", "replace_symbol"}: + msg = ( + f"Unsupported projection mode: {projection_mode!r}. " + "Expected 'add' or 'replace_symbol'." + ) + raise ValueError(msg) + return cast("ProjectionMode", projection_mode) + + def calculate_symbol_group_margin_ratio( client: Mt5TradingClient, *, @@ -928,13 +940,22 @@ def calculate_symbol_group_margin_ratio( new_position_side: OrderSide | None = None, new_position_volume: float = 0.0, suppress_errors: bool = True, + projection_mode: ProjectionMode = "add", ) -> float: """Return estimated symbol-group margin over account equity. Per-symbol current exposure is summed with :func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside - the input symbol group, optional projected order margin is added for that - symbol. Invalid equity always raises to fail closed. + the input symbol group and candidate side/volume are provided, projected order + margin is applied according to ``projection_mode``: + + - ``"add"`` (default): adds candidate margin to the group total. + - ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then + adds candidate margin. Useful for reversal-style projections where the new + order is intended to replace existing exposure for that symbol. + + If the candidate margin estimation fails, the subtraction is also skipped so + the operation is atomic. Invalid equity always raises to fail closed. Raises: AttributeError: When symbol margin lookup or projected margin lookup @@ -945,23 +966,22 @@ def calculate_symbol_group_margin_ratio( lookup or projected margin lookup fails and ``suppress_errors`` is ``False``. """ + projection_mode = _validate_projection_mode(projection_mode) equity = _account_equity(client) unique_symbols = list(dict.fromkeys(symbols)) - margin = sum( - calculate_positions_margin_by_symbol( - client, - symbols=unique_symbols, - suppress_errors=suppress_errors, - ).values(), - 0.0, + per_symbol = calculate_positions_margin_by_symbol( + client, + symbols=unique_symbols, + suppress_errors=suppress_errors, ) + margin = sum(per_symbol.values(), 0.0) if ( new_symbol in unique_symbols and new_position_side is not None and new_position_volume > 0 ): try: - margin += estimate_order_margin( + candidate_margin = estimate_order_margin( client, new_symbol, new_position_side, @@ -971,6 +991,10 @@ def calculate_symbol_group_margin_ratio( if not suppress_errors: raise _logger.warning("Skipping projected margin for %r.", new_symbol) + else: + if projection_mode == "replace_symbol": + margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0)) + margin += candidate_margin return margin / equity diff --git a/pyproject.toml b/pyproject.toml index b305dc8..6e04326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.9.5" +version = "0.9.6" 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_cli.py b/tests/test_cli.py index 4909a69..ad015dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -740,6 +740,306 @@ class TestCommands: assert "must be a JSON object" in normalize_cli_output(result.output) +# --------------------------------------------------------------------------- +# close-positions command +# --------------------------------------------------------------------------- + + +def _build_mock_trading_client() -> MagicMock: + """Return a MagicMock Mt5TradingClient with trading constants set.""" + 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.ORDER_FILLING_IOC = 30 + client.mt5.ORDER_TIME_GTC = 40 + client.mt5.TRADE_RETCODE_DONE = 10009 + client.mt5.TRADE_RETCODE_PLACED = 10008 + client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010 + return client + + +class TestClosePositions: + """Tests for the close-positions command.""" + + @pytest.fixture + def trading_client(self, mocker: MockerFixture) -> MagicMock: + """Patch create_trading_client and return a mock trading client.""" + client = _build_mock_trading_client() + client.positions_get_as_df.return_value = pd.DataFrame([ + {"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0}, + {"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5}, + ]) + client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} + mocker.patch("mt5cli.cli.create_trading_client", return_value=client) + return client + + def test_dry_run_does_not_require_yes( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test --dry-run mode succeeds without --yes.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"], + ) + assert result.exit_code == 0, result.output + assert output.exists() + trading_client.order_send.assert_not_called() + trading_client.shutdown.assert_called_once() + + def test_live_requires_yes( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test live close-positions fails without --yes.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--symbol", "JP225"], + ) + assert result.exit_code != 0 + assert "Pass --yes" in normalize_cli_output(result.output) + trading_client.order_send.assert_not_called() + + def test_live_with_yes_calls_order_send( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test --yes triggers live execution for matching positions.""" + trading_client.order_send.return_value = {"retcode": 10009, "comment": "ok"} + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--symbol", "JP225", "--yes"], + ) + assert result.exit_code == 0, result.output + trading_client.order_send.assert_called_once() + trading_client.shutdown.assert_called_once() + + def test_symbol_filter_passed_through( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test --symbol values are used to filter positions.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--symbol", + "JP225", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(output.read_text()) + assert len(data) == 1 + assert data[0]["symbol"] == "JP225" + trading_client.shutdown.assert_called_once() + + def test_multiple_symbols_filter( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test multiple --symbol options are combined.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--symbol", + "JP225", + "--symbol", + "EURUSD", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(output.read_text()) + assert len(data) == 2 + symbols = {row["symbol"] for row in data} + assert symbols == {"JP225", "EURUSD"} + trading_client.shutdown.assert_called_once() + + def test_ticket_filter_passed_through( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test --ticket values are used to filter positions.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--ticket", + "2", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(output.read_text()) + assert len(data) == 1 + assert data[0]["symbol"] == "EURUSD" + trading_client.shutdown.assert_called_once() + + def test_symbol_and_ticket_combined( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test --symbol and --ticket apply AND semantics when combined.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--symbol", + "JP225", + "--ticket", + "1", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(output.read_text()) + # symbol=JP225 AND ticket=1 → exactly one match + assert len(data) == 1 + assert data[0]["symbol"] == "JP225" + trading_client.shutdown.assert_called_once() + + def test_missing_symbol_and_ticket_fails( + self, + tmp_path: Path, + mocker: MockerFixture, + ) -> None: + """Test that omitting both --symbol and --ticket fails closed.""" + mocker.patch("mt5cli.cli.create_trading_client") + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--dry-run"], + ) + assert result.exit_code != 0 + assert "symbol" in normalize_cli_output(result.output).lower() + + def test_output_export_dry_run( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test dry-run results export with status=dry_run.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"], + ) + assert result.exit_code == 0, result.output + trading_client.shutdown.assert_called_once() + data = json.loads(output.read_text()) + assert data[0]["status"] == "dry_run" + assert data[0]["dry_run"] is True + assert data[0]["order_side"] == "SELL" + + def test_order_send_unchanged( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test that order-send behavior is unchanged by close-positions addition.""" + output = tmp_path / "out.csv" + request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1}) + result = runner.invoke( + app, + ["-o", str(output), "order-send", "--request", request, "--yes"], + ) + assert result.exit_code == 0, result.output + mock_client.order_send_as_df.assert_called_once() + + def test_shutdown_called_on_close_error( + self, + tmp_path: Path, + mocker: MockerFixture, + ) -> None: + """Test that shutdown is called even when close_open_positions raises.""" + client = _build_mock_trading_client() + client.positions_get_as_df.side_effect = RuntimeError("connection lost") + mocker.patch("mt5cli.cli.create_trading_client", return_value=client) + output = tmp_path / "close.json" + result = runner.invoke( + app, + ["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"], + ) + assert result.exit_code != 0 + client.shutdown.assert_called_once() + + def test_dry_run_wins_over_yes( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test that --dry-run takes precedence when combined with --yes.""" + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--symbol", + "JP225", + "--dry-run", + "--yes", + ], + ) + assert result.exit_code == 0, result.output + trading_client.order_send.assert_not_called() + trading_client.shutdown.assert_called_once() + + def test_no_matching_positions_exports_empty_result( + self, + tmp_path: Path, + trading_client: MagicMock, + ) -> None: + """Test that zero filter matches produces an empty JSON array.""" + trading_client.positions_get_as_df.return_value = pd.DataFrame([ + {"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0}, + ]) + output = tmp_path / "close.json" + result = runner.invoke( + app, + [ + "-o", + str(output), + "close-positions", + "--symbol", + "NONEXISTENT", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + trading_client.shutdown.assert_called_once() + assert output.exists() + assert json.loads(output.read_text()) == [] + + # --------------------------------------------------------------------------- # Callback / shared options # --------------------------------------------------------------------------- diff --git a/tests/test_trading.py b/tests/test_trading.py index e48ac34..9f26b01 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from types import SimpleNamespace -from typing import Any, cast +from typing import Any, cast, get_args from unittest.mock import MagicMock import pandas as pd @@ -19,6 +19,7 @@ from mt5cli.trading import ( MarginVolume, OrderExecutionResult, OrderLimits, + ProjectionMode, calculate_account_projected_margin_ratio, calculate_margin_and_volume, calculate_new_position_margin_ratio, @@ -2065,6 +2066,202 @@ class TestVolumeAndExecution: suppress_errors=False, ) + def test_symbol_group_margin_ratio_replace_symbol_subtracts_and_adds( + self, + mocker: MockerFixture, + ) -> None: + """Test replace_symbol mode subtracts current exposure and adds candidate.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 15.0 + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 25.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + projection_mode="replace_symbol", + ) + + # margin = 25.0 - 25.0 (replaced) + 15.0 (candidate) = 15.0 + _assert_close(result, 0.015) + + def test_symbol_group_margin_ratio_replace_no_existing_adds_candidate( + self, + mocker: MockerFixture, + ) -> None: + """Test replace_symbol with no existing current margin still adds candidate.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1} + client.order_calc_margin.return_value = 20.0 + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 0.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.2, + projection_mode="replace_symbol", + ) + + # margin = 0.0 - 0.0 + 20.0 = 20.0 + _assert_close(result, 0.02) + + def test_symbol_group_margin_ratio_replace_symbol_outside_group_unchanged( + self, + mocker: MockerFixture, + ) -> None: + """Test candidate symbol outside the group does not affect margin.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 30.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="GBPUSD", + new_position_side="BUY", + new_position_volume=0.1, + projection_mode="replace_symbol", + ) + + # GBPUSD is not in the group; no candidate margin applied + _assert_close(result, 0.03) + + def test_symbol_group_margin_ratio_replace_no_candidate_returns_current( + self, + mocker: MockerFixture, + ) -> None: + """Test replace_symbol with no candidate side/volume returns current margin.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 40.0}, + ) + + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + projection_mode="replace_symbol", + ) + + _assert_close(result, 0.04) + + def test_symbol_group_margin_ratio_replace_suppresses_candidate_failure( + self, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Test replace mode skips both subtract and add when estimation fails.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 25.0}, + ) + mocker.patch( + "mt5cli.trading.estimate_order_margin", + side_effect=Mt5TradingError("bad tick"), + ) + + with caplog.at_level(logging.WARNING, logger="mt5cli.trading"): + result = calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + projection_mode="replace_symbol", + suppress_errors=True, + ) + + # When candidate fails, neither subtraction nor addition is applied + _assert_close(result, 0.025) + assert "Skipping projected margin" in caplog.text + + def test_symbol_group_margin_ratio_replace_reraises_candidate_failure( + self, + mocker: MockerFixture, + ) -> None: + """Test replace mode re-raises candidate failure when suppress_errors=False.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 1000.0} + mocker.patch( + "mt5cli.trading.calculate_positions_margin_by_symbol", + return_value={"EURUSD": 25.0}, + ) + mocker.patch( + "mt5cli.trading.estimate_order_margin", + side_effect=Mt5TradingError("bad tick"), + ) + + with pytest.raises(Mt5TradingError, match="bad tick"): + calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + new_symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + projection_mode="replace_symbol", + suppress_errors=False, + ) + + def test_projection_mode_type_alias_is_importable(self) -> None: + """Test ProjectionMode type alias is importable and has expected values.""" + assert ProjectionMode is not None + args = get_args(ProjectionMode) + assert "add" in args + assert "replace_symbol" in args + + def test_invalid_projection_mode_raises_value_error(self) -> None: + """Test that an unsupported projection_mode raises ValueError.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 10000.0} + client.positions_get_as_df.return_value = pd.DataFrame( + columns=["symbol", "type", "volume"] + ) + with pytest.raises(ValueError, match="Unsupported projection mode"): + calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + projection_mode="invalid", # type: ignore[arg-type] + ) + + def test_invalid_projection_mode_message_includes_value_and_accepted( + self, + ) -> None: + """Test ValueError message contains the bad value and accepted modes.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = {"equity": 10000.0} + client.positions_get_as_df.return_value = pd.DataFrame( + columns=["symbol", "type", "volume"] + ) + with pytest.raises(ValueError, match="'unknown'") as exc_info: + calculate_symbol_group_margin_ratio( + client, + symbols=["EURUSD"], + projection_mode="unknown", # type: ignore[arg-type] + ) + msg = str(exc_info.value) + assert "add" in msg + assert "replace_symbol" in msg + 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() diff --git a/uv.lock b/uv.lock index e1d1247..17152b0 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.9.5" +version = "0.9.6" source = { editable = "." } dependencies = [ { name = "click" },