Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b878a61c07 |
+36
-8
@@ -830,7 +830,9 @@ def calculate_volume_by_margin(
|
|||||||
"""Calculate max normalized volume affordable for one side.
|
"""Calculate max normalized volume affordable for one side.
|
||||||
|
|
||||||
Returns:
|
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:
|
Raises:
|
||||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
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}."
|
msg = f"Invalid volume constraints for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"]
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
|
||||||
if not isinstance(price, int | float) or price <= 0:
|
if not isinstance(price, int | float) or price <= 0:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
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))
|
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
||||||
if min_margin <= 0 or min_margin > available_margin:
|
if min_margin <= 0 or min_margin > available_margin:
|
||||||
return 0.0
|
return 0.0
|
||||||
raw_volume = available_margin / min_margin * volume_min
|
lo = 0
|
||||||
capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
|
hi = int(
|
||||||
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
|
max(
|
||||||
normalized = volume_min + max(0, steps) * volume_step
|
0,
|
||||||
return round(normalized, 10) if normalized >= volume_min else 0.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(
|
def determine_order_limits(
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
maintainers = [{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]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"mt5cli/history.py" = ["TC003"]
|
|
||||||
"tests/**/*.py" = [
|
"tests/**/*.py" = [
|
||||||
"DOC201", # Missing return documentation
|
"DOC201", # Missing return documentation
|
||||||
"DOC501", # Raised exception missing from docstring
|
"DOC501", # Raised exception missing from docstring
|
||||||
|
|||||||
+62
-1
@@ -1375,6 +1375,67 @@ class TestVolumeAndExecution:
|
|||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5TradingError):
|
||||||
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
|
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:
|
def test_calculate_margin_and_volume_without_native_helper(self) -> None:
|
||||||
"""Test margin helper uses module volume calculation when needed."""
|
"""Test margin helper uses module volume calculation when needed."""
|
||||||
|
|
||||||
@@ -1401,7 +1462,7 @@ class TestVolumeAndExecution:
|
|||||||
) -> float:
|
) -> float:
|
||||||
assert order_type in {10, 11}
|
assert order_type in {10, 11}
|
||||||
assert symbol == "EURUSD"
|
assert symbol == "EURUSD"
|
||||||
_assert_close(volume, 0.1)
|
assert 0.1 <= volume <= 1.0
|
||||||
_assert_close(price, 100.0)
|
_assert_close(price, 100.0)
|
||||||
return 10.0
|
return 10.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user