mt5cli 0.9.0__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.0 → mt5cli-0.9.2}/PKG-INFO +1 -1
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/public-contract.md +14 -12
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/__init__.py +4 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/contract.py +2 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/trading.py +160 -56
- {mt5cli-0.9.0 → mt5cli-0.9.2}/pyproject.toml +1 -2
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/test_trading.py +417 -33
- {mt5cli-0.9.0 → mt5cli-0.9.2}/uv.lock +1 -1
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.claude/agents/codex.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.claude/settings.json +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/FUNDING.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/dependabot.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/renovate.json +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.github/workflows/release.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/.gitignore +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/AGENTS.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/CLAUDE.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/LICENSE +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/README.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/cli.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/client.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/converters.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/exceptions.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/history.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/index.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/schemas.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/sdk.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/storage.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/trading.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/api/utils.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/docs/index.md +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mkdocs.yml +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/__main__.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/cli.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/client.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/converters.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/exceptions.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/history.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/retry.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/schemas.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/sdk.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/storage.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/mt5cli/utils.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/__init__.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/conftest.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/test_cli.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/test_contracts.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/test_history.py +0 -0
- {mt5cli-0.9.0 → mt5cli-0.9.2}/tests/test_sdk.py +0 -0
- {mt5cli-0.9.0 → 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)
|
|
@@ -830,7 +904,9 @@ def calculate_volume_by_margin(
|
|
|
830
904
|
"""Calculate max normalized volume affordable for one side.
|
|
831
905
|
|
|
832
906
|
Returns:
|
|
833
|
-
|
|
907
|
+
Largest stepped volume whose actual margin (from ``order_calc_margin``)
|
|
908
|
+
fits within ``available_margin``, rounded down to symbol volume
|
|
909
|
+
constraints; ``0.0`` when no affordable step exists.
|
|
834
910
|
|
|
835
911
|
Raises:
|
|
836
912
|
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
|
@@ -845,9 +921,10 @@ def calculate_volume_by_margin(
|
|
|
845
921
|
msg = f"Invalid volume constraints for {symbol!r}."
|
|
846
922
|
raise Mt5TradingError(msg)
|
|
847
923
|
side = _normalize_order_side(order_side)
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
924
|
+
price = _valid_tick_price(
|
|
925
|
+
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
|
926
|
+
)
|
|
927
|
+
if price is None:
|
|
851
928
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
852
929
|
raise Mt5TradingError(msg)
|
|
853
930
|
order_type = (
|
|
@@ -856,11 +933,38 @@ def calculate_volume_by_margin(
|
|
|
856
933
|
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
|
857
934
|
if min_margin <= 0 or min_margin > available_margin:
|
|
858
935
|
return 0.0
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
936
|
+
lo = 0
|
|
937
|
+
hi = int(
|
|
938
|
+
max(
|
|
939
|
+
0,
|
|
940
|
+
floor(
|
|
941
|
+
(
|
|
942
|
+
(
|
|
943
|
+
min(available_margin / min_margin * volume_min, volume_max)
|
|
944
|
+
if volume_max > 0
|
|
945
|
+
else available_margin / min_margin * volume_min
|
|
946
|
+
)
|
|
947
|
+
- volume_min
|
|
948
|
+
)
|
|
949
|
+
/ volume_step
|
|
950
|
+
+ 1e-12
|
|
951
|
+
),
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
best = -1
|
|
955
|
+
|
|
956
|
+
while lo <= hi:
|
|
957
|
+
mid = (lo + hi) // 2
|
|
958
|
+
normalized = round(volume_min + mid * volume_step, 10)
|
|
959
|
+
actual = float(client.order_calc_margin(order_type, symbol, normalized, price))
|
|
960
|
+
|
|
961
|
+
if actual > 0 and actual <= available_margin:
|
|
962
|
+
best = mid
|
|
963
|
+
lo = mid + 1
|
|
964
|
+
else:
|
|
965
|
+
hi = mid - 1
|
|
966
|
+
|
|
967
|
+
return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0
|
|
864
968
|
|
|
865
969
|
|
|
866
970
|
def determine_order_limits(
|
|
@@ -896,11 +1000,11 @@ def determine_order_limits(
|
|
|
896
1000
|
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
|
897
1001
|
normalized_side = _position_side_from_order_side(side)
|
|
898
1002
|
tick = get_tick_snapshot(client, symbol)
|
|
899
|
-
|
|
900
|
-
|
|
1003
|
+
entry_key = "ask" if normalized_side == "long" else "bid"
|
|
1004
|
+
entry = _valid_tick_price(tick, entry_key)
|
|
1005
|
+
if entry is None:
|
|
901
1006
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
902
1007
|
raise Mt5TradingError(msg)
|
|
903
|
-
entry = float(entry_value)
|
|
904
1008
|
try:
|
|
905
1009
|
symbol_info = get_symbol_snapshot(client, symbol)
|
|
906
1010
|
except (AttributeError, KeyError, TypeError, ValueError):
|
|
@@ -976,8 +1080,8 @@ def place_market_order(
|
|
|
976
1080
|
if not dry_run:
|
|
977
1081
|
ensure_symbol_selected(client, symbol)
|
|
978
1082
|
tick = get_tick_snapshot(client, symbol)
|
|
979
|
-
price = tick
|
|
980
|
-
if
|
|
1083
|
+
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
|
|
1084
|
+
if price is None:
|
|
981
1085
|
msg = f"Tick price is unavailable for {symbol!r}."
|
|
982
1086
|
raise Mt5TradingError(msg)
|
|
983
1087
|
request = {
|
|
@@ -987,7 +1091,7 @@ def place_market_order(
|
|
|
987
1091
|
"type": (
|
|
988
1092
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
|
989
1093
|
),
|
|
990
|
-
"price":
|
|
1094
|
+
"price": price,
|
|
991
1095
|
"type_filling": _resolve_mt5_constant(
|
|
992
1096
|
client.mt5,
|
|
993
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"}]
|
|
@@ -124,7 +124,6 @@ ignore = [
|
|
|
124
124
|
]
|
|
125
125
|
|
|
126
126
|
[tool.ruff.lint.per-file-ignores]
|
|
127
|
-
"mt5cli/history.py" = ["TC003"]
|
|
128
127
|
"tests/**/*.py" = [
|
|
129
128
|
"DOC201", # Missing return documentation
|
|
130
129
|
"DOC501", # Raised exception missing from docstring
|
|
@@ -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."""
|
|
@@ -1375,6 +1484,67 @@ class TestVolumeAndExecution:
|
|
|
1375
1484
|
with pytest.raises(Mt5TradingError):
|
|
1376
1485
|
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
|
|
1377
1486
|
|
|
1487
|
+
def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget(
|
|
1488
|
+
self,
|
|
1489
|
+
) -> None:
|
|
1490
|
+
"""Tiered margin: binary search returns the largest affordable step."""
|
|
1491
|
+
client = _mock_trade_client()
|
|
1492
|
+
client.symbol_info_as_dict.return_value = {
|
|
1493
|
+
"volume_min": 0.1,
|
|
1494
|
+
"volume_max": 1.0,
|
|
1495
|
+
"volume_step": 0.1,
|
|
1496
|
+
}
|
|
1497
|
+
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
|
1498
|
+
# min_margin (0.1): 25.0 -> hi=4.
|
|
1499
|
+
# Search: mid=2 (0.3)->75<=130, mid=3 (0.4)->100<=130, mid=4 (0.5)->150>130.
|
|
1500
|
+
client.order_calc_margin.side_effect = [25.0, 75.0, 100.0, 150.0]
|
|
1501
|
+
|
|
1502
|
+
result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY")
|
|
1503
|
+
|
|
1504
|
+
_assert_close(result, 0.4)
|
|
1505
|
+
|
|
1506
|
+
def test_calculate_volume_by_margin_returns_zero_when_all_steps_unaffordable(
|
|
1507
|
+
self,
|
|
1508
|
+
) -> None:
|
|
1509
|
+
"""If all binary-search probes exceed budget, returns zero."""
|
|
1510
|
+
client = _mock_trade_client()
|
|
1511
|
+
client.symbol_info_as_dict.return_value = {
|
|
1512
|
+
"volume_min": 0.1,
|
|
1513
|
+
"volume_max": 0.5,
|
|
1514
|
+
"volume_step": 0.1,
|
|
1515
|
+
}
|
|
1516
|
+
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
|
1517
|
+
# min_margin (0.1): 10.0 -> hi=4.
|
|
1518
|
+
# Search: mid=2 (0.3)->150>130->hi=1, mid=0 (0.1)->150>130->hi=-1.
|
|
1519
|
+
client.order_calc_margin.side_effect = [10.0, 150.0, 150.0]
|
|
1520
|
+
|
|
1521
|
+
result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY")
|
|
1522
|
+
|
|
1523
|
+
_assert_close(result, 0.0)
|
|
1524
|
+
|
|
1525
|
+
def test_calculate_volume_by_margin_binary_search_is_bounded(self) -> None:
|
|
1526
|
+
"""Binary search finds the largest affordable volume in O(log n) MT5 calls."""
|
|
1527
|
+
client = _mock_trade_client()
|
|
1528
|
+
client.symbol_info_as_dict.return_value = {
|
|
1529
|
+
"volume_min": 0.01,
|
|
1530
|
+
"volume_max": 1000.0,
|
|
1531
|
+
"volume_step": 0.01,
|
|
1532
|
+
}
|
|
1533
|
+
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
|
1534
|
+
# Steps 0-50000 cost 0.001 (affordable); steps 50001+ cost 2000.0 (not).
|
|
1535
|
+
# Total range: 99999 steps. Linear scan: ~50000 calls; binary search: ~17.
|
|
1536
|
+
affordable_step = 50000
|
|
1537
|
+
|
|
1538
|
+
def _margin(_ot: int, _sym: str, volume: float, _px: float) -> float:
|
|
1539
|
+
step = round((volume - 0.01) / 0.01)
|
|
1540
|
+
return 0.001 if step <= affordable_step else 2000.0
|
|
1541
|
+
|
|
1542
|
+
client.order_calc_margin.side_effect = _margin
|
|
1543
|
+
result = calculate_volume_by_margin(client, "EURUSD", 200.0, "BUY")
|
|
1544
|
+
|
|
1545
|
+
_assert_close(result, 500.01) # 0.01 + 50000 * 0.01
|
|
1546
|
+
assert client.order_calc_margin.call_count <= 25
|
|
1547
|
+
|
|
1378
1548
|
def test_calculate_margin_and_volume_without_native_helper(self) -> None:
|
|
1379
1549
|
"""Test margin helper uses module volume calculation when needed."""
|
|
1380
1550
|
|
|
@@ -1401,7 +1571,7 @@ class TestVolumeAndExecution:
|
|
|
1401
1571
|
) -> float:
|
|
1402
1572
|
assert order_type in {10, 11}
|
|
1403
1573
|
assert symbol == "EURUSD"
|
|
1404
|
-
|
|
1574
|
+
assert 0.1 <= volume <= 1.0
|
|
1405
1575
|
_assert_close(price, 100.0)
|
|
1406
1576
|
return 10.0
|
|
1407
1577
|
|
|
@@ -1553,18 +1723,34 @@ class TestVolumeAndExecution:
|
|
|
1553
1723
|
preserved_margin_ratio=0.0,
|
|
1554
1724
|
)
|
|
1555
1725
|
|
|
1556
|
-
def
|
|
1726
|
+
def test_calculate_margin_and_volume_positive_ratio_avoids_oversized_native_volume(
|
|
1557
1727
|
self,
|
|
1558
1728
|
) -> None:
|
|
1559
|
-
"""
|
|
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
|
+
"""
|
|
1560
1736
|
client = _mock_trade_client()
|
|
1561
1737
|
client.account_info_as_dict.return_value = {"margin_free": 100.0}
|
|
1562
|
-
client.calculate_volume_by_margin.side_effect = [0.4, 0.3]
|
|
1563
1738
|
client.symbol_info_as_dict.return_value = {
|
|
1564
1739
|
"volume_min": 0.1,
|
|
1565
1740
|
"volume_max": 1.0,
|
|
1566
1741
|
"volume_step": 0.1,
|
|
1567
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
|
|
1568
1754
|
|
|
1569
1755
|
result = calculate_margin_and_volume(
|
|
1570
1756
|
client,
|
|
@@ -1574,30 +1760,25 @@ class TestVolumeAndExecution:
|
|
|
1574
1760
|
)
|
|
1575
1761
|
|
|
1576
1762
|
_assert_close(result["trade_margin"], 40.0)
|
|
1577
|
-
_assert_close(result["buy_volume"], 0.
|
|
1763
|
+
_assert_close(result["buy_volume"], 0.3)
|
|
1578
1764
|
_assert_close(result["sell_volume"], 0.3)
|
|
1579
|
-
client.calculate_volume_by_margin.
|
|
1580
|
-
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "SELL")
|
|
1765
|
+
client.calculate_volume_by_margin.assert_not_called()
|
|
1581
1766
|
|
|
1582
|
-
def
|
|
1767
|
+
def test_calculate_margin_and_volume_positive_ratio_raises_on_missing_symbol_info(
|
|
1583
1768
|
self,
|
|
1584
1769
|
) -> None:
|
|
1585
|
-
"""Test missing symbol
|
|
1770
|
+
"""Test missing symbol info propagates an error when ratio is positive."""
|
|
1586
1771
|
client = MagicMock()
|
|
1587
1772
|
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
|
|
1588
|
-
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
|
|
1589
1773
|
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
|
1590
1774
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
_assert_close(result["volume_min"], 0.0)
|
|
1599
|
-
_assert_close(result["volume_max"], 0.0)
|
|
1600
|
-
_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
|
+
)
|
|
1601
1782
|
|
|
1602
1783
|
def test_new_position_margin_ratio_adds_hypothetical_margin(self) -> None:
|
|
1603
1784
|
"""Test hypothetical order margin is added to account margin."""
|
|
@@ -2895,3 +3076,206 @@ class TestFetchLatestClosedRatesIndexed:
|
|
|
2895
3076
|
assert "open" in result.columns
|
|
2896
3077
|
assert "close" in result.columns
|
|
2897
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
|