diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 23f2f32..cfc81d3 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -830,7 +830,9 @@ def calculate_volume_by_margin( """Calculate max normalized volume affordable for one side. Returns: - Affordable volume rounded down to symbol volume constraints. + Largest stepped volume whose actual margin (from ``order_calc_margin``) + fits within ``available_margin``, rounded down to symbol volume + constraints; ``0.0`` when no affordable step exists. Raises: Mt5TradingError: If symbol volume constraints or tick data are invalid. @@ -845,8 +847,7 @@ def calculate_volume_by_margin( 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"] + price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"] if not isinstance(price, int | float) or price <= 0: msg = f"Tick price is unavailable for {symbol!r}." raise Mt5TradingError(msg) @@ -856,11 +857,38 @@ def calculate_volume_by_margin( 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 + lo = 0 + hi = int( + max( + 0, + floor( + ( + ( + min(available_margin / min_margin * volume_min, volume_max) + if volume_max > 0 + else available_margin / min_margin * volume_min + ) + - volume_min + ) + / volume_step + + 1e-12 + ), + ) + ) + best = -1 + + while lo <= hi: + mid = (lo + hi) // 2 + normalized = round(volume_min + mid * volume_step, 10) + actual = float(client.order_calc_margin(order_type, symbol, normalized, price)) + + if actual > 0 and actual <= available_margin: + best = mid + lo = mid + 1 + else: + hi = mid - 1 + + return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0 def determine_order_limits( diff --git a/pyproject.toml b/pyproject.toml index b8ba49a..2ac7ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.9.0" +version = "0.9.1" 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"}] @@ -124,7 +124,6 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"mt5cli/history.py" = ["TC003"] "tests/**/*.py" = [ "DOC201", # Missing return documentation "DOC501", # Raised exception missing from docstring diff --git a/tests/test_trading.py b/tests/test_trading.py index 19409cf..47baeb8 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -1375,6 +1375,67 @@ class TestVolumeAndExecution: with pytest.raises(Mt5TradingError): calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL") + def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget( + self, + ) -> None: + """Tiered margin: binary search returns the largest affordable 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} + # min_margin (0.1): 25.0 -> hi=4. + # Search: mid=2 (0.3)->75<=130, mid=3 (0.4)->100<=130, mid=4 (0.5)->150>130. + client.order_calc_margin.side_effect = [25.0, 75.0, 100.0, 150.0] + + result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY") + + _assert_close(result, 0.4) + + def test_calculate_volume_by_margin_returns_zero_when_all_steps_unaffordable( + self, + ) -> None: + """If all binary-search probes exceed budget, returns zero.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.1, + "volume_max": 0.5, + "volume_step": 0.1, + } + client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} + # min_margin (0.1): 10.0 -> hi=4. + # Search: mid=2 (0.3)->150>130->hi=1, mid=0 (0.1)->150>130->hi=-1. + client.order_calc_margin.side_effect = [10.0, 150.0, 150.0] + + result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY") + + _assert_close(result, 0.0) + + def test_calculate_volume_by_margin_binary_search_is_bounded(self) -> None: + """Binary search finds the largest affordable volume in O(log n) MT5 calls.""" + client = _mock_trade_client() + client.symbol_info_as_dict.return_value = { + "volume_min": 0.01, + "volume_max": 1000.0, + "volume_step": 0.01, + } + client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} + # Steps 0-50000 cost 0.001 (affordable); steps 50001+ cost 2000.0 (not). + # Total range: 99999 steps. Linear scan: ~50000 calls; binary search: ~17. + affordable_step = 50000 + + def _margin(_ot: int, _sym: str, volume: float, _px: float) -> float: + step = round((volume - 0.01) / 0.01) + return 0.001 if step <= affordable_step else 2000.0 + + client.order_calc_margin.side_effect = _margin + result = calculate_volume_by_margin(client, "EURUSD", 200.0, "BUY") + + _assert_close(result, 500.01) # 0.01 + 50000 * 0.01 + assert client.order_calc_margin.call_count <= 25 + def test_calculate_margin_and_volume_without_native_helper(self) -> None: """Test margin helper uses module volume calculation when needed.""" @@ -1401,7 +1462,7 @@ class TestVolumeAndExecution: ) -> float: assert order_type in {10, 11} assert symbol == "EURUSD" - _assert_close(volume, 0.1) + assert 0.1 <= volume <= 1.0 _assert_close(price, 100.0) return 10.0 diff --git a/uv.lock b/uv.lock index 09bb106..b06ec3a 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "click" },