pdmt5 0.0.8__tar.gz → 0.0.9__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 (31) hide show
  1. {pdmt5-0.0.8 → pdmt5-0.0.9}/PKG-INFO +1 -1
  2. {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/dataframe.py +3 -3
  3. {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/mt5.py +18 -15
  4. {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/trading.py +53 -9
  5. {pdmt5-0.0.8 → pdmt5-0.0.9}/pyproject.toml +1 -1
  6. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_dataframe.py +13 -19
  7. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_mt5.py +5 -3
  8. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_trading.py +256 -0
  9. {pdmt5-0.0.8 → pdmt5-0.0.9}/uv.lock +1 -1
  10. {pdmt5-0.0.8 → pdmt5-0.0.9}/.claude/settings.json +0 -0
  11. {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/FUNDING.yml +0 -0
  12. {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/copilot-instructions.md +0 -0
  13. {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/dependabot.yml +0 -0
  14. {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/workflows/ci.yml +0 -0
  15. {pdmt5-0.0.8 → pdmt5-0.0.9}/.gitignore +0 -0
  16. {pdmt5-0.0.8 → pdmt5-0.0.9}/CLAUDE.md +0 -0
  17. {pdmt5-0.0.8 → pdmt5-0.0.9}/LICENSE +0 -0
  18. {pdmt5-0.0.8 → pdmt5-0.0.9}/README.md +0 -0
  19. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/dataframe.md +0 -0
  20. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/index.md +0 -0
  21. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/mt5.md +0 -0
  22. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/trading.md +0 -0
  23. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/utils.md +0 -0
  24. {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/index.md +0 -0
  25. {pdmt5-0.0.8 → pdmt5-0.0.9}/mkdocs.yml +0 -0
  26. {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/__init__.py +0 -0
  27. {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/utils.py +0 -0
  28. {pdmt5-0.0.8 → pdmt5-0.0.9}/renovate.json +0 -0
  29. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/__init__.py +0 -0
  30. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_init.py +0 -0
  31. {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdmt5
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: Pandas-based data handler for MetaTrader 5
5
5
  Project-URL: Repository, https://github.com/dceoy/pdmt5.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -86,16 +86,16 @@ class Mt5DataClient(Mt5Client):
86
86
  for i in range(1 + max(0, self.retry_count)):
87
87
  if i:
88
88
  self.logger.warning(
89
- "Retrying MetaTrader5 initialization (%d/%d)...",
89
+ "Retrying MT5 initialization (%d/%d)...",
90
90
  i,
91
91
  self.retry_count,
92
92
  )
93
93
  time.sleep(i)
94
94
  if self.initialize(**initialize_kwargs): # type: ignore[reportArgumentType]
95
- self.logger.info("MetaTrader5 initialization successful.")
95
+ self.logger.info("MT5 initialization successful.")
96
96
  return
97
97
  error_message = (
98
- f"MetaTrader5 initialization failed after {self.retry_count} retries:"
98
+ f"MT5 initialization failed after {self.retry_count} retries:"
99
99
  f" {self.last_error()}"
100
100
  )
101
101
  raise Mt5RuntimeError(error_message)
@@ -59,16 +59,20 @@ class Mt5Client(BaseModel):
59
59
  @wraps(func)
60
60
  def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
61
61
  try:
62
- result = func(self, *args, **kwargs)
62
+ response = func(self, *args, **kwargs)
63
63
  except Exception as e:
64
- error_message = f"Mt5Client operation failed: {func.__name__}"
65
- self.logger.exception(error_message)
64
+ error_message = f"MT5 {func.__name__} failed with error: {e}"
66
65
  raise Mt5RuntimeError(error_message) from e
67
66
  else:
68
- return result
67
+ self.logger.info(
68
+ "MT5 %s returned a response: %s",
69
+ func.__name__,
70
+ response,
71
+ )
72
+ return response
69
73
  finally:
70
74
  last_error_response = self.mt5.last_error()
71
- message = f"MetaTrader5 last status: {last_error_response}"
75
+ message = f"MT5 last status: {last_error_response}"
72
76
  if last_error_response[0] != self.mt5.RES_S_OK:
73
77
  self.logger.warning(message)
74
78
  else:
@@ -119,7 +123,7 @@ class Mt5Client(BaseModel):
119
123
  """
120
124
  if path is not None:
121
125
  self.logger.info(
122
- "Initializing MetaTrader5 connection with path: %s",
126
+ "Initializing MT5 connection with path: %s",
123
127
  path,
124
128
  )
125
129
  self._is_initialized = self.mt5.initialize(
@@ -137,7 +141,7 @@ class Mt5Client(BaseModel):
137
141
  },
138
142
  )
139
143
  else:
140
- self.logger.info("Initializing MetaTrader5 connection.")
144
+ self.logger.info("Initializing MT5 connection.")
141
145
  self._is_initialized = self.mt5.initialize()
142
146
  return self._is_initialized
143
147
 
@@ -161,7 +165,7 @@ class Mt5Client(BaseModel):
161
165
  True if successful, False otherwise.
162
166
  """
163
167
  self._initialize_if_needed()
164
- self.logger.info("Logging in to MetaTrader5 account: %d", login)
168
+ self.logger.info("Logging in to MT5 account: %d", login)
165
169
  return self.mt5.login(
166
170
  login,
167
171
  **{
@@ -178,7 +182,7 @@ class Mt5Client(BaseModel):
178
182
  @_log_mt5_last_status_code
179
183
  def shutdown(self) -> None:
180
184
  """Close the previously established connection to the MetaTrader 5 terminal."""
181
- self.logger.info("Shutting down MetaTrader5 connection.")
185
+ self.logger.info("Shutting down MT5 connection.")
182
186
  response = self.mt5.shutdown()
183
187
  self._is_initialized = False
184
188
  return response
@@ -191,16 +195,17 @@ class Mt5Client(BaseModel):
191
195
  Tuple of (terminal_version, build, release_date).
192
196
  """
193
197
  self._initialize_if_needed()
194
- self.logger.info("Retrieving MetaTrader5 version information.")
198
+ self.logger.info("Retrieving MT5 version information.")
195
199
  return self.mt5.version()
196
200
 
201
+ @_log_mt5_last_status_code
197
202
  def last_error(self) -> tuple[int, str]:
198
203
  """Return data on the last error.
199
204
 
200
205
  Returns:
201
206
  Tuple of (error_code, error_description).
202
207
  """
203
- self.logger.info("Retrieving last MetaTrader5 error")
208
+ self.logger.info("Retrieving last MT5 error")
204
209
  return self.mt5.last_error()
205
210
 
206
211
  @_log_mt5_last_status_code
@@ -985,10 +990,8 @@ class Mt5Client(BaseModel):
985
990
  Mt5RuntimeError: With error details from MetaTrader5.
986
991
  """
987
992
  if response is None:
988
- last_error_response = self.mt5.last_error()
989
993
  error_message = (
990
- f"MetaTrader5 {operation} returned {response}:"
991
- f" last_error={last_error_response}"
994
+ f"MT5 {operation} returned {response}:"
995
+ f" last_error={self.mt5.last_error()}"
992
996
  ) + (f" context={context}" if context else "")
993
- self.logger.error(error_message)
994
997
  raise Mt5RuntimeError(error_message)
@@ -31,7 +31,7 @@ class Mt5TradingClient(Mt5DataClient):
31
31
 
32
32
  def close_open_positions(
33
33
  self,
34
- symbols: str | list[str] | tuple[str] | None = None,
34
+ symbols: str | list[str] | tuple[str, ...] | None = None,
35
35
  **kwargs: Any, # noqa: ANN401
36
36
  ) -> dict[str, list[dict[str, Any]]]:
37
37
  """Close all open positions for specified symbols.
@@ -51,6 +51,7 @@ class Mt5TradingClient(Mt5DataClient):
51
51
  symbol_list = symbols
52
52
  else:
53
53
  symbol_list = self.symbols_get()
54
+ self.logger.info("Fetching and closing positions for symbols: %s", symbol_list)
54
55
  return {
55
56
  s: self._fetch_and_close_position(symbol=s, **kwargs) for s in symbol_list
56
57
  }
@@ -99,11 +100,17 @@ class Mt5TradingClient(Mt5DataClient):
99
100
  for p in positions_dict
100
101
  ]
101
102
 
102
- def send_or_check_order(self, request: dict[str, Any]) -> dict[str, Any]:
103
+ def send_or_check_order(
104
+ self,
105
+ request: dict[str, Any],
106
+ dry_run: bool | None = None,
107
+ ) -> dict[str, Any]:
103
108
  """Send or check an order request.
104
109
 
105
110
  Args:
106
111
  request: Order request dictionary.
112
+ dry_run: Optional flag to enable dry run mode. If None, uses the instance's
113
+ `dry_run` attribute.
107
114
 
108
115
  Returns:
109
116
  Dictionary with operation result.
@@ -112,29 +119,66 @@ class Mt5TradingClient(Mt5DataClient):
112
119
  Mt5TradingError: If the order operation fails.
113
120
  """
114
121
  self.logger.debug("request: %s", request)
115
- if self.dry_run:
122
+ is_dry_run = dry_run if dry_run is not None else self.dry_run
123
+ self.logger.debug("is_dry_run: %s", is_dry_run)
124
+ if is_dry_run:
116
125
  response = self.order_check_as_dict(request=request)
117
126
  order_func = "order_check"
118
127
  else:
119
128
  response = self.order_send_as_dict(request=request)
120
129
  order_func = "order_send"
121
130
  retcode = response.get("retcode")
122
- if ((not self.dry_run) and retcode == self.mt5.TRADE_RETCODE_DONE) or (
123
- self.dry_run and retcode == 0
131
+ if ((not is_dry_run) and retcode == self.mt5.TRADE_RETCODE_DONE) or (
132
+ is_dry_run and retcode == 0
124
133
  ):
125
- self.logger.info("retcode: %s, response: %s", retcode, response)
134
+ self.logger.info("response: %s", response)
126
135
  return response
127
136
  elif retcode in {
128
137
  self.mt5.TRADE_RETCODE_TRADE_DISABLED,
129
138
  self.mt5.TRADE_RETCODE_MARKET_CLOSED,
130
139
  }:
131
- self.logger.info("retcode: %s, response: %s", retcode, response)
140
+ self.logger.info("response: %s", response)
132
141
  comment = response.get("comment", "Unknown error")
133
142
  self.logger.warning("%s() failed and skipped. <= `%s`", order_func, comment)
134
143
  return response
135
144
  else:
136
- self.logger.error("retcode: %s, response: %s", retcode, response)
145
+ self.logger.error("response: %s", response)
137
146
  comment = response.get("comment", "Unknown error")
138
147
  error_message = f"{order_func}() failed and aborted. <= `{comment}`"
139
- self.logger.error(error_message)
148
+ raise Mt5TradingError(error_message)
149
+
150
+ def calculate_minimum_order_margins(self, symbol: str) -> dict[str, float]:
151
+ """Calculate minimum order margins for a given symbol.
152
+
153
+ Args:
154
+ symbol: Symbol for which to calculate minimum order margins.
155
+
156
+ Returns:
157
+ Dictionary with margin information.
158
+
159
+ Raises:
160
+ Mt5TradingError: If margin calculation fails.
161
+ """
162
+ symbol_info = self.symbol_info_as_dict(symbol=symbol)
163
+ symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
164
+ min_ask_order_margin = self.mt5.order_calc_margin(
165
+ action=self.mt5.ORDER_TYPE_BUY,
166
+ symbol=symbol,
167
+ volume=symbol_info["volume_min"],
168
+ price=symbol_info_tick["ask"],
169
+ )
170
+ min_bid_order_margin = self.mt5.order_calc_margin(
171
+ action=self.mt5.ORDER_TYPE_SELL,
172
+ symbol=symbol,
173
+ volume=symbol_info["volume_min"],
174
+ price=symbol_info_tick["bid"],
175
+ )
176
+ min_order_margins = {"ask": min_ask_order_margin, "bid": min_bid_order_margin}
177
+ self.logger.info("Minimum order margins for %s: %s", symbol, min_order_margins)
178
+ if all(min_order_margins.values()):
179
+ return min_order_margins
180
+ else:
181
+ error_message = (
182
+ f"Failed to calculate minimum order margins for symbol: {symbol}."
183
+ )
140
184
  raise Mt5TradingError(error_message)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdmt5"
3
- version = "0.0.8"
3
+ version = "0.0.9"
4
4
  description = "Pandas-based data handler for MetaTrader 5"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -478,7 +478,7 @@ class TestMt5DataClient:
478
478
 
479
479
  client = Mt5DataClient(mt5=mock_mt5_import, retry_count=0)
480
480
  pattern = (
481
- r"MetaTrader5 initialization failed after 0 retries: "
481
+ r"MT5 initialization failed after 0 retries: "
482
482
  r"\(1, 'Connection failed'\)"
483
483
  )
484
484
  with pytest.raises(Mt5RuntimeError, match=pattern):
@@ -933,7 +933,7 @@ class TestMt5DataClient:
933
933
  client = Mt5DataClient(mt5=mock_mt5_import)
934
934
  client.initialize()
935
935
  with pytest.raises(
936
- Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_margin"
936
+ Mt5RuntimeError, match=r"MT5 order_calc_margin returned None"
937
937
  ):
938
938
  client.order_calc_margin(0, "EURUSD", 0.0, 1.1300)
939
939
 
@@ -949,7 +949,7 @@ class TestMt5DataClient:
949
949
  client = Mt5DataClient(mt5=mock_mt5_import)
950
950
  client.initialize()
951
951
  with pytest.raises(
952
- Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_margin"
952
+ Mt5RuntimeError, match=r"MT5 order_calc_margin returned None"
953
953
  ):
954
954
  client.order_calc_margin(0, "EURUSD", 0.1, 0.0)
955
955
 
@@ -964,7 +964,7 @@ class TestMt5DataClient:
964
964
  client.initialize()
965
965
  with pytest.raises(
966
966
  Mt5RuntimeError,
967
- match=r"Mt5Client operation failed: order_calc_margin",
967
+ match=r"MT5 order_calc_margin returned None",
968
968
  ):
969
969
  client.order_calc_margin(0, "EURUSD", 0.1, 1.1300)
970
970
 
@@ -992,7 +992,7 @@ class TestMt5DataClient:
992
992
  client = Mt5DataClient(mt5=mock_mt5_import)
993
993
  client.initialize()
994
994
  with pytest.raises(
995
- Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
995
+ Mt5RuntimeError, match=r"MT5 order_calc_profit returned None"
996
996
  ):
997
997
  client.order_calc_profit(0, "EURUSD", 0.0, 1.1300, 1.1400)
998
998
 
@@ -1008,7 +1008,7 @@ class TestMt5DataClient:
1008
1008
  client = Mt5DataClient(mt5=mock_mt5_import)
1009
1009
  client.initialize()
1010
1010
  with pytest.raises(
1011
- Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
1011
+ Mt5RuntimeError, match=r"MT5 order_calc_profit returned None"
1012
1012
  ):
1013
1013
  client.order_calc_profit(0, "EURUSD", 0.1, 0.0, 1.1400)
1014
1014
 
@@ -1024,7 +1024,7 @@ class TestMt5DataClient:
1024
1024
  client = Mt5DataClient(mt5=mock_mt5_import)
1025
1025
  client.initialize()
1026
1026
  with pytest.raises(
1027
- Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
1027
+ Mt5RuntimeError, match=r"MT5 order_calc_profit returned None"
1028
1028
  ):
1029
1029
  client.order_calc_profit(0, "EURUSD", 0.1, 1.1300, 0.0)
1030
1030
 
@@ -1039,7 +1039,7 @@ class TestMt5DataClient:
1039
1039
  client.initialize()
1040
1040
  with pytest.raises(
1041
1041
  Mt5RuntimeError,
1042
- match=r"Mt5Client operation failed: order_calc_profit",
1042
+ match=r"MT5 order_calc_profit returned None",
1043
1043
  ):
1044
1044
  client.order_calc_profit(0, "EURUSD", 0.1, 1.1300, 1.1400)
1045
1045
 
@@ -1126,9 +1126,7 @@ class TestMt5DataClient:
1126
1126
 
1127
1127
  client = Mt5DataClient(mt5=mock_mt5_import)
1128
1128
  client.initialize()
1129
- with pytest.raises(
1130
- Mt5RuntimeError, match=r"Mt5Client operation failed: symbol_select"
1131
- ):
1129
+ with pytest.raises(Mt5RuntimeError, match=r"MT5 symbol_select returned None"):
1132
1130
  client.symbol_select("EURUSD")
1133
1131
 
1134
1132
  def test_market_book_add(self, mock_mt5_import: ModuleType | None) -> None:
@@ -1152,9 +1150,7 @@ class TestMt5DataClient:
1152
1150
 
1153
1151
  client = Mt5DataClient(mt5=mock_mt5_import)
1154
1152
  client.initialize()
1155
- with pytest.raises(
1156
- Mt5RuntimeError, match=r"Mt5Client operation failed: market_book_add"
1157
- ):
1153
+ with pytest.raises(Mt5RuntimeError, match=r"MT5 market_book_add returned None"):
1158
1154
  client.market_book_add("EURUSD")
1159
1155
 
1160
1156
  def test_market_book_release(self, mock_mt5_import: ModuleType | None) -> None:
@@ -1182,7 +1178,7 @@ class TestMt5DataClient:
1182
1178
  client.initialize()
1183
1179
  with pytest.raises(
1184
1180
  Mt5RuntimeError,
1185
- match=r"Mt5Client operation failed: market_book_release",
1181
+ match=r"MT5 market_book_release returned None",
1186
1182
  ):
1187
1183
  client.market_book_release("EURUSD")
1188
1184
 
@@ -1485,9 +1481,7 @@ class TestMt5DataClient:
1485
1481
 
1486
1482
  client = Mt5DataClient(mt5=mock_mt5_import)
1487
1483
  client.initialize()
1488
- with pytest.raises(
1489
- Mt5RuntimeError, match=r"Mt5Client operation failed: market_book_get"
1490
- ):
1484
+ with pytest.raises(Mt5RuntimeError, match=r"MT5 market_book_get returned None"):
1491
1485
  client.market_book_get("EURUSD")
1492
1486
 
1493
1487
  def test_shutdown_when_not_initialized(
@@ -1913,7 +1907,7 @@ class TestMt5DataClientRetryLogic:
1913
1907
  with pytest.raises(Mt5RuntimeError) as exc_info:
1914
1908
  client.initialize_mt5()
1915
1909
 
1916
- assert "MetaTrader5 initialization failed after" in str(exc_info.value)
1910
+ assert "MT5 initialization failed after" in str(exc_info.value)
1917
1911
  assert mock_mt5_import.initialize.call_count == 3 # All attempts made
1918
1912
  # Check that sleep was called for retries
1919
1913
  assert mock_sleep.call_count == 2
@@ -119,10 +119,12 @@ class TestMt5Client:
119
119
 
120
120
  def test_last_error(self, client: Mt5Client, mock_mt5: Mock) -> None:
121
121
  """Test last_error method."""
122
+ mock_mt5.last_error.return_value = (1001, "Test error")
122
123
  error = client.last_error()
123
124
 
124
125
  assert error == (1001, "Test error")
125
- mock_mt5.last_error.assert_called_once()
126
+ # last_error is called twice: once by the method, once by the decorator
127
+ assert mock_mt5.last_error.call_count == 2
126
128
 
127
129
  def test_initialize_if_needed_calls_initialize(
128
130
  self, client: Mt5Client, mock_mt5: Mock
@@ -211,7 +213,7 @@ class TestMt5Client:
211
213
  with pytest.raises(Mt5RuntimeError) as exc_info:
212
214
  initialized_client.symbols_get()
213
215
 
214
- assert "Mt5Client operation failed: symbols_get" in str(exc_info.value)
216
+ assert "MT5 symbols_get returned None" in str(exc_info.value)
215
217
 
216
218
  def test_symbol_info(
217
219
  self,
@@ -645,7 +647,7 @@ class TestMt5Client:
645
647
  initialized_client.symbol_info("EURUSD")
646
648
 
647
649
  error_msg = str(exc_info.value)
648
- assert "Mt5Client operation failed: symbol_info" in error_msg
650
+ assert "MT5 symbol_info returned None" in error_msg
649
651
 
650
652
  def test_default_mt5_import(self, mock_metatrader5_import: MockerFixture) -> None:
651
653
  """Test default MetaTrader5 module import."""
@@ -49,9 +49,11 @@ def mock_mt5_import(
49
49
  mock_mt5.terminal_info = mocker.MagicMock() # type: ignore[attr-defined]
50
50
  mock_mt5.symbols_get = mocker.MagicMock() # type: ignore[attr-defined]
51
51
  mock_mt5.symbol_info = mocker.MagicMock() # type: ignore[attr-defined]
52
+ mock_mt5.symbol_info_tick = mocker.MagicMock() # type: ignore[attr-defined]
52
53
  mock_mt5.positions_get = mocker.MagicMock() # type: ignore[attr-defined]
53
54
  mock_mt5.order_check = mocker.MagicMock() # type: ignore[attr-defined]
54
55
  mock_mt5.order_send = mocker.MagicMock() # type: ignore[attr-defined]
56
+ mock_mt5.order_calc_margin = mocker.MagicMock() # type: ignore[attr-defined]
55
57
 
56
58
  # Trading-specific constants
57
59
  mock_mt5.TRADE_ACTION_DEAL = 1
@@ -265,6 +267,58 @@ class TestMt5TradingClient:
265
267
  assert "EURUSD" in result
266
268
  assert result["EURUSD"] == []
267
269
 
270
+ def test_close_open_positions_tuple_input(
271
+ self,
272
+ mock_mt5_import: ModuleType,
273
+ ) -> None:
274
+ """Test close_open_positions with tuple input."""
275
+ client = Mt5TradingClient(mt5=mock_mt5_import)
276
+ mock_mt5_import.initialize.return_value = True
277
+ client.initialize()
278
+
279
+ # Mock empty positions
280
+ mock_mt5_import.positions_get.return_value = []
281
+
282
+ result = client.close_open_positions(("EURUSD", "GBPUSD"))
283
+
284
+ assert len(result) == 2
285
+ assert "EURUSD" in result
286
+ assert "GBPUSD" in result
287
+ assert result["EURUSD"] == []
288
+ assert result["GBPUSD"] == []
289
+
290
+ def test_close_open_positions_with_kwargs(
291
+ self,
292
+ mock_mt5_import: ModuleType,
293
+ mock_position_buy: MockPositionInfo,
294
+ ) -> None:
295
+ """Test close_open_positions with additional kwargs."""
296
+ client = Mt5TradingClient(mt5=mock_mt5_import)
297
+ mock_mt5_import.initialize.return_value = True
298
+ client.initialize()
299
+
300
+ # Mock positions
301
+ mock_mt5_import.positions_get.return_value = [mock_position_buy]
302
+
303
+ mock_mt5_import.order_send.return_value.retcode = 10009
304
+ mock_mt5_import.order_send.return_value._asdict.return_value = {
305
+ "retcode": 10009,
306
+ "result": "success",
307
+ }
308
+
309
+ # Pass custom kwargs
310
+ result = client.close_open_positions(
311
+ "EURUSD", comment="custom_close", magic=12345
312
+ )
313
+
314
+ assert len(result["EURUSD"]) == 1
315
+ assert result["EURUSD"][0]["retcode"] == 10009
316
+
317
+ # Check that kwargs were passed through
318
+ call_args = mock_mt5_import.order_send.call_args[0][0]
319
+ assert call_args["comment"] == "custom_close"
320
+ assert call_args["magic"] == 12345
321
+
268
322
  def test_send_or_check_order_dry_run_success(
269
323
  self,
270
324
  mock_mt5_import: ModuleType,
@@ -431,6 +485,72 @@ class TestMt5TradingClient:
431
485
  ):
432
486
  client.send_or_check_order(request)
433
487
 
488
+ def test_send_or_check_order_dry_run_override(
489
+ self,
490
+ mock_mt5_import: ModuleType,
491
+ ) -> None:
492
+ """Test send_or_check_order with dry_run parameter override."""
493
+ # Client initialized with dry_run=False
494
+ client = Mt5TradingClient(mt5=mock_mt5_import, dry_run=False)
495
+ mock_mt5_import.initialize.return_value = True
496
+ client.initialize()
497
+
498
+ request = {
499
+ "action": 1,
500
+ "symbol": "EURUSD",
501
+ "volume": 0.1,
502
+ "type": 1,
503
+ }
504
+
505
+ # Mock successful order check
506
+ mock_mt5_import.order_check.return_value.retcode = 0
507
+ mock_mt5_import.order_check.return_value._asdict.return_value = {
508
+ "retcode": 0,
509
+ "result": "check_success",
510
+ }
511
+
512
+ # Override with dry_run=True
513
+ result = client.send_or_check_order(request, dry_run=True)
514
+
515
+ assert result["retcode"] == 0
516
+ assert result["result"] == "check_success"
517
+ # Should call order_check, not order_send
518
+ mock_mt5_import.order_check.assert_called_once_with(request)
519
+ mock_mt5_import.order_send.assert_not_called()
520
+
521
+ def test_send_or_check_order_real_mode_override(
522
+ self,
523
+ mock_mt5_import: ModuleType,
524
+ ) -> None:
525
+ """Test send_or_check_order with real mode override."""
526
+ # Client initialized with dry_run=True
527
+ client = Mt5TradingClient(mt5=mock_mt5_import, dry_run=True)
528
+ mock_mt5_import.initialize.return_value = True
529
+ client.initialize()
530
+
531
+ request = {
532
+ "action": 1,
533
+ "symbol": "EURUSD",
534
+ "volume": 0.1,
535
+ "type": 1,
536
+ }
537
+
538
+ # Mock successful order send
539
+ mock_mt5_import.order_send.return_value.retcode = 10009
540
+ mock_mt5_import.order_send.return_value._asdict.return_value = {
541
+ "retcode": 10009,
542
+ "result": "send_success",
543
+ }
544
+
545
+ # Override with dry_run=False
546
+ result = client.send_or_check_order(request, dry_run=False)
547
+
548
+ assert result["retcode"] == 10009
549
+ assert result["result"] == "send_success"
550
+ # Should call order_send, not order_check
551
+ mock_mt5_import.order_send.assert_called_once_with(request)
552
+ mock_mt5_import.order_check.assert_not_called()
553
+
434
554
  def test_order_filling_mode_constants(
435
555
  self,
436
556
  mock_mt5_import: ModuleType,
@@ -490,3 +610,139 @@ class TestMt5TradingClient:
490
610
  # Sell position should result in buy order
491
611
  call_args = mock_mt5_import.order_send.call_args[0][0]
492
612
  assert call_args["type"] == mock_mt5_import.ORDER_TYPE_BUY
613
+
614
+ def test_calculate_minimum_order_margins_success(
615
+ self,
616
+ mock_mt5_import: ModuleType,
617
+ ) -> None:
618
+ """Test successful calculation of minimum order margins."""
619
+ client = Mt5TradingClient(mt5=mock_mt5_import)
620
+ mock_mt5_import.initialize.return_value = True
621
+ client.initialize()
622
+
623
+ # Mock symbol info
624
+ mock_mt5_import.symbol_info.return_value._asdict.return_value = {
625
+ "volume_min": 0.01,
626
+ "name": "EURUSD",
627
+ }
628
+
629
+ # Mock symbol tick info
630
+ mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
631
+ "ask": 1.1000,
632
+ "bid": 1.0998,
633
+ }
634
+
635
+ # Mock order_calc_margin to return successful results
636
+ mock_mt5_import.order_calc_margin.side_effect = [100.5, 99.8]
637
+
638
+ result = client.calculate_minimum_order_margins("EURUSD")
639
+
640
+ assert result == {"ask": 100.5, "bid": 99.8}
641
+ assert mock_mt5_import.order_calc_margin.call_count == 2
642
+
643
+ # Verify first call (buy order)
644
+ first_call = mock_mt5_import.order_calc_margin.call_args_list[0]
645
+ assert first_call[1]["action"] == mock_mt5_import.ORDER_TYPE_BUY
646
+ assert first_call[1]["symbol"] == "EURUSD"
647
+ assert first_call[1]["volume"] == 0.01
648
+ assert first_call[1]["price"] == 1.1000
649
+
650
+ # Verify second call (sell order)
651
+ second_call = mock_mt5_import.order_calc_margin.call_args_list[1]
652
+ assert second_call[1]["action"] == mock_mt5_import.ORDER_TYPE_SELL
653
+ assert second_call[1]["symbol"] == "EURUSD"
654
+ assert second_call[1]["volume"] == 0.01
655
+ assert second_call[1]["price"] == 1.0998
656
+
657
+ def test_calculate_minimum_order_margins_failure_ask(
658
+ self,
659
+ mock_mt5_import: ModuleType,
660
+ ) -> None:
661
+ """Test failed calculation of minimum order margins - ask margin fails."""
662
+ client = Mt5TradingClient(mt5=mock_mt5_import)
663
+ mock_mt5_import.initialize.return_value = True
664
+ client.initialize()
665
+
666
+ # Mock symbol info
667
+ mock_mt5_import.symbol_info.return_value._asdict.return_value = {
668
+ "volume_min": 0.01,
669
+ "name": "EURUSD",
670
+ }
671
+
672
+ # Mock symbol tick info
673
+ mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
674
+ "ask": 1.1000,
675
+ "bid": 1.0998,
676
+ }
677
+
678
+ # Mock order_calc_margin to return None for ask margin
679
+ mock_mt5_import.order_calc_margin.side_effect = [None, 99.8]
680
+
681
+ with pytest.raises(Mt5TradingError) as exc_info:
682
+ client.calculate_minimum_order_margins("EURUSD")
683
+
684
+ assert "Failed to calculate minimum order margins for symbol: EURUSD" in str(
685
+ exc_info.value
686
+ )
687
+
688
+ def test_calculate_minimum_order_margins_failure_bid(
689
+ self,
690
+ mock_mt5_import: ModuleType,
691
+ ) -> None:
692
+ """Test failed calculation of minimum order margins - bid margin fails."""
693
+ client = Mt5TradingClient(mt5=mock_mt5_import)
694
+ mock_mt5_import.initialize.return_value = True
695
+ client.initialize()
696
+
697
+ # Mock symbol info
698
+ mock_mt5_import.symbol_info.return_value._asdict.return_value = {
699
+ "volume_min": 0.01,
700
+ "name": "EURUSD",
701
+ }
702
+
703
+ # Mock symbol tick info
704
+ mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
705
+ "ask": 1.1000,
706
+ "bid": 1.0998,
707
+ }
708
+
709
+ # Mock order_calc_margin to return None for bid margin
710
+ mock_mt5_import.order_calc_margin.side_effect = [100.5, None]
711
+
712
+ with pytest.raises(Mt5TradingError) as exc_info:
713
+ client.calculate_minimum_order_margins("EURUSD")
714
+
715
+ assert "Failed to calculate minimum order margins for symbol: EURUSD" in str(
716
+ exc_info.value
717
+ )
718
+
719
+ def test_calculate_minimum_order_margins_failure_both(
720
+ self,
721
+ mock_mt5_import: ModuleType,
722
+ ) -> None:
723
+ """Test failed calculation of minimum order margins - both margins fail."""
724
+ client = Mt5TradingClient(mt5=mock_mt5_import)
725
+ mock_mt5_import.initialize.return_value = True
726
+ client.initialize()
727
+
728
+ # Mock symbol info
729
+ mock_mt5_import.symbol_info.return_value._asdict.return_value = {
730
+ "volume_min": 0.01,
731
+ "name": "EURUSD",
732
+ }
733
+
734
+ # Mock symbol tick info
735
+ mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
736
+ "ask": 1.1000,
737
+ "bid": 1.0998,
738
+ }
739
+
740
+ # Mock order_calc_margin to return None for both margins
741
+ mock_mt5_import.order_calc_margin.side_effect = [None, None]
742
+
743
+ with pytest.raises(Mt5TradingError) as exc_info:
744
+ client.calculate_minimum_order_margins("EURUSD")
745
+
746
+ assert "Failed to calculate minimum order margins for symbol: EURUSD" in str(
747
+ exc_info.value
748
+ )
@@ -613,7 +613,7 @@ wheels = [
613
613
 
614
614
  [[package]]
615
615
  name = "pdmt5"
616
- version = "0.0.8"
616
+ version = "0.0.9"
617
617
  source = { editable = "." }
618
618
  dependencies = [
619
619
  { name = "metatrader5", marker = "sys_platform == 'win32'" },
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