Compare commits

..

6 Commits

Author SHA1 Message Date
Daichi Narushima 9ac3b885c3 test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50) (#53)
* test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50)

Covers all acceptance criteria: partial failure with warning log, all-fail,
empty symbol list with no-broker-call assertion, duplicate deduplication,
successful aggregation with first-seen key order, suppress_errors=False
propagation, and three calculate_positions_margin_safe cases (partial skip,
all-fail → 0.0, empty list → 0.0).

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

* test: fix warning log assertion and parametrize suppress_errors=False test

- Use record.getMessage() + levelno check instead of record.message, which
  is only populated after formatting and can return an empty string.
- Parametrize test_one_symbol_fails_suppress_errors_false over all three
  exception types caught by the implementation (Mt5TradingError,
  Mt5RuntimeError, AttributeError) so any future narrowing of the except
  tuple would be caught by tests.

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

* style: shorten docstring to fit 88-char line limit

* style: shorten docstring to fit 88-char line limit

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:41:30 +09:00
Daichi Narushima 823cb5b0a4 Revert "Bump version to v0.9.3 (#51)" (#52)
This reverts commit f1ada55bce.
2026-06-23 19:17:42 +09:00
agent 1c57be5c44 fix: centralize tick price validation in calculate_spread_ratio and determine_order_limits (#52)
Replaces manual isinstance/<=0 checks in calculate_spread_ratio() and
determine_order_limits() with _valid_tick_price(), ensuring NaN, inf,
-inf, zero, negative, bool, and invalid-string tick values are
consistently rejected across all trading helpers.

Adds regression tests covering numeric-string acceptance and every
invalid-value category for both functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:48:13 +00:00
Daichi Narushima f1ada55bce Bump version to v0.9.3 (#51) 2026-06-23 18:30:18 +09:00
agent d292fbb9d9 feat: centralize tick price validation and add resilient position margin helpers (#49, #50)
Add _valid_tick_price() internal helper that returns a positive finite float
from a tick dict or None for any invalid value (missing, None, NaN, infinite,
zero, negative, or unsupported type). Refactor five existing bid/ask validation
sites in trading.py to use it, removing duplicated isinstance/isfinite checks.

Add calculate_positions_margin_by_symbol() which computes margin per unique
symbol independently using the existing strict calculate_positions_margin(),
with first-seen deduplication and configurable error suppression
(Mt5TradingError, Mt5RuntimeError, AttributeError) via suppress_errors=.

Add calculate_positions_margin_safe() as a thin sum wrapper with
suppress_errors=True, returning 0.0 on empty or fully-failed inputs.

Both new helpers are exported from mt5cli, added to STABLE_SDK_EXPORTS, and
documented in docs/api/public-contract.md. Existing strict behavior of
calculate_positions_margin() is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:30:02 +00:00
Daichi Narushima 8e53212a24 fix: always use mt5cli calculate_volume_by_margin to prevent LACK OF FUNDS (#48) 2026-06-23 14:01:46 +09:00
7 changed files with 502 additions and 95 deletions
+14 -12
View File
@@ -99,18 +99,20 @@ 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 |
| `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 |
| `determine_order_limits` | SL/TP price levels from ratios |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_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 |
| `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) |
| `determine_order_limits` | SL/TP price levels from ratios |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_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.
+4
View File
@@ -119,6 +119,8 @@ from .trading import (
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_positions_margin,
calculate_positions_margin_by_symbol,
calculate_positions_margin_safe,
calculate_spread_ratio,
calculate_volume_by_margin,
close_open_positions,
@@ -188,6 +190,8 @@ __all__ = [
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
+2
View File
@@ -31,6 +31,8 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
+125 -49
View File
@@ -2,13 +2,14 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from math import floor, isfinite
from numbers import Integral, Real
from typing import TYPE_CHECKING, Literal, TypedDict, cast
import pandas as pd
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .history import drop_forming_rate_bar
from .sdk import build_config
@@ -16,7 +17,9 @@ from .utils import coerce_login as _coerce_login
from .utils import parse_timeframe
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from collections.abc import Iterator, Mapping, Sequence
_logger = logging.getLogger(__name__)
PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"]
@@ -128,6 +131,8 @@ __all__ = [
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"close_open_positions",
@@ -423,6 +428,30 @@ def _optional_price(value: object) -> float | None:
return price
def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None:
"""Return a positive finite float from tick[key], or None if invalid.
Accepts int, float, or numeric string values. Returns None when the key is
missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
Booleans are treated as non-numeric and return None.
"""
value = tick.get(key)
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
price = float(value)
elif isinstance(value, str):
try:
price = float(value)
except ValueError:
return None
else:
return None
if not isfinite(price) or price <= 0:
return None
return price
def _success_retcodes(mt5: object) -> frozenset[int]:
values = {
value
@@ -461,9 +490,10 @@ def _calculate_min_volume_if_affordable(
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"]
if not isinstance(price, int | float) or price <= 0:
price = _valid_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
order_type = (
@@ -621,14 +651,14 @@ def estimate_order_margin(
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
try:
margin = float(raw_margin)
except (TypeError, ValueError) as exc:
@@ -682,22 +712,85 @@ def calculate_positions_margin(
return total
def calculate_positions_margin_by_symbol(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
suppress_errors: bool = True,
) -> dict[str, float]:
"""Return per-symbol estimated margin for open positions.
Computes margin for each unique input symbol independently using the strict
:func:`calculate_positions_margin` helper. Duplicates are deduplicated in
first-seen order.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbols: Symbols to compute margin for.
suppress_errors: When ``True``, log and skip symbols that raise
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
When ``False``, re-raise the first failure.
Returns:
Mapping of symbol to margin total in first-seen unique-symbol order.
Returns an empty dict when ``symbols`` is empty or all symbols fail
with ``suppress_errors=True``.
Raises:
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
``suppress_errors=False``.
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
``suppress_errors=False``.
AttributeError: When a symbol raises ``AttributeError`` and
``suppress_errors=False``.
"""
result: dict[str, float] = {}
for symbol in dict.fromkeys(symbols):
try:
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
if not suppress_errors:
raise
_logger.warning("Skipping margin for %r: %s", symbol, exc)
return result
def calculate_positions_margin_safe(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
) -> float:
"""Return the total estimated margin for open positions across symbols.
Internally calls :func:`calculate_positions_margin_by_symbol` with
``suppress_errors=True``. Failed symbols are silently skipped.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbols: Symbols to include.
Returns:
Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
"""
return sum(
calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
0.0,
)
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
Raises:
Mt5TradingError: If bid or ask is unavailable or non-positive.
Mt5TradingError: If bid or ask is unavailable.
"""
tick = get_tick_snapshot(client, symbol)
bid = tick.get("bid")
ask = tick.get("ask")
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
bid = _valid_tick_price(tick, "bid")
ask = _valid_tick_price(tick, "ask")
if bid is None or ask is None:
msg = f"Tick bid/ask is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
if bid <= 0 or ask <= 0:
msg = f"Tick bid/ask must be positive for {symbol!r}."
raise Mt5TradingError(msg)
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
return (ask - bid) / ((ask + bid) / 2.0)
def calculate_new_position_margin_ratio(
@@ -720,9 +813,10 @@ def calculate_new_position_margin_ratio(
margin = float(account.get("margin") or 0.0)
if new_position_side is not None and new_position_volume > 0:
side = _normalize_order_side(new_position_side)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0:
price = _valid_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
order_type = (
@@ -780,28 +874,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)
@@ -847,8 +921,10 @@ def calculate_volume_by_margin(
msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"]
if not isinstance(price, int | float) or price <= 0:
price = _valid_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
order_type = (
@@ -924,11 +1000,11 @@ def determine_order_limits(
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
normalized_side = _position_side_from_order_side(side)
tick = get_tick_snapshot(client, symbol)
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
if not isinstance(entry_value, int | float):
entry_key = "ask" if normalized_side == "long" else "bid"
entry = _valid_tick_price(tick, entry_key)
if entry is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
entry = float(entry_value)
try:
symbol_info = get_symbol_snapshot(client, symbol)
except (AttributeError, KeyError, TypeError, ValueError):
@@ -1004,8 +1080,8 @@ def place_market_order(
if not dry_run:
ensure_symbol_selected(client, symbol)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0:
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
request = {
@@ -1015,7 +1091,7 @@ def place_market_order(
"type": (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
),
"price": float(price),
"price": price,
"type_filling": _resolve_mt5_constant(
client.mt5,
"ORDER_FILLING",
+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"}]
+355 -32
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
@@ -18,9 +19,12 @@ from mt5cli.trading import (
MarginVolume,
OrderExecutionResult,
OrderLimits,
_valid_tick_price, # type: ignore[reportPrivateUsage]
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_positions_margin,
calculate_positions_margin_by_symbol,
calculate_positions_margin_safe,
calculate_spread_ratio,
calculate_volume_by_margin,
close_open_positions,
@@ -122,12 +126,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 +152,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"),
@@ -161,11 +167,15 @@ class TestCalculateMarginAndVolume:
self,
account_dict: dict[str, float | None],
expected_margin_free: float,
mocker: MockerFixture,
) -> None:
"""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")
mock_calc_vol = mocker.patch(
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
)
result = calculate_margin_and_volume(
client,
@@ -175,14 +185,19 @@ 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)
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
def test_clamps_negative_margin_free_to_zero(self) -> None:
def test_clamps_negative_margin_free_to_zero(self, mocker: MockerFixture) -> 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")
mock_calc_vol = mocker.patch(
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
)
result = calculate_margin_and_volume(
client,
@@ -191,10 +206,11 @@ 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)
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
@pytest.mark.parametrize(
("unit_ratio", "preserved_ratio"),
@@ -412,6 +428,55 @@ class TestDetermineOrderLimits:
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
determine_order_limits(client, "EURUSD", "long")
def test_accepts_numeric_string_entry(self) -> None:
"""Test numeric string ask/bid values are accepted as entry prices."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {
"ask": "1.1010",
"bid": "1.1000",
}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = determine_order_limits(client, "EURUSD", "long")
_assert_close(result["entry"], 1.1010)
@pytest.mark.parametrize(
("side", "field", "bad_value"),
[
("long", "ask", float("nan")),
("long", "ask", float("inf")),
("long", "ask", float("-inf")),
("long", "ask", 0.0),
("long", "ask", -1.0),
("long", "ask", True),
("long", "ask", False),
("long", "ask", "invalid"),
("short", "bid", float("nan")),
("short", "bid", float("inf")),
("short", "bid", float("-inf")),
("short", "bid", 0.0),
("short", "bid", -1.0),
("short", "bid", True),
("short", "bid", False),
("short", "bid", "invalid"),
],
)
def test_rejects_invalid_entry_tick_values(
self,
side: str,
field: str,
bad_value: object,
) -> None:
"""Test invalid entry tick values raise Mt5TradingError."""
tick: dict[str, object] = {"ask": 1.1, "bid": 1.0}
tick[field] = bad_value
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = tick
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
determine_order_limits(client, "EURUSD", side)
def test_rejects_stop_loss_inside_broker_stop_level(self) -> None:
"""Test stop-loss prices closer than trade_stops_level raise Mt5TradingError."""
client = MagicMock()
@@ -778,6 +843,50 @@ class TestSnapshotsAndState:
with pytest.raises(Mt5TradingError):
calculate_spread_ratio(client, "EURUSD")
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
"""Test numeric string bid/ask values are accepted."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"bid": "99.0", "ask": "101.0"}
_assert_close(calculate_spread_ratio(client, "EURUSD"), 0.02)
@pytest.mark.parametrize(
("field", "bad_value"),
[
("bid", None),
("bid", float("nan")),
("bid", float("inf")),
("bid", float("-inf")),
("bid", 0.0),
("bid", -1.0),
("bid", True),
("bid", False),
("bid", "invalid"),
("ask", None),
("ask", float("nan")),
("ask", float("inf")),
("ask", float("-inf")),
("ask", 0.0),
("ask", -1.0),
("ask", True),
("ask", False),
("ask", "invalid"),
],
)
def test_calculate_spread_ratio_rejects_invalid_tick_values(
self,
field: str,
bad_value: object,
) -> None:
"""Test invalid bid/ask values raise Mt5TradingError."""
tick: dict[str, object] = {"bid": 100.0, "ask": 100.0}
tick[field] = bad_value
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = tick
with pytest.raises(Mt5TradingError, match="Tick bid/ask is unavailable"):
calculate_spread_ratio(client, "EURUSD")
class TestNormalizeOrderVolume:
"""Tests for normalize_order_volume."""
@@ -1614,18 +1723,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 +1760,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, match="missing"):
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."""
@@ -2956,3 +3076,206 @@ class TestFetchLatestClosedRatesIndexed:
assert "open" in result.columns
assert "close" in result.columns
assert isinstance(result.index, pd.DatetimeIndex)
class TestValidTickPrice:
"""Tests for the _valid_tick_price internal helper (#49)."""
def test_valid_positive_float(self) -> None:
"""Returns a positive float value unchanged."""
_assert_close(_valid_tick_price({"bid": 1.1000}, "bid"), 1.1000)
def test_valid_positive_int(self) -> None:
"""Accepts an integer value and returns it as float."""
result = _valid_tick_price({"bid": 2}, "bid")
_assert_close(result, 2.0)
assert isinstance(result, float)
def test_valid_numeric_string(self) -> None:
"""Accepts a numeric string and returns the parsed float."""
_assert_close(_valid_tick_price({"bid": "1.5"}, "bid"), 1.5)
def test_missing_key(self) -> None:
"""Returns None when the key is absent from the tick dict."""
assert _valid_tick_price({}, "bid") is None
def test_none_value(self) -> None:
"""Returns None when the stored value is None."""
assert _valid_tick_price({"bid": None}, "bid") is None
def test_invalid_string(self) -> None:
"""Returns None for a non-numeric string."""
assert _valid_tick_price({"bid": "not_a_number"}, "bid") is None
def test_nan(self) -> None:
"""Returns None for a NaN float."""
assert _valid_tick_price({"bid": float("nan")}, "bid") is None
def test_positive_infinity(self) -> None:
"""Returns None for positive infinity."""
assert _valid_tick_price({"bid": float("inf")}, "bid") is None
def test_negative_infinity(self) -> None:
"""Returns None for negative infinity."""
assert _valid_tick_price({"bid": float("-inf")}, "bid") is None
def test_zero(self) -> None:
"""Returns None for zero (not a valid price)."""
assert _valid_tick_price({"bid": 0.0}, "bid") is None
def test_negative_value(self) -> None:
"""Returns None for a negative price."""
assert _valid_tick_price({"bid": -1.0}, "bid") is None
def test_unsupported_type(self) -> None:
"""Returns None for unsupported value types such as list."""
assert _valid_tick_price({"bid": [1.0]}, "bid") is None
class TestCalculatePositionsMarginBySymbol:
"""Tests for calculate_positions_margin_by_symbol (#50)."""
def test_all_symbols_succeed(self, mocker: MockerFixture) -> None:
"""Returns one entry per symbol in first-seen order when all calls succeed."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[12.5, 30.0],
)
result = calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD"]
)
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
_assert_close(result["EURUSD"], 12.5)
_assert_close(result["GBPUSD"], 30.0)
def test_one_symbol_fails_suppress_errors_true(
self, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
) -> None:
"""Skips the failing symbol, emits a warning, and returns the successful one."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
)
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
result = calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
)
assert result == {"GBPUSD": 30.0}
assert any(
"EURUSD" in record.getMessage() and record.levelno == logging.WARNING
for record in caplog.records
)
@pytest.mark.parametrize(
"exc",
[
Mt5TradingError("trading error"),
Mt5RuntimeError("runtime error"),
AttributeError("missing attr"),
],
)
def test_one_symbol_fails_suppress_errors_false(
self, mocker: MockerFixture, exc: Exception
) -> None:
"""Re-raises the first failure for each caught exception type."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[exc, 30.0],
)
with pytest.raises(type(exc)):
calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=False
)
def test_all_symbols_fail_suppress_errors_true(self, mocker: MockerFixture) -> None:
"""Returns an empty dict when all symbols fail with suppress_errors=True."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("err1"), Mt5TradingError("err2")],
)
result = calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
)
assert result == {}
def test_empty_symbol_list(self, mocker: MockerFixture) -> None:
"""Returns an empty dict for an empty input list without any broker calls."""
client = _mock_trade_client()
mock_calc = mocker.patch("mt5cli.trading.calculate_positions_margin")
result = calculate_positions_margin_by_symbol(client, symbols=[])
assert result == {}
mock_calc.assert_not_called()
def test_duplicate_symbols_preserve_first_seen_order(
self, mocker: MockerFixture
) -> None:
"""Processes each unique symbol once in first-seen order."""
client = _mock_trade_client()
mock_calc = mocker.patch(
"mt5cli.trading.calculate_positions_margin",
return_value=12.5,
)
result = calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD", "EURUSD"]
)
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
assert mock_calc.call_count == 2
class TestCalculatePositionsMarginSafe:
"""Tests for calculate_positions_margin_safe (#50)."""
def test_returns_summed_total(self, mocker: MockerFixture) -> None:
"""Returns the sum of all per-symbol margins."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[12.5, 30.0],
)
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
_assert_close(total, 42.5)
def test_partial_failure_skips_and_sums(self, mocker: MockerFixture) -> None:
"""Sums only successful margins when one symbol raises."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
)
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
_assert_close(total, 30.0)
def test_all_symbols_fail_returns_zero(self, mocker: MockerFixture) -> None:
"""Returns 0.0 when every symbol raises."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("err1"), Mt5RuntimeError("err2")],
)
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
_assert_close(total, 0.0)
def test_empty_symbols_returns_zero(self) -> None:
"""Returns 0.0 for an empty symbol list."""
client = _mock_trade_client()
total = calculate_positions_margin_safe(client, symbols=[])
_assert_close(total, 0.0)
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" },