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.
Files changed (56) hide show
  1. {mt5cli-0.9.1 → mt5cli-0.9.2}/PKG-INFO +1 -1
  2. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/public-contract.md +14 -12
  3. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/__init__.py +4 -0
  4. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/contract.py +2 -0
  5. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/trading.py +125 -49
  6. {mt5cli-0.9.1 → mt5cli-0.9.2}/pyproject.toml +1 -1
  7. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_trading.py +355 -32
  8. {mt5cli-0.9.1 → mt5cli-0.9.2}/uv.lock +1 -1
  9. {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/local-qa/SKILL.md +0 -0
  10. {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  11. {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/mt5cli/SKILL.md +0 -0
  12. {mt5cli-0.9.1 → mt5cli-0.9.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  13. {mt5cli-0.9.1 → mt5cli-0.9.2}/.claude/agents/codex.md +0 -0
  14. {mt5cli-0.9.1 → mt5cli-0.9.2}/.claude/settings.json +0 -0
  15. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/FUNDING.yml +0 -0
  16. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/dependabot.yml +0 -0
  17. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/renovate.json +0 -0
  18. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/ci.yml +0 -0
  19. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/claude.yml +0 -0
  20. {mt5cli-0.9.1 → mt5cli-0.9.2}/.github/workflows/release.yml +0 -0
  21. {mt5cli-0.9.1 → mt5cli-0.9.2}/.gitignore +0 -0
  22. {mt5cli-0.9.1 → mt5cli-0.9.2}/AGENTS.md +0 -0
  23. {mt5cli-0.9.1 → mt5cli-0.9.2}/CLAUDE.md +0 -0
  24. {mt5cli-0.9.1 → mt5cli-0.9.2}/LICENSE +0 -0
  25. {mt5cli-0.9.1 → mt5cli-0.9.2}/README.md +0 -0
  26. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/cli.md +0 -0
  27. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/client.md +0 -0
  28. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/converters.md +0 -0
  29. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/exceptions.md +0 -0
  30. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/history.md +0 -0
  31. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/index.md +0 -0
  32. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/schemas.md +0 -0
  33. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/sdk.md +0 -0
  34. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/storage.md +0 -0
  35. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/trading.md +0 -0
  36. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/api/utils.md +0 -0
  37. {mt5cli-0.9.1 → mt5cli-0.9.2}/docs/index.md +0 -0
  38. {mt5cli-0.9.1 → mt5cli-0.9.2}/mkdocs.yml +0 -0
  39. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/__main__.py +0 -0
  40. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/cli.py +0 -0
  41. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/client.py +0 -0
  42. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/converters.py +0 -0
  43. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/exceptions.py +0 -0
  44. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/history.py +0 -0
  45. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/retry.py +0 -0
  46. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/schemas.py +0 -0
  47. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/sdk.py +0 -0
  48. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/storage.py +0 -0
  49. {mt5cli-0.9.1 → mt5cli-0.9.2}/mt5cli/utils.py +0 -0
  50. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/__init__.py +0 -0
  51. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/conftest.py +0 -0
  52. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_cli.py +0 -0
  53. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_contracts.py +0 -0
  54. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_history.py +0 -0
  55. {mt5cli-0.9.1 → mt5cli-0.9.2}/tests/test_sdk.py +0 -0
  56. {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.1
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
- | `determine_order_limits` | SL/TP price levels from ratios |
110
- | `ensure_symbol_selected` | Select/verify Market Watch visibility |
111
- | `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
112
- | `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
113
- | `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
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
- tick = get_tick_snapshot(client, symbol)
465
- price = tick["ask"] if side == "BUY" else tick["bid"]
466
- if not isinstance(price, int | float) or price <= 0:
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["ask"] if side == "BUY" else tick["bid"]
625
- if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
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, float(price))
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 or non-positive.
785
+ Mt5TradingError: If bid or ask is unavailable.
690
786
  """
691
787
  tick = get_tick_snapshot(client, symbol)
692
- bid = tick.get("bid")
693
- ask = tick.get("ask")
694
- if not isinstance(bid, int | float) or not isinstance(ask, int | float):
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
- if bid <= 0 or ask <= 0:
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
- tick = get_tick_snapshot(client, symbol)
724
- price = tick["ask"] if side == "BUY" else tick["bid"]
725
- if not isinstance(price, int | float) or price <= 0:
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
- native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
784
- if callable(native_calculate_volume):
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 = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"]
851
- if not isinstance(price, int | float) or price <= 0:
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
- entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
928
- if not isinstance(entry_value, int | float):
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["ask"] if side == "BUY" else tick["bid"]
1008
- if not isinstance(price, int | float) or price <= 0:
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": float(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.1"
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
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY")
150
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL")
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.calculate_volume_by_margin.return_value = 0.0
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
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
179
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
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.calculate_volume_by_margin.return_value = 0.0
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
- expected_margin_free = 0.0
195
- assert result["margin_free"] == expected_margin_free
196
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
197
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
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 test_calculate_margin_and_volume_positive_ratio_uses_existing_behavior(
1726
+ def test_calculate_margin_and_volume_positive_ratio_avoids_oversized_native_volume(
1618
1727
  self,
1619
1728
  ) -> None:
1620
- """Test positive unit ratios keep proportional native sizing."""
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.4)
1763
+ _assert_close(result["buy_volume"], 0.3)
1639
1764
  _assert_close(result["sell_volume"], 0.3)
1640
- client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "BUY")
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 test_calculate_margin_and_volume_handles_missing_symbol_snapshot(
1767
+ def test_calculate_margin_and_volume_positive_ratio_raises_on_missing_symbol_info(
1644
1768
  self,
1645
1769
  ) -> None:
1646
- """Test missing symbol metadata falls back to zero volume constraints."""
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
- result = calculate_margin_and_volume(
1653
- client,
1654
- "EURUSD",
1655
- unit_margin_ratio=0.5,
1656
- preserved_margin_ratio=0.2,
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)
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.9.1"
490
+ version = "0.9.2"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
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