From 96c75f7852f6568c3920453155eddf490964fc54 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:43:52 +0900 Subject: [PATCH] Add account-wide projected margin ratio helper (#60) * feat: add account projected margin ratio helper * Bump version to v0.9.4 * fix: address account margin ratio review feedback * fix: simplify account margin ratio errors --- docs/api/public-contract.md | 37 +++++----- mt5cli/__init__.py | 2 + mt5cli/contract.py | 1 + mt5cli/trading.py | 62 +++++++++++++--- pyproject.toml | 2 +- tests/test_contracts.py | 2 + tests/test_trading.py | 137 ++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 217 insertions(+), 28 deletions(-) diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index 3b6777a..d4f7661 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -90,24 +90,25 @@ diagrams. These helpers implement broker-facing calculations only. They do not encode strategy entries, exits, Kelly sizing, or signal logic. -| Symbol | Role | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | -| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views | -| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings | -| `detect_position_side` | Net long / short / flat from open positions | -| `calculate_spread_ratio` | Relative bid-ask spread | -| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing | -| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals | -| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) | -| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) | -| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure | -| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure | -| `determine_order_limits` | SL/TP price levels from ratios | -| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan | -| `ensure_symbol_selected` | Select/verify Market Watch visibility | -| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) | -| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | -| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | +| Symbol | Role | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views | +| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings | +| `detect_position_side` | Net long / short / flat from open positions | +| `calculate_spread_ratio` | Relative bid-ask spread | +| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing | +| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals | +| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) | +| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) | +| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure | +| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure | +| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure | +| `determine_order_limits` | SL/TP price levels from ratios | +| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan | +| `ensure_symbol_selected` | Select/verify Market Watch visibility | +| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) | +| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | +| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index 5cb9289..8369edf 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -119,6 +119,7 @@ from .trading import ( OrderSide, OrderTimeMode, PositionSide, + calculate_account_projected_margin_ratio, calculate_margin_and_volume, calculate_new_position_margin_ratio, calculate_positions_margin, @@ -196,6 +197,7 @@ __all__ = [ "build_config", "build_rate_targets", "build_rate_view_name", + "calculate_account_projected_margin_ratio", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", "calculate_positions_margin", diff --git a/mt5cli/contract.py b/mt5cli/contract.py index eec3c2b..494b49e 100644 --- a/mt5cli/contract.py +++ b/mt5cli/contract.py @@ -26,6 +26,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "build_config", "build_rate_targets", "build_rate_view_name", + "calculate_account_projected_margin_ratio", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", "calculate_projected_margin_ratio", diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 35356eb..9d3ac6e 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -127,6 +127,7 @@ __all__ = [ "OrderSide", "OrderTimeMode", "PositionSide", + "calculate_account_projected_margin_ratio", "calculate_margin_and_volume", "calculate_new_position_margin_ratio", "calculate_positions_margin", @@ -834,15 +835,60 @@ def calculate_new_position_margin_ratio( def _account_equity(client: Mt5TradingClient) -> float: account = get_account_snapshot(client) - try: - equity = float(account.get("equity") or 0.0) - except (TypeError, ValueError) as exc: - msg = "Account equity must be positive to calculate margin ratio." - raise Mt5TradingError(msg) from exc - if equity <= 0 or not isfinite(equity): - msg = "Account equity must be positive to calculate margin ratio." + return _required_account_number(account, "equity", allow_zero=False) + + +def _required_account_number( + account: Mapping[str, object], + field: str, + *, + allow_zero: bool, +) -> float: + raw_value = account.get(field) + if isinstance(raw_value, bool) or not isinstance(raw_value, Real): + msg = f"Account {field} must be a finite number to calculate margin ratio." raise Mt5TradingError(msg) - return equity + value = float(raw_value) + if ( + not isfinite(value) + or (not allow_zero and value <= 0) + or (allow_zero and value < 0) + ): + msg = ( + f"Account {field} must be a non-negative finite number." + if allow_zero + else f"Account {field} must be a positive finite number." + ) + raise Mt5TradingError(msg) + return value + + +def calculate_account_projected_margin_ratio( + client: Mt5TradingClient, + *, + symbol: str | None = None, + new_position_side: OrderSide | None = None, + new_position_volume: float = 0.0, +) -> float: + """Return account-wide current plus optional new-position margin over equity. + + Current exposure comes from the broker account snapshot ``margin`` field so + unrelated open positions remain in the baseline. Optional projected + exposure is added via :func:`estimate_order_margin` only when a symbol, side, + and positive volume are all supplied. + + """ + account = get_account_snapshot(client) + equity = _required_account_number(account, "equity", allow_zero=False) + margin = _required_account_number(account, "margin", allow_zero=True) + if symbol is not None and new_position_side is not None and new_position_volume > 0: + margin += estimate_order_margin( + client, + symbol, + new_position_side, + new_position_volume, + ) + return margin / equity def calculate_projected_margin_ratio( diff --git a/pyproject.toml b/pyproject.toml index 56141c8..6f75b25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.9.3" +version = "0.9.4" 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 fd28ff0..6a3c9ab 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -37,6 +37,7 @@ from mt5cli import ( RateTarget, build_config, build_rate_targets, + calculate_account_projected_margin_ratio, calculate_margin_and_volume, calculate_positions_margin, calculate_projected_margin_ratio, @@ -684,6 +685,7 @@ class TestStableSdkContract: assert price is not None assert abs(price - 1.2) < 1e-9 assert callable(calculate_trailing_stop_updates) + assert callable(calculate_account_projected_margin_ratio) assert callable(calculate_projected_margin_ratio) assert callable(calculate_symbol_group_margin_ratio) diff --git a/tests/test_trading.py b/tests/test_trading.py index b2ae726..d71532d 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -19,6 +19,7 @@ from mt5cli.trading import ( MarginVolume, OrderExecutionResult, OrderLimits, + calculate_account_projected_margin_ratio, calculate_margin_and_volume, calculate_new_position_margin_ratio, calculate_positions_margin, @@ -1891,6 +1892,142 @@ class TestVolumeAndExecution: _assert_close(result, 0.024) client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1) + @pytest.mark.parametrize( + ("account", "kwargs", "candidate_margin", "expected_ratio"), + [ + ({"equity": 10_000.0, "margin": 4500.0}, {}, None, 0.45), + ( + {"equity": 10_000.0, "margin": 4500.0}, + { + "symbol": "EURUSD", + "new_position_side": "BUY", + "new_position_volume": 0.1, + }, + 1000.0, + 0.55, + ), + ({"equity": 10_000.0, "margin": 55.0}, {}, None, 0.0055), + ], + ) + def test_account_projected_margin_ratio_uses_account_margin_baseline( + self, + account: dict[str, object], + kwargs: dict[str, object], + candidate_margin: float | None, + expected_ratio: float, + mocker: MockerFixture, + ) -> None: + """Test account-wide exposure uses snapshot margin plus optional candidate.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = account + client.positions_get_as_df.return_value = pd.DataFrame( + [{"symbol": "GBPUSD", "type": 0, "volume": 2.0}], + ) + mock_margin = mocker.patch( + "mt5cli.trading.estimate_order_margin", + return_value=candidate_margin, + ) + + result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs)) + + _assert_close(result, expected_ratio) + if candidate_margin is None: + mock_margin.assert_not_called() + else: + mock_margin.assert_called_once_with(client, "EURUSD", "BUY", 0.1) + client.positions_get_as_df.assert_not_called() + + @pytest.mark.parametrize( + ("kwargs", "expected_ratio"), + [ + ({"new_position_side": "BUY", "new_position_volume": 0.1}, 0.45), + ({"symbol": "EURUSD", "new_position_volume": 0.1}, 0.45), + ({"symbol": "EURUSD", "new_position_side": "BUY"}, 0.45), + ( + { + "symbol": "EURUSD", + "new_position_side": "BUY", + "new_position_volume": -0.1, + }, + 0.45, + ), + ], + ) + def test_account_projected_margin_ratio_skips_incomplete_candidate( + self, + kwargs: dict[str, object], + expected_ratio: float, + mocker: MockerFixture, + ) -> None: + """Test candidate margin is added only when symbol, side, and volume exist.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = { + "equity": 10_000.0, + "margin": 4500.0, + } + mock_margin = mocker.patch("mt5cli.trading.estimate_order_margin") + + result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs)) + + _assert_close(result, expected_ratio) + mock_margin.assert_not_called() + + @pytest.mark.parametrize( + ("account", "match"), + [ + ({"margin": 4500.0}, "Account equity"), + ({"equity": None, "margin": 4500.0}, "Account equity"), + ({"equity": "10000", "margin": 4500.0}, "Account equity"), + ({"equity": True, "margin": 4500.0}, "Account equity"), + ({"equity": float("nan"), "margin": 4500.0}, "Account equity"), + ({"equity": float("inf"), "margin": 4500.0}, "Account equity"), + ({"equity": 0.0, "margin": 4500.0}, "Account equity"), + ({"equity": -1.0, "margin": 4500.0}, "Account equity"), + ({"equity": 10_000.0}, "Account margin"), + ({"equity": 10_000.0, "margin": None}, "Account margin"), + ({"equity": 10_000.0, "margin": "4500"}, "Account margin"), + ({"equity": 10_000.0, "margin": True}, "Account margin"), + ({"equity": 10_000.0, "margin": False}, "Account margin"), + ({"equity": 10_000.0, "margin": float("nan")}, "Account margin"), + ({"equity": 10_000.0, "margin": float("inf")}, "Account margin"), + ({"equity": 10_000.0, "margin": -1.0}, "Account margin"), + ], + ) + def test_account_projected_margin_ratio_rejects_invalid_snapshot_fields( + self, + account: dict[str, object], + match: str, + ) -> None: + """Test invalid account equity and margin fields fail closed.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = account + + with pytest.raises(Mt5TradingError, match=match): + calculate_account_projected_margin_ratio(client) + + def test_account_projected_margin_ratio_propagates_candidate_margin_error( + self, + mocker: MockerFixture, + ) -> None: + """Test candidate margin errors are not suppressed.""" + client = _mock_trade_client() + client.account_info_as_dict.return_value = { + "equity": 10_000.0, + "margin": 4500.0, + } + mocker.patch( + "mt5cli.trading.estimate_order_margin", + side_effect=Mt5TradingError("bad tick"), + ) + + with pytest.raises(Mt5TradingError, match="bad tick"): + calculate_account_projected_margin_ratio( + client, + symbol="EURUSD", + new_position_side="BUY", + new_position_volume=0.1, + ) + def test_symbol_group_margin_ratio_sums_group_exposure( self, mocker: MockerFixture, diff --git a/uv.lock b/uv.lock index c9e1bc3..7d5407b 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.9.3" +version = "0.9.4" source = { editable = "." } dependencies = [ { name = "click" },