fix: always use mt5cli calculate_volume_by_margin to prevent LACK OF FUNDS (#47)

Remove native-client delegation in calculate_margin_and_volume() so the
pdmt5 linear estimate can no longer overstate affordable volume on
foreign-denominated instruments or CFDs. The module's own
calculate_volume_by_margin() verifies each candidate volume against
order_calc_margin() and returns the largest stepped lot that fits the
margin budget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-23 03:30:50 +00:00
parent b878a61c07
commit 2adb8d9ecf
4 changed files with 49 additions and 55 deletions
+2 -22
View File
@@ -780,28 +780,8 @@ def calculate_margin_and_volume(
"SELL",
)
else:
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
if callable(native_calculate_volume):
buy_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "BUY"),
),
)
sell_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "SELL"),
),
)
else:
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
sell_volume = calculate_volume_by_margin(
client,
symbol,
trade_margin,
"SELL",
)
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
try:
symbol_info = get_symbol_snapshot(client, symbol)
volume_min = float(symbol_info.get("volume_min") or 0.0)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "0.9.1"
version = "0.9.2"
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"}]
+45 -31
View File
@@ -122,12 +122,14 @@ class TestDetectPositionSide:
class TestCalculateMarginAndVolume:
"""Tests for calculate_margin_and_volume."""
def test_calculates_margin_budget_and_volumes(self) -> None:
def test_calculates_margin_budget_and_volumes(self, mocker: MockerFixture) -> None:
"""Test margin budget and buy/sell volumes are derived from ratios."""
client = MagicMock()
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
client.symbol_info_as_dict.side_effect = AttributeError("missing")
mock_calc_vol = mocker.patch(
"mt5cli.trading.calculate_volume_by_margin", side_effect=[0.3, 0.2]
)
result = calculate_margin_and_volume(
client,
@@ -146,8 +148,8 @@ class TestCalculateMarginAndVolume:
"volume_max": 0.0,
"volume_step": 0.0,
}
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL")
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "BUY")
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "SELL")
@pytest.mark.parametrize(
("account_dict", "expected_margin_free"),
@@ -165,7 +167,7 @@ class TestCalculateMarginAndVolume:
"""Test missing or zero margin_free yields zero trade margin."""
client = MagicMock()
client.account_info_as_dict.return_value = account_dict
client.calculate_volume_by_margin.return_value = 0.0
client.symbol_info_as_dict.side_effect = AttributeError("missing")
result = calculate_margin_and_volume(
client,
@@ -175,14 +177,15 @@ class TestCalculateMarginAndVolume:
)
assert result["margin_free"] == expected_margin_free
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
_assert_close(result["buy_volume"], 0.0)
_assert_close(result["sell_volume"], 0.0)
client.calculate_volume_by_margin.assert_not_called()
def test_clamps_negative_margin_free_to_zero(self) -> None:
"""Test negative margin_free is clamped to zero before sizing."""
client = MagicMock()
client.account_info_as_dict.return_value = {"margin_free": -500.0}
client.calculate_volume_by_margin.return_value = 0.0
client.symbol_info_as_dict.side_effect = AttributeError("missing")
result = calculate_margin_and_volume(
client,
@@ -191,10 +194,10 @@ class TestCalculateMarginAndVolume:
preserved_margin_ratio=0.2,
)
expected_margin_free = 0.0
assert result["margin_free"] == expected_margin_free
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
_assert_close(result["margin_free"], 0.0)
_assert_close(result["buy_volume"], 0.0)
_assert_close(result["sell_volume"], 0.0)
client.calculate_volume_by_margin.assert_not_called()
@pytest.mark.parametrize(
("unit_ratio", "preserved_ratio"),
@@ -1614,18 +1617,34 @@ class TestVolumeAndExecution:
preserved_margin_ratio=0.0,
)
def test_calculate_margin_and_volume_positive_ratio_uses_existing_behavior(
def test_calculate_margin_and_volume_positive_ratio_avoids_oversized_native_volume(
self,
) -> None:
"""Test positive unit ratios keep proportional native sizing."""
"""Regression: module path is used even when native helper is present.
The pdmt5 linear estimate (floor(40/10)*0.1 = 0.4) would overstate
affordable volume because order_calc_margin returns 45.0 for 0.4 lots,
exceeding the 40.0 budget. The module's verified binary search returns
0.3 (margin=30.0), which is safe.
"""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"margin_free": 100.0}
client.calculate_volume_by_margin.side_effect = [0.4, 0.3]
client.symbol_info_as_dict.return_value = {
"volume_min": 0.1,
"volume_max": 1.0,
"volume_step": 0.1,
}
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
client.calculate_volume_by_margin.return_value = 0.4 # pdmt5 linear oversized
def _mock_calc_margin(
_order_type: int, _symbol: str, volume: float, _price: float
) -> float:
if volume <= 0.3:
return round(volume * 100.0, 10) # 0.1→10, 0.2→20, 0.3→30
return round(volume * 112.5, 10) # 0.4→45 — exceeds 40.0 budget
client.order_calc_margin.side_effect = _mock_calc_margin
result = calculate_margin_and_volume(
client,
@@ -1635,30 +1654,25 @@ class TestVolumeAndExecution:
)
_assert_close(result["trade_margin"], 40.0)
_assert_close(result["buy_volume"], 0.4)
_assert_close(result["buy_volume"], 0.3)
_assert_close(result["sell_volume"], 0.3)
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "SELL")
client.calculate_volume_by_margin.assert_not_called()
def test_calculate_margin_and_volume_handles_missing_symbol_snapshot(
def test_calculate_margin_and_volume_positive_ratio_raises_on_missing_symbol_info(
self,
) -> None:
"""Test missing symbol metadata falls back to zero volume constraints."""
"""Test missing symbol info propagates an error when ratio is positive."""
client = MagicMock()
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
client.symbol_info_as_dict.side_effect = AttributeError("missing")
result = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
_assert_close(result["volume_min"], 0.0)
_assert_close(result["volume_max"], 0.0)
_assert_close(result["volume_step"], 0.0)
with pytest.raises(AttributeError):
calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
def test_new_position_margin_ratio_adds_hypothetical_margin(self) -> None:
"""Test hypothetical order margin is added to account margin."""
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.9.1"
version = "0.9.2"
source = { editable = "." }
dependencies = [
{ name = "click" },