mt5cli 0.9.1__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-0.9.1 → mt5cli-0.9.2}/PKG-INFO +1 -1
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/public-contract.md +14 -12
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/__init__.py +4 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/contract.py +2 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/trading.py +125 -49
- {mt5cli-0.9.1 → mt5cli-0.9.2}/pyproject.toml +1 -1
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_trading.py +355 -32
- {mt5cli-0.9.1 → mt5cli-0.9.2}/uv.lock +1 -1
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.claude/agents/codex.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.claude/settings.json +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/FUNDING.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/dependabot.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/renovate.json +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/release.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/.gitignore +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/AGENTS.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/CLAUDE.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/LICENSE +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/README.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/cli.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/client.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/converters.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/exceptions.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/history.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/index.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/schemas.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/sdk.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/storage.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/trading.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/utils.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/index.md +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mkdocs.yml +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/__main__.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/cli.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/client.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/converters.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/exceptions.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/history.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/retry.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/schemas.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/sdk.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/storage.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/utils.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/__init__.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/conftest.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_cli.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_contracts.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_history.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_sdk.py +0 -0
- {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -99,18 +99,20 @@ diagrams.
|
|
|
99
99
|
These helpers implement broker-facing calculations only. They do not encode
|
|
100
100
|
strategy entries, exits, Kelly sizing, or signal logic.
|
|
101
101
|
|
|
102
|
-
| Symbol | Role
|
|
103
|
-
| -------------------------------------------------------------------------------------------------- |
|
|
104
|
-
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views
|
|
105
|
-
| `detect_position_side` | Net long / short / flat from open positions
|
|
106
|
-
| `calculate_spread_ratio` | Relative bid-ask spread
|
|
107
|
-
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing
|
|
108
|
-
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
102
|
+
| Symbol | Role |
|
|
103
|
+
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
104
|
+
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
|
105
|
+
| `detect_position_side` | Net long / short / flat from open positions |
|
|
106
|
+
| `calculate_spread_ratio` | Relative bid-ask spread |
|
|
107
|
+
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
|
108
|
+
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
|
109
|
+
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
|
110
|
+
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
|
111
|
+
| `determine_order_limits` | SL/TP price levels from ratios |
|
|
112
|
+
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
|
113
|
+
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
|
114
|
+
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
|
115
|
+
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
|
114
116
|
|
|
115
117
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
|
116
118
|
|
|
@@ -119,6 +119,8 @@ from .trading import (
|
|
|
119
119
|
calculate_margin_and_volume,
|
|
120
120
|
calculate_new_position_margin_ratio,
|
|
121
121
|
calculate_positions_margin,
|
|
122
|
+
calculate_positions_margin_by_symbol,
|
|
123
|
+
calculate_positions_margin_safe,
|
|
122
124
|
calculate_spread_ratio,
|
|
123
125
|
calculate_volume_by_margin,
|
|
124
126
|
close_open_positions,
|
|
@@ -188,6 +190,8 @@ __all__ = [
|
|
|
188
190
|
"calculate_margin_and_volume",
|
|
189
191
|
"calculate_new_position_margin_ratio",
|
|
190
192
|
"calculate_positions_margin",
|
|
193
|
+
"calculate_positions_margin_by_symbol",
|
|
194
|
+
"calculate_positions_margin_safe",
|
|
191
195
|
"calculate_spread_ratio",
|
|
192
196
|
"calculate_volume_by_margin",
|
|
193
197
|
"call_with_normalized_errors",
|
|
@@ -31,6 +31,8 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|
|
31
31
|
"calculate_margin_and_volume",
|
|
32
32
|
"calculate_new_position_margin_ratio",
|
|
33
33
|
"calculate_positions_margin",
|
|
34
|
+
"calculate_positions_margin_by_symbol",
|
|
35
|
+
"calculate_positions_margin_safe",
|
|
34
36
|
"calculate_spread_ratio",
|
|
35
37
|
"calculate_volume_by_margin",
|
|
36
38
|
"call_with_normalized_errors",
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from math import floor, isfinite
|
|
7
8
|
from numbers import Integral, Real
|
|
8
9
|
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
|
9
10
|
|
|
10
11
|
import pandas as pd
|
|
11
|
-
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
|
12
|
+
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
|
12
13
|
|
|
13
14
|
from .history import drop_forming_rate_bar
|
|
14
15
|
from .sdk import build_config
|
|
@@ -16,7 +17,9 @@ from .utils import coerce_login as _coerce_login
|
|
|
16
17
|
from .utils import parse_timeframe
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
|
-
from collections.abc import Iterator, Sequence
|
|
20
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
21
|
+
|
|
22
|
+
_logger = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
PositionSide = Literal["long", "short"]
|
|
22
25
|
OrderSide = Literal["BUY", "SELL"]
|
|
@@ -128,6 +131,8 @@ __all__ = [
|
|
|
128
131
|
"calculate_margin_and_volume",
|
|
129
132
|
"calculate_new_position_margin_ratio",
|
|
130
133
|
"calculate_positions_margin",
|
|
134
|
+
"calculate_positions_margin_by_symbol",
|
|
135
|
+
"calculate_positions_margin_safe",
|
|
131
136
|
"calculate_spread_ratio",
|
|
132
137
|
"calculate_volume_by_margin",
|
|
133
138
|
"close_open_positions",
|
|
@@ -423,6 +428,30 @@ def _optional_price(value: object) -> float | None:
|
|
|
423
428
|
return price
|
|
424
429
|
|
|
425
430
|
|
|
431
|
+
def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None:
|
|
432
|
+
"""Return a positive finite float from tick[key], or None if invalid.
|
|
433
|
+
|
|
434
|
+
Accepts int, float, or numeric string values. Returns None when the key is
|
|
435
|
+
missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
|
|
436
|
+
Booleans are treated as non-numeric and return None.
|
|
437
|
+
"""
|
|
438
|
+
value = tick.get(key)
|
|
439
|
+
if value is None or isinstance(value, bool):
|
|
440
|
+
return None
|
|
441
|
+
if isinstance(value, int | float):
|
|
442
|
+
price = float(value)
|
|
443
|
+
elif isinstance(value, str):
|
|
444
|
+
try:
|
|
445
|
+
price = float(value)
|
|
446
|
+
except ValueError:
|
|
447
|
+
return None
|
|
448
|
+
else:
|
|
449
|
+
return None
|
|
450
|
+
if not isfinite(price) or price <= 0:
|
|
451
|
+
return None
|
|
452
|
+
return price
|
|
453
|
+
|
|
454
|
+
|
|
426
455
|
def _success_retcodes(mt5: object) -> frozenset[int]:
|
|
427
456
|
values = {
|
|
428
457
|
value
|
|
@@ -461,9 +490,10 @@ def _calculate_min_volume_if_affordable(
|
|
|
461
490
|
msg = f"Invalid volume constraints for {symbol!r}."
|
|
462
491
|
raise Mt5TradingError(msg)
|
|
463
492
|
side = _normalize_order_side(order_side)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
493
|
+
price = _valid_tick_price(
|
|
494
|
+
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
|
495
|
+
)
|
|
496
|
+
if price is None:
|
|
467
497
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
468
498
|
raise Mt5TradingError(msg)
|
|
469
499
|
order_type = (
|
|
@@ -621,14 +651,14 @@ def estimate_order_margin(
|
|
|
621
651
|
raise Mt5TradingError(msg)
|
|
622
652
|
side = _normalize_order_side(order_side)
|
|
623
653
|
tick = get_tick_snapshot(client, symbol)
|
|
624
|
-
price = tick
|
|
625
|
-
if
|
|
654
|
+
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
|
|
655
|
+
if price is None:
|
|
626
656
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
627
657
|
raise Mt5TradingError(msg)
|
|
628
658
|
order_type = (
|
|
629
659
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
|
630
660
|
)
|
|
631
|
-
raw_margin = client.order_calc_margin(order_type, symbol, volume,
|
|
661
|
+
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
|
|
632
662
|
try:
|
|
633
663
|
margin = float(raw_margin)
|
|
634
664
|
except (TypeError, ValueError) as exc:
|
|
@@ -682,22 +712,85 @@ def calculate_positions_margin(
|
|
|
682
712
|
return total
|
|
683
713
|
|
|
684
714
|
|
|
715
|
+
def calculate_positions_margin_by_symbol(
|
|
716
|
+
client: Mt5TradingClient,
|
|
717
|
+
*,
|
|
718
|
+
symbols: Sequence[str],
|
|
719
|
+
suppress_errors: bool = True,
|
|
720
|
+
) -> dict[str, float]:
|
|
721
|
+
"""Return per-symbol estimated margin for open positions.
|
|
722
|
+
|
|
723
|
+
Computes margin for each unique input symbol independently using the strict
|
|
724
|
+
:func:`calculate_positions_margin` helper. Duplicates are deduplicated in
|
|
725
|
+
first-seen order.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
client: Connected ``Mt5TradingClient`` instance.
|
|
729
|
+
symbols: Symbols to compute margin for.
|
|
730
|
+
suppress_errors: When ``True``, log and skip symbols that raise
|
|
731
|
+
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
|
|
732
|
+
When ``False``, re-raise the first failure.
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
Mapping of symbol to margin total in first-seen unique-symbol order.
|
|
736
|
+
Returns an empty dict when ``symbols`` is empty or all symbols fail
|
|
737
|
+
with ``suppress_errors=True``.
|
|
738
|
+
|
|
739
|
+
Raises:
|
|
740
|
+
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
|
|
741
|
+
``suppress_errors=False``.
|
|
742
|
+
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
|
|
743
|
+
``suppress_errors=False``.
|
|
744
|
+
AttributeError: When a symbol raises ``AttributeError`` and
|
|
745
|
+
``suppress_errors=False``.
|
|
746
|
+
"""
|
|
747
|
+
result: dict[str, float] = {}
|
|
748
|
+
for symbol in dict.fromkeys(symbols):
|
|
749
|
+
try:
|
|
750
|
+
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
|
|
751
|
+
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
|
|
752
|
+
if not suppress_errors:
|
|
753
|
+
raise
|
|
754
|
+
_logger.warning("Skipping margin for %r: %s", symbol, exc)
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def calculate_positions_margin_safe(
|
|
759
|
+
client: Mt5TradingClient,
|
|
760
|
+
*,
|
|
761
|
+
symbols: Sequence[str],
|
|
762
|
+
) -> float:
|
|
763
|
+
"""Return the total estimated margin for open positions across symbols.
|
|
764
|
+
|
|
765
|
+
Internally calls :func:`calculate_positions_margin_by_symbol` with
|
|
766
|
+
``suppress_errors=True``. Failed symbols are silently skipped.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
client: Connected ``Mt5TradingClient`` instance.
|
|
770
|
+
symbols: Symbols to include.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
|
|
774
|
+
"""
|
|
775
|
+
return sum(
|
|
776
|
+
calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
|
|
777
|
+
0.0,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
685
781
|
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
|
686
782
|
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
|
687
783
|
|
|
688
784
|
Raises:
|
|
689
|
-
Mt5TradingError: If bid or ask is unavailable
|
|
785
|
+
Mt5TradingError: If bid or ask is unavailable.
|
|
690
786
|
"""
|
|
691
787
|
tick = get_tick_snapshot(client, symbol)
|
|
692
|
-
bid = tick
|
|
693
|
-
ask = tick
|
|
694
|
-
if
|
|
788
|
+
bid = _valid_tick_price(tick, "bid")
|
|
789
|
+
ask = _valid_tick_price(tick, "ask")
|
|
790
|
+
if bid is None or ask is None:
|
|
695
791
|
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
|
696
792
|
raise Mt5TradingError(msg)
|
|
697
|
-
|
|
698
|
-
msg = f"Tick bid/ask must be positive for {symbol!r}."
|
|
699
|
-
raise Mt5TradingError(msg)
|
|
700
|
-
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
|
|
793
|
+
return (ask - bid) / ((ask + bid) / 2.0)
|
|
701
794
|
|
|
702
795
|
|
|
703
796
|
def calculate_new_position_margin_ratio(
|
|
@@ -720,9 +813,10 @@ def calculate_new_position_margin_ratio(
|
|
|
720
813
|
margin = float(account.get("margin") or 0.0)
|
|
721
814
|
if new_position_side is not None and new_position_volume > 0:
|
|
722
815
|
side = _normalize_order_side(new_position_side)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
816
|
+
price = _valid_tick_price(
|
|
817
|
+
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
|
818
|
+
)
|
|
819
|
+
if price is None:
|
|
726
820
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
727
821
|
raise Mt5TradingError(msg)
|
|
728
822
|
order_type = (
|
|
@@ -780,28 +874,8 @@ def calculate_margin_and_volume(
|
|
|
780
874
|
"SELL",
|
|
781
875
|
)
|
|
782
876
|
else:
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
buy_volume = float(
|
|
786
|
-
cast(
|
|
787
|
-
"float | int | str",
|
|
788
|
-
native_calculate_volume(symbol, trade_margin, "BUY"),
|
|
789
|
-
),
|
|
790
|
-
)
|
|
791
|
-
sell_volume = float(
|
|
792
|
-
cast(
|
|
793
|
-
"float | int | str",
|
|
794
|
-
native_calculate_volume(symbol, trade_margin, "SELL"),
|
|
795
|
-
),
|
|
796
|
-
)
|
|
797
|
-
else:
|
|
798
|
-
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
|
799
|
-
sell_volume = calculate_volume_by_margin(
|
|
800
|
-
client,
|
|
801
|
-
symbol,
|
|
802
|
-
trade_margin,
|
|
803
|
-
"SELL",
|
|
804
|
-
)
|
|
877
|
+
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
|
878
|
+
sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
|
|
805
879
|
try:
|
|
806
880
|
symbol_info = get_symbol_snapshot(client, symbol)
|
|
807
881
|
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
|
@@ -847,8 +921,10 @@ def calculate_volume_by_margin(
|
|
|
847
921
|
msg = f"Invalid volume constraints for {symbol!r}."
|
|
848
922
|
raise Mt5TradingError(msg)
|
|
849
923
|
side = _normalize_order_side(order_side)
|
|
850
|
-
price =
|
|
851
|
-
|
|
924
|
+
price = _valid_tick_price(
|
|
925
|
+
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
|
926
|
+
)
|
|
927
|
+
if price is None:
|
|
852
928
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
853
929
|
raise Mt5TradingError(msg)
|
|
854
930
|
order_type = (
|
|
@@ -924,11 +1000,11 @@ def determine_order_limits(
|
|
|
924
1000
|
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
|
925
1001
|
normalized_side = _position_side_from_order_side(side)
|
|
926
1002
|
tick = get_tick_snapshot(client, symbol)
|
|
927
|
-
|
|
928
|
-
|
|
1003
|
+
entry_key = "ask" if normalized_side == "long" else "bid"
|
|
1004
|
+
entry = _valid_tick_price(tick, entry_key)
|
|
1005
|
+
if entry is None:
|
|
929
1006
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
930
1007
|
raise Mt5TradingError(msg)
|
|
931
|
-
entry = float(entry_value)
|
|
932
1008
|
try:
|
|
933
1009
|
symbol_info = get_symbol_snapshot(client, symbol)
|
|
934
1010
|
except (AttributeError, KeyError, TypeError, ValueError):
|
|
@@ -1004,8 +1080,8 @@ def place_market_order(
|
|
|
1004
1080
|
if not dry_run:
|
|
1005
1081
|
ensure_symbol_selected(client, symbol)
|
|
1006
1082
|
tick = get_tick_snapshot(client, symbol)
|
|
1007
|
-
price = tick
|
|
1008
|
-
if
|
|
1083
|
+
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
|
|
1084
|
+
if price is None:
|
|
1009
1085
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
1010
1086
|
raise Mt5TradingError(msg)
|
|
1011
1087
|
request = {
|
|
@@ -1015,7 +1091,7 @@ def place_market_order(
|
|
|
1015
1091
|
"type": (
|
|
1016
1092
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
|
1017
1093
|
),
|
|
1018
|
-
"price":
|
|
1094
|
+
"price": price,
|
|
1019
1095
|
"type_filling": _resolve_mt5_constant(
|
|
1020
1096
|
client.mt5,
|
|
1021
1097
|
"ORDER_FILLING",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.2"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from types import SimpleNamespace
|
|
6
7
|
from typing import Any, cast
|
|
7
8
|
from unittest.mock import MagicMock
|
|
@@ -18,9 +19,12 @@ from mt5cli.trading import (
|
|
|
18
19
|
MarginVolume,
|
|
19
20
|
OrderExecutionResult,
|
|
20
21
|
OrderLimits,
|
|
22
|
+
_valid_tick_price, # type: ignore[reportPrivateUsage]
|
|
21
23
|
calculate_margin_and_volume,
|
|
22
24
|
calculate_new_position_margin_ratio,
|
|
23
25
|
calculate_positions_margin,
|
|
26
|
+
calculate_positions_margin_by_symbol,
|
|
27
|
+
calculate_positions_margin_safe,
|
|
24
28
|
calculate_spread_ratio,
|
|
25
29
|
calculate_volume_by_margin,
|
|
26
30
|
close_open_positions,
|
|
@@ -122,12 +126,14 @@ class TestDetectPositionSide:
|
|
|
122
126
|
class TestCalculateMarginAndVolume:
|
|
123
127
|
"""Tests for calculate_margin_and_volume."""
|
|
124
128
|
|
|
125
|
-
def test_calculates_margin_budget_and_volumes(self) -> None:
|
|
129
|
+
def test_calculates_margin_budget_and_volumes(self, mocker: MockerFixture) -> None:
|
|
126
130
|
"""Test margin budget and buy/sell volumes are derived from ratios."""
|
|
127
131
|
client = MagicMock()
|
|
128
132
|
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
|
|
129
|
-
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
|
|
130
133
|
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
|
134
|
+
mock_calc_vol = mocker.patch(
|
|
135
|
+
"mt5cli.trading.calculate_volume_by_margin", side_effect=[0.3, 0.2]
|
|
136
|
+
)
|
|
131
137
|
|
|
132
138
|
result = calculate_margin_and_volume(
|
|
133
139
|
client,
|
|
@@ -146,8 +152,8 @@ class TestCalculateMarginAndVolume:
|
|
|
146
152
|
"volume_max": 0.0,
|
|
147
153
|
"volume_step": 0.0,
|
|
148
154
|
}
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "BUY")
|
|
156
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "SELL")
|
|
151
157
|
|
|
152
158
|
@pytest.mark.parametrize(
|
|
153
159
|
("account_dict", "expected_margin_free"),
|
|
@@ -161,11 +167,15 @@ class TestCalculateMarginAndVolume:
|
|
|
161
167
|
self,
|
|
162
168
|
account_dict: dict[str, float | None],
|
|
163
169
|
expected_margin_free: float,
|
|
170
|
+
mocker: MockerFixture,
|
|
164
171
|
) -> None:
|
|
165
172
|
"""Test missing or zero margin_free yields zero trade margin."""
|
|
166
173
|
client = MagicMock()
|
|
167
174
|
client.account_info_as_dict.return_value = account_dict
|
|
168
|
-
client.
|
|
175
|
+
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
|
176
|
+
mock_calc_vol = mocker.patch(
|
|
177
|
+
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
|
|
178
|
+
)
|
|
169
179
|
|
|
170
180
|
result = calculate_margin_and_volume(
|
|
171
181
|
client,
|
|
@@ -175,14 +185,19 @@ class TestCalculateMarginAndVolume:
|
|
|
175
185
|
)
|
|
176
186
|
|
|
177
187
|
assert result["margin_free"] == expected_margin_free
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
_assert_close(result["buy_volume"], 0.0)
|
|
189
|
+
_assert_close(result["sell_volume"], 0.0)
|
|
190
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
|
|
191
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
|
|
180
192
|
|
|
181
|
-
def test_clamps_negative_margin_free_to_zero(self) -> None:
|
|
193
|
+
def test_clamps_negative_margin_free_to_zero(self, mocker: MockerFixture) -> None:
|
|
182
194
|
"""Test negative margin_free is clamped to zero before sizing."""
|
|
183
195
|
client = MagicMock()
|
|
184
196
|
client.account_info_as_dict.return_value = {"margin_free": -500.0}
|
|
185
|
-
client.
|
|
197
|
+
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
|
198
|
+
mock_calc_vol = mocker.patch(
|
|
199
|
+
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
|
|
200
|
+
)
|
|
186
201
|
|
|
187
202
|
result = calculate_margin_and_volume(
|
|
188
203
|
client,
|
|
@@ -191,10 +206,11 @@ class TestCalculateMarginAndVolume:
|
|
|
191
206
|
preserved_margin_ratio=0.2,
|
|
192
207
|
)
|
|
193
208
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
_assert_close(result["margin_free"], 0.0)
|
|
210
|
+
_assert_close(result["buy_volume"], 0.0)
|
|
211
|
+
_assert_close(result["sell_volume"], 0.0)
|
|
212
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
|
|
213
|
+
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
|
|
198
214
|
|
|
199
215
|
@pytest.mark.parametrize(
|
|
200
216
|
("unit_ratio", "preserved_ratio"),
|
|
@@ -412,6 +428,55 @@ class TestDetermineOrderLimits:
|
|
|
412
428
|
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
|
413
429
|
determine_order_limits(client, "EURUSD", "long")
|
|
414
430
|
|
|
431
|
+
def test_accepts_numeric_string_entry(self) -> None:
|
|
432
|
+
"""Test numeric string ask/bid values are accepted as entry prices."""
|
|
433
|
+
client = MagicMock()
|
|
434
|
+
client.symbol_info_tick_as_dict.return_value = {
|
|
435
|
+
"ask": "1.1010",
|
|
436
|
+
"bid": "1.1000",
|
|
437
|
+
}
|
|
438
|
+
client.symbol_info_as_dict.return_value = {"digits": 4}
|
|
439
|
+
|
|
440
|
+
result = determine_order_limits(client, "EURUSD", "long")
|
|
441
|
+
|
|
442
|
+
_assert_close(result["entry"], 1.1010)
|
|
443
|
+
|
|
444
|
+
@pytest.mark.parametrize(
|
|
445
|
+
("side", "field", "bad_value"),
|
|
446
|
+
[
|
|
447
|
+
("long", "ask", float("nan")),
|
|
448
|
+
("long", "ask", float("inf")),
|
|
449
|
+
("long", "ask", float("-inf")),
|
|
450
|
+
("long", "ask", 0.0),
|
|
451
|
+
("long", "ask", -1.0),
|
|
452
|
+
("long", "ask", True),
|
|
453
|
+
("long", "ask", False),
|
|
454
|
+
("long", "ask", "invalid"),
|
|
455
|
+
("short", "bid", float("nan")),
|
|
456
|
+
("short", "bid", float("inf")),
|
|
457
|
+
("short", "bid", float("-inf")),
|
|
458
|
+
("short", "bid", 0.0),
|
|
459
|
+
("short", "bid", -1.0),
|
|
460
|
+
("short", "bid", True),
|
|
461
|
+
("short", "bid", False),
|
|
462
|
+
("short", "bid", "invalid"),
|
|
463
|
+
],
|
|
464
|
+
)
|
|
465
|
+
def test_rejects_invalid_entry_tick_values(
|
|
466
|
+
self,
|
|
467
|
+
side: str,
|
|
468
|
+
field: str,
|
|
469
|
+
bad_value: object,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Test invalid entry tick values raise Mt5TradingError."""
|
|
472
|
+
tick: dict[str, object] = {"ask": 1.1, "bid": 1.0}
|
|
473
|
+
tick[field] = bad_value
|
|
474
|
+
client = MagicMock()
|
|
475
|
+
client.symbol_info_tick_as_dict.return_value = tick
|
|
476
|
+
|
|
477
|
+
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
|
478
|
+
determine_order_limits(client, "EURUSD", side)
|
|
479
|
+
|
|
415
480
|
def test_rejects_stop_loss_inside_broker_stop_level(self) -> None:
|
|
416
481
|
"""Test stop-loss prices closer than trade_stops_level raise Mt5TradingError."""
|
|
417
482
|
client = MagicMock()
|
|
@@ -778,6 +843,50 @@ class TestSnapshotsAndState:
|
|
|
778
843
|
with pytest.raises(Mt5TradingError):
|
|
779
844
|
calculate_spread_ratio(client, "EURUSD")
|
|
780
845
|
|
|
846
|
+
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
|
|
847
|
+
"""Test numeric string bid/ask values are accepted."""
|
|
848
|
+
client = MagicMock()
|
|
849
|
+
client.symbol_info_tick_as_dict.return_value = {"bid": "99.0", "ask": "101.0"}
|
|
850
|
+
|
|
851
|
+
_assert_close(calculate_spread_ratio(client, "EURUSD"), 0.02)
|
|
852
|
+
|
|
853
|
+
@pytest.mark.parametrize(
|
|
854
|
+
("field", "bad_value"),
|
|
855
|
+
[
|
|
856
|
+
("bid", None),
|
|
857
|
+
("bid", float("nan")),
|
|
858
|
+
("bid", float("inf")),
|
|
859
|
+
("bid", float("-inf")),
|
|
860
|
+
("bid", 0.0),
|
|
861
|
+
("bid", -1.0),
|
|
862
|
+
("bid", True),
|
|
863
|
+
("bid", False),
|
|
864
|
+
("bid", "invalid"),
|
|
865
|
+
("ask", None),
|
|
866
|
+
("ask", float("nan")),
|
|
867
|
+
("ask", float("inf")),
|
|
868
|
+
("ask", float("-inf")),
|
|
869
|
+
("ask", 0.0),
|
|
870
|
+
("ask", -1.0),
|
|
871
|
+
("ask", True),
|
|
872
|
+
("ask", False),
|
|
873
|
+
("ask", "invalid"),
|
|
874
|
+
],
|
|
875
|
+
)
|
|
876
|
+
def test_calculate_spread_ratio_rejects_invalid_tick_values(
|
|
877
|
+
self,
|
|
878
|
+
field: str,
|
|
879
|
+
bad_value: object,
|
|
880
|
+
) -> None:
|
|
881
|
+
"""Test invalid bid/ask values raise Mt5TradingError."""
|
|
882
|
+
tick: dict[str, object] = {"bid": 100.0, "ask": 100.0}
|
|
883
|
+
tick[field] = bad_value
|
|
884
|
+
client = MagicMock()
|
|
885
|
+
client.symbol_info_tick_as_dict.return_value = tick
|
|
886
|
+
|
|
887
|
+
with pytest.raises(Mt5TradingError, match="Tick bid/ask is unavailable"):
|
|
888
|
+
calculate_spread_ratio(client, "EURUSD")
|
|
889
|
+
|
|
781
890
|
|
|
782
891
|
class TestNormalizeOrderVolume:
|
|
783
892
|
"""Tests for normalize_order_volume."""
|
|
@@ -1614,18 +1723,34 @@ class TestVolumeAndExecution:
|
|
|
1614
1723
|
preserved_margin_ratio=0.0,
|
|
1615
1724
|
)
|
|
1616
1725
|
|
|
1617
|
-
def
|
|
1726
|
+
def test_calculate_margin_and_volume_positive_ratio_avoids_oversized_native_volume(
|
|
1618
1727
|
self,
|
|
1619
1728
|
) -> None:
|
|
1620
|
-
"""
|
|
1729
|
+
"""Regression: module path is used even when native helper is present.
|
|
1730
|
+
|
|
1731
|
+
The pdmt5 linear estimate (floor(40/10)*0.1 = 0.4) would overstate
|
|
1732
|
+
affordable volume because order_calc_margin returns 45.0 for 0.4 lots,
|
|
1733
|
+
exceeding the 40.0 budget. The module's verified binary search returns
|
|
1734
|
+
0.3 (margin=30.0), which is safe.
|
|
1735
|
+
"""
|
|
1621
1736
|
client = _mock_trade_client()
|
|
1622
1737
|
client.account_info_as_dict.return_value = {"margin_free": 100.0}
|
|
1623
|
-
client.calculate_volume_by_margin.side_effect = [0.4, 0.3]
|
|
1624
1738
|
client.symbol_info_as_dict.return_value = {
|
|
1625
1739
|
"volume_min": 0.1,
|
|
1626
1740
|
"volume_max": 1.0,
|
|
1627
1741
|
"volume_step": 0.1,
|
|
1628
1742
|
}
|
|
1743
|
+
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
|
1744
|
+
client.calculate_volume_by_margin.return_value = 0.4 # pdmt5 linear oversized
|
|
1745
|
+
|
|
1746
|
+
def _mock_calc_margin(
|
|
1747
|
+
_order_type: int, _symbol: str, volume: float, _price: float
|
|
1748
|
+
) -> float:
|
|
1749
|
+
if volume <= 0.3:
|
|
1750
|
+
return round(volume * 100.0, 10) # 0.1→10, 0.2→20, 0.3→30
|
|
1751
|
+
return round(volume * 112.5, 10) # 0.4→45 — exceeds 40.0 budget
|
|
1752
|
+
|
|
1753
|
+
client.order_calc_margin.side_effect = _mock_calc_margin
|
|
1629
1754
|
|
|
1630
1755
|
result = calculate_margin_and_volume(
|
|
1631
1756
|
client,
|
|
@@ -1635,30 +1760,25 @@ class TestVolumeAndExecution:
|
|
|
1635
1760
|
)
|
|
1636
1761
|
|
|
1637
1762
|
_assert_close(result["trade_margin"], 40.0)
|
|
1638
|
-
_assert_close(result["buy_volume"], 0.
|
|
1763
|
+
_assert_close(result["buy_volume"], 0.3)
|
|
1639
1764
|
_assert_close(result["sell_volume"], 0.3)
|
|
1640
|
-
client.calculate_volume_by_margin.
|
|
1641
|
-
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "SELL")
|
|
1765
|
+
client.calculate_volume_by_margin.assert_not_called()
|
|
1642
1766
|
|
|
1643
|
-
def
|
|
1767
|
+
def test_calculate_margin_and_volume_positive_ratio_raises_on_missing_symbol_info(
|
|
1644
1768
|
self,
|
|
1645
1769
|
) -> None:
|
|
1646
|
-
"""Test missing symbol
|
|
1770
|
+
"""Test missing symbol info propagates an error when ratio is positive."""
|
|
1647
1771
|
client = MagicMock()
|
|
1648
1772
|
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
|
|
1649
|
-
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
|
|
1650
1773
|
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
|
1651
1774
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
_assert_close(result["volume_min"], 0.0)
|
|
1660
|
-
_assert_close(result["volume_max"], 0.0)
|
|
1661
|
-
_assert_close(result["volume_step"], 0.0)
|
|
1775
|
+
with pytest.raises(AttributeError, match="missing"):
|
|
1776
|
+
calculate_margin_and_volume(
|
|
1777
|
+
client,
|
|
1778
|
+
"EURUSD",
|
|
1779
|
+
unit_margin_ratio=0.5,
|
|
1780
|
+
preserved_margin_ratio=0.2,
|
|
1781
|
+
)
|
|
1662
1782
|
|
|
1663
1783
|
def test_new_position_margin_ratio_adds_hypothetical_margin(self) -> None:
|
|
1664
1784
|
"""Test hypothetical order margin is added to account margin."""
|
|
@@ -2956,3 +3076,206 @@ class TestFetchLatestClosedRatesIndexed:
|
|
|
2956
3076
|
assert "open" in result.columns
|
|
2957
3077
|
assert "close" in result.columns
|
|
2958
3078
|
assert isinstance(result.index, pd.DatetimeIndex)
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
class TestValidTickPrice:
|
|
3082
|
+
"""Tests for the _valid_tick_price internal helper (#49)."""
|
|
3083
|
+
|
|
3084
|
+
def test_valid_positive_float(self) -> None:
|
|
3085
|
+
"""Returns a positive float value unchanged."""
|
|
3086
|
+
_assert_close(_valid_tick_price({"bid": 1.1000}, "bid"), 1.1000)
|
|
3087
|
+
|
|
3088
|
+
def test_valid_positive_int(self) -> None:
|
|
3089
|
+
"""Accepts an integer value and returns it as float."""
|
|
3090
|
+
result = _valid_tick_price({"bid": 2}, "bid")
|
|
3091
|
+
_assert_close(result, 2.0)
|
|
3092
|
+
assert isinstance(result, float)
|
|
3093
|
+
|
|
3094
|
+
def test_valid_numeric_string(self) -> None:
|
|
3095
|
+
"""Accepts a numeric string and returns the parsed float."""
|
|
3096
|
+
_assert_close(_valid_tick_price({"bid": "1.5"}, "bid"), 1.5)
|
|
3097
|
+
|
|
3098
|
+
def test_missing_key(self) -> None:
|
|
3099
|
+
"""Returns None when the key is absent from the tick dict."""
|
|
3100
|
+
assert _valid_tick_price({}, "bid") is None
|
|
3101
|
+
|
|
3102
|
+
def test_none_value(self) -> None:
|
|
3103
|
+
"""Returns None when the stored value is None."""
|
|
3104
|
+
assert _valid_tick_price({"bid": None}, "bid") is None
|
|
3105
|
+
|
|
3106
|
+
def test_invalid_string(self) -> None:
|
|
3107
|
+
"""Returns None for a non-numeric string."""
|
|
3108
|
+
assert _valid_tick_price({"bid": "not_a_number"}, "bid") is None
|
|
3109
|
+
|
|
3110
|
+
def test_nan(self) -> None:
|
|
3111
|
+
"""Returns None for a NaN float."""
|
|
3112
|
+
assert _valid_tick_price({"bid": float("nan")}, "bid") is None
|
|
3113
|
+
|
|
3114
|
+
def test_positive_infinity(self) -> None:
|
|
3115
|
+
"""Returns None for positive infinity."""
|
|
3116
|
+
assert _valid_tick_price({"bid": float("inf")}, "bid") is None
|
|
3117
|
+
|
|
3118
|
+
def test_negative_infinity(self) -> None:
|
|
3119
|
+
"""Returns None for negative infinity."""
|
|
3120
|
+
assert _valid_tick_price({"bid": float("-inf")}, "bid") is None
|
|
3121
|
+
|
|
3122
|
+
def test_zero(self) -> None:
|
|
3123
|
+
"""Returns None for zero (not a valid price)."""
|
|
3124
|
+
assert _valid_tick_price({"bid": 0.0}, "bid") is None
|
|
3125
|
+
|
|
3126
|
+
def test_negative_value(self) -> None:
|
|
3127
|
+
"""Returns None for a negative price."""
|
|
3128
|
+
assert _valid_tick_price({"bid": -1.0}, "bid") is None
|
|
3129
|
+
|
|
3130
|
+
def test_unsupported_type(self) -> None:
|
|
3131
|
+
"""Returns None for unsupported value types such as list."""
|
|
3132
|
+
assert _valid_tick_price({"bid": [1.0]}, "bid") is None
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
class TestCalculatePositionsMarginBySymbol:
|
|
3136
|
+
"""Tests for calculate_positions_margin_by_symbol (#50)."""
|
|
3137
|
+
|
|
3138
|
+
def test_all_symbols_succeed(self, mocker: MockerFixture) -> None:
|
|
3139
|
+
"""Returns one entry per symbol in first-seen order when all calls succeed."""
|
|
3140
|
+
client = _mock_trade_client()
|
|
3141
|
+
mocker.patch(
|
|
3142
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3143
|
+
side_effect=[12.5, 30.0],
|
|
3144
|
+
)
|
|
3145
|
+
|
|
3146
|
+
result = calculate_positions_margin_by_symbol(
|
|
3147
|
+
client, symbols=["EURUSD", "GBPUSD"]
|
|
3148
|
+
)
|
|
3149
|
+
|
|
3150
|
+
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
|
|
3151
|
+
_assert_close(result["EURUSD"], 12.5)
|
|
3152
|
+
_assert_close(result["GBPUSD"], 30.0)
|
|
3153
|
+
|
|
3154
|
+
def test_one_symbol_fails_suppress_errors_true(
|
|
3155
|
+
self, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
|
3156
|
+
) -> None:
|
|
3157
|
+
"""Skips the failing symbol, emits a warning, and returns the successful one."""
|
|
3158
|
+
client = _mock_trade_client()
|
|
3159
|
+
mocker.patch(
|
|
3160
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3161
|
+
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
|
|
3162
|
+
)
|
|
3163
|
+
|
|
3164
|
+
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
|
3165
|
+
result = calculate_positions_margin_by_symbol(
|
|
3166
|
+
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
|
|
3167
|
+
)
|
|
3168
|
+
|
|
3169
|
+
assert result == {"GBPUSD": 30.0}
|
|
3170
|
+
assert any(
|
|
3171
|
+
"EURUSD" in record.getMessage() and record.levelno == logging.WARNING
|
|
3172
|
+
for record in caplog.records
|
|
3173
|
+
)
|
|
3174
|
+
|
|
3175
|
+
@pytest.mark.parametrize(
|
|
3176
|
+
"exc",
|
|
3177
|
+
[
|
|
3178
|
+
Mt5TradingError("trading error"),
|
|
3179
|
+
Mt5RuntimeError("runtime error"),
|
|
3180
|
+
AttributeError("missing attr"),
|
|
3181
|
+
],
|
|
3182
|
+
)
|
|
3183
|
+
def test_one_symbol_fails_suppress_errors_false(
|
|
3184
|
+
self, mocker: MockerFixture, exc: Exception
|
|
3185
|
+
) -> None:
|
|
3186
|
+
"""Re-raises the first failure for each caught exception type."""
|
|
3187
|
+
client = _mock_trade_client()
|
|
3188
|
+
mocker.patch(
|
|
3189
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3190
|
+
side_effect=[exc, 30.0],
|
|
3191
|
+
)
|
|
3192
|
+
|
|
3193
|
+
with pytest.raises(type(exc)):
|
|
3194
|
+
calculate_positions_margin_by_symbol(
|
|
3195
|
+
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=False
|
|
3196
|
+
)
|
|
3197
|
+
|
|
3198
|
+
def test_all_symbols_fail_suppress_errors_true(self, mocker: MockerFixture) -> None:
|
|
3199
|
+
"""Returns an empty dict when all symbols fail with suppress_errors=True."""
|
|
3200
|
+
client = _mock_trade_client()
|
|
3201
|
+
mocker.patch(
|
|
3202
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3203
|
+
side_effect=[Mt5TradingError("err1"), Mt5TradingError("err2")],
|
|
3204
|
+
)
|
|
3205
|
+
|
|
3206
|
+
result = calculate_positions_margin_by_symbol(
|
|
3207
|
+
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
|
|
3208
|
+
)
|
|
3209
|
+
|
|
3210
|
+
assert result == {}
|
|
3211
|
+
|
|
3212
|
+
def test_empty_symbol_list(self, mocker: MockerFixture) -> None:
|
|
3213
|
+
"""Returns an empty dict for an empty input list without any broker calls."""
|
|
3214
|
+
client = _mock_trade_client()
|
|
3215
|
+
mock_calc = mocker.patch("mt5cli.trading.calculate_positions_margin")
|
|
3216
|
+
result = calculate_positions_margin_by_symbol(client, symbols=[])
|
|
3217
|
+
assert result == {}
|
|
3218
|
+
mock_calc.assert_not_called()
|
|
3219
|
+
|
|
3220
|
+
def test_duplicate_symbols_preserve_first_seen_order(
|
|
3221
|
+
self, mocker: MockerFixture
|
|
3222
|
+
) -> None:
|
|
3223
|
+
"""Processes each unique symbol once in first-seen order."""
|
|
3224
|
+
client = _mock_trade_client()
|
|
3225
|
+
mock_calc = mocker.patch(
|
|
3226
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3227
|
+
return_value=12.5,
|
|
3228
|
+
)
|
|
3229
|
+
|
|
3230
|
+
result = calculate_positions_margin_by_symbol(
|
|
3231
|
+
client, symbols=["EURUSD", "GBPUSD", "EURUSD"]
|
|
3232
|
+
)
|
|
3233
|
+
|
|
3234
|
+
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
|
|
3235
|
+
assert mock_calc.call_count == 2
|
|
3236
|
+
|
|
3237
|
+
|
|
3238
|
+
class TestCalculatePositionsMarginSafe:
|
|
3239
|
+
"""Tests for calculate_positions_margin_safe (#50)."""
|
|
3240
|
+
|
|
3241
|
+
def test_returns_summed_total(self, mocker: MockerFixture) -> None:
|
|
3242
|
+
"""Returns the sum of all per-symbol margins."""
|
|
3243
|
+
client = _mock_trade_client()
|
|
3244
|
+
mocker.patch(
|
|
3245
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3246
|
+
side_effect=[12.5, 30.0],
|
|
3247
|
+
)
|
|
3248
|
+
|
|
3249
|
+
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
|
3250
|
+
|
|
3251
|
+
_assert_close(total, 42.5)
|
|
3252
|
+
|
|
3253
|
+
def test_partial_failure_skips_and_sums(self, mocker: MockerFixture) -> None:
|
|
3254
|
+
"""Sums only successful margins when one symbol raises."""
|
|
3255
|
+
client = _mock_trade_client()
|
|
3256
|
+
mocker.patch(
|
|
3257
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3258
|
+
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
|
|
3259
|
+
)
|
|
3260
|
+
|
|
3261
|
+
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
|
3262
|
+
|
|
3263
|
+
_assert_close(total, 30.0)
|
|
3264
|
+
|
|
3265
|
+
def test_all_symbols_fail_returns_zero(self, mocker: MockerFixture) -> None:
|
|
3266
|
+
"""Returns 0.0 when every symbol raises."""
|
|
3267
|
+
client = _mock_trade_client()
|
|
3268
|
+
mocker.patch(
|
|
3269
|
+
"mt5cli.trading.calculate_positions_margin",
|
|
3270
|
+
side_effect=[Mt5TradingError("err1"), Mt5RuntimeError("err2")],
|
|
3271
|
+
)
|
|
3272
|
+
|
|
3273
|
+
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
|
3274
|
+
|
|
3275
|
+
_assert_close(total, 0.0)
|
|
3276
|
+
|
|
3277
|
+
def test_empty_symbols_returns_zero(self) -> None:
|
|
3278
|
+
"""Returns 0.0 for an empty symbol list."""
|
|
3279
|
+
client = _mock_trade_client()
|
|
3280
|
+
total = calculate_positions_margin_safe(client, symbols=[])
|
|
3281
|
+
_assert_close(total, 0.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|