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>
This commit is contained in:
agent
2026-06-23 07:30:02 +00:00
co-authored by Claude Sonnet 4.6
parent 8e53212a24
commit d292fbb9d9
5 changed files with 292 additions and 28 deletions
+157
View File
@@ -18,9 +18,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,
@@ -2979,3 +2982,157 @@ 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 when all margin 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 result == {"EURUSD": 12.5, "GBPUSD": 30.0}
def test_one_symbol_fails_suppress_errors_true(self, mocker: MockerFixture) -> None:
"""Skips the failing symbol and returns the successful one."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
)
result = calculate_positions_margin_by_symbol(
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
)
assert result == {"GBPUSD": 30.0}
def test_one_symbol_fails_suppress_errors_false(
self, mocker: MockerFixture
) -> None:
"""Re-raises the first failure when suppress_errors=False."""
client = _mock_trade_client()
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
)
with pytest.raises(Mt5TradingError, match="tick unavailable"):
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) -> None:
"""Returns an empty dict for an empty input list."""
client = _mock_trade_client()
result = calculate_positions_margin_by_symbol(client, symbols=[])
assert result == {}
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)