Compare commits

..

1 Commits

Author SHA1 Message Date
Daichi Narushima b878a61c07 fix: re-verify normalized volume margin in calculate_volume_by_margin (#46)
* fix: re-verify normalized volume margin before returning from calculate_volume_by_margin

For CFDs, index products, and tiered-margin instruments, the initial
min-lot margin estimate can be optimistic; the normalized stepped volume
may require more margin than available_margin.  After computing the
normalized volume, step down by volume_step until order_calc_margin
confirms affordability, or return 0.0 if no step is affordable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: fix ruff line-length violations in calculate_volume_by_margin tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use integer step index and add actual>0 guard in calculate_volume_by_margin

Replace float-subtraction loop with integer step index to eliminate
accumulation rounding error and add `actual > 0` guard so a broker
returning zero/negative margin is never accepted as affordable.
Inline `capped` to keep local-variable count within Ruff PLR0914 limit.
Update docstring to reflect re-verification behaviour and 0.0 fallback.
Tighten test assertion from `volume > 0` to the symbol's valid range.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to v0.9.1

* perf: replace linear step-down scan with binary search in calculate_volume_by_margin

Resolves the P2 review finding: the previous O(n) loop called
order_calc_margin once per volume step, making sizing appear hung for
symbols with a large step range or small volume_step.

Binary search over the integer step index finds the largest affordable
step in O(log n) IPC calls (≈17 for a 99 999-step range vs up to 99 999
in the worst case). Monotonicity of broker margin with volume is assumed,
which holds for standard linear margin schedules.

To stay within the PLR0914 local-variable limit the steps variable is
inlined into hi and the tick temporary is eliminated by accessing the
snapshot dict directly. Error messages still go via msg to satisfy EM102.

Two existing tests are updated to match the binary-search call sequence.
A new regression test (volume_min=0.01, volume_max=1000.0) configures
a tiered-margin mock with its threshold at step 50000 and asserts that
the total order_calc_margin call count does not exceed 25.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove obsolete TC003 per-file-ignore for history.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 04:40:00 +09:00
4 changed files with 100 additions and 12 deletions
+36 -8
View File
@@ -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(
+1 -2
View File
@@ -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
+62 -1
View File
@@ -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
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.9.0"
version = "0.9.1"
source = { editable = "." }
dependencies = [
{ name = "click" },