pdmt5 0.0.7__tar.gz → 0.0.8__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.
- {pdmt5-0.0.7 → pdmt5-0.0.8}/PKG-INFO +1 -1
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pdmt5/__init__.py +2 -1
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pdmt5/mt5.py +133 -78
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pyproject.toml +2 -2
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/test_dataframe.py +22 -20
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/test_init.py +6 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/test_mt5.py +21 -16
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/test_trading.py +1 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/uv.lock +1 -1
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.claude/settings.json +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.github/FUNDING.yml +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.github/copilot-instructions.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.github/dependabot.yml +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.github/workflows/ci.yml +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/.gitignore +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/CLAUDE.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/LICENSE +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/README.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/api/dataframe.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/api/index.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/api/mt5.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/api/trading.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/api/utils.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/docs/index.md +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/mkdocs.yml +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pdmt5/dataframe.py +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pdmt5/trading.py +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/pdmt5/utils.py +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/renovate.json +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/__init__.py +0 -0
- {pdmt5-0.0.7 → pdmt5-0.0.8}/test/test_utils.py +0 -0
|
@@ -4,7 +4,7 @@ from importlib.metadata import version
|
|
|
4
4
|
|
|
5
5
|
from .dataframe import Mt5Config, Mt5DataClient
|
|
6
6
|
from .mt5 import Mt5Client, Mt5RuntimeError
|
|
7
|
-
from .trading import Mt5TradingClient
|
|
7
|
+
from .trading import Mt5TradingClient, Mt5TradingError
|
|
8
8
|
|
|
9
9
|
__version__ = version(__package__) if __package__ else None
|
|
10
10
|
|
|
@@ -14,4 +14,5 @@ __all__ = [
|
|
|
14
14
|
"Mt5DataClient",
|
|
15
15
|
"Mt5RuntimeError",
|
|
16
16
|
"Mt5TradingClient",
|
|
17
|
+
"Mt5TradingError",
|
|
17
18
|
]
|
|
@@ -6,12 +6,14 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import importlib
|
|
8
8
|
import logging
|
|
9
|
+
from functools import wraps
|
|
9
10
|
from types import ModuleType # noqa: TC003
|
|
10
11
|
from typing import TYPE_CHECKING, Any, Self
|
|
11
12
|
|
|
12
13
|
from pydantic import BaseModel, ConfigDict, Field
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
15
17
|
from datetime import datetime
|
|
16
18
|
from types import TracebackType
|
|
17
19
|
|
|
@@ -43,6 +45,37 @@ class Mt5Client(BaseModel):
|
|
|
43
45
|
)
|
|
44
46
|
_is_initialized: bool = False
|
|
45
47
|
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _log_mt5_last_status_code(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
50
|
+
"""Decorator to log MetaTrader5 last status code after method execution.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
func: The method to decorate.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The decorated method.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@wraps(func)
|
|
60
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
61
|
+
try:
|
|
62
|
+
result = func(self, *args, **kwargs)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
error_message = f"Mt5Client operation failed: {func.__name__}"
|
|
65
|
+
self.logger.exception(error_message)
|
|
66
|
+
raise Mt5RuntimeError(error_message) from e
|
|
67
|
+
else:
|
|
68
|
+
return result
|
|
69
|
+
finally:
|
|
70
|
+
last_error_response = self.mt5.last_error()
|
|
71
|
+
message = f"MetaTrader5 last status: {last_error_response}"
|
|
72
|
+
if last_error_response[0] != self.mt5.RES_S_OK:
|
|
73
|
+
self.logger.warning(message)
|
|
74
|
+
else:
|
|
75
|
+
self.logger.info(message)
|
|
76
|
+
|
|
77
|
+
return wrapper
|
|
78
|
+
|
|
46
79
|
def __enter__(self) -> Self:
|
|
47
80
|
"""Context manager entry.
|
|
48
81
|
|
|
@@ -61,6 +94,7 @@ class Mt5Client(BaseModel):
|
|
|
61
94
|
"""Context manager exit."""
|
|
62
95
|
self.shutdown()
|
|
63
96
|
|
|
97
|
+
@_log_mt5_last_status_code
|
|
64
98
|
def initialize(
|
|
65
99
|
self,
|
|
66
100
|
path: str | None = None,
|
|
@@ -83,9 +117,7 @@ class Mt5Client(BaseModel):
|
|
|
83
117
|
Returns:
|
|
84
118
|
True if successful, False otherwise.
|
|
85
119
|
"""
|
|
86
|
-
if
|
|
87
|
-
self.logger.warning("Skipping initialization, already initialized.")
|
|
88
|
-
elif path is not None:
|
|
120
|
+
if path is not None:
|
|
89
121
|
self.logger.info(
|
|
90
122
|
"Initializing MetaTrader5 connection with path: %s",
|
|
91
123
|
path,
|
|
@@ -109,6 +141,7 @@ class Mt5Client(BaseModel):
|
|
|
109
141
|
self._is_initialized = self.mt5.initialize()
|
|
110
142
|
return self._is_initialized
|
|
111
143
|
|
|
144
|
+
@_log_mt5_last_status_code
|
|
112
145
|
def login(
|
|
113
146
|
self,
|
|
114
147
|
login: int,
|
|
@@ -127,7 +160,7 @@ class Mt5Client(BaseModel):
|
|
|
127
160
|
Returns:
|
|
128
161
|
True if successful, False otherwise.
|
|
129
162
|
"""
|
|
130
|
-
self.
|
|
163
|
+
self._initialize_if_needed()
|
|
131
164
|
self.logger.info("Logging in to MetaTrader5 account: %d", login)
|
|
132
165
|
return self.mt5.login(
|
|
133
166
|
login,
|
|
@@ -142,24 +175,22 @@ class Mt5Client(BaseModel):
|
|
|
142
175
|
},
|
|
143
176
|
)
|
|
144
177
|
|
|
178
|
+
@_log_mt5_last_status_code
|
|
145
179
|
def shutdown(self) -> None:
|
|
146
180
|
"""Close the previously established connection to the MetaTrader 5 terminal."""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
else:
|
|
152
|
-
self.logger.warning(
|
|
153
|
-
"MetaTrader5 connection is not initialized, nothing to shut down"
|
|
154
|
-
)
|
|
181
|
+
self.logger.info("Shutting down MetaTrader5 connection.")
|
|
182
|
+
response = self.mt5.shutdown()
|
|
183
|
+
self._is_initialized = False
|
|
184
|
+
return response
|
|
155
185
|
|
|
186
|
+
@_log_mt5_last_status_code
|
|
156
187
|
def version(self) -> tuple[int, int, str]:
|
|
157
188
|
"""Return the MetaTrader 5 terminal version.
|
|
158
189
|
|
|
159
190
|
Returns:
|
|
160
191
|
Tuple of (terminal_version, build, release_date).
|
|
161
192
|
"""
|
|
162
|
-
self.
|
|
193
|
+
self._initialize_if_needed()
|
|
163
194
|
self.logger.info("Retrieving MetaTrader5 version information.")
|
|
164
195
|
return self.mt5.version()
|
|
165
196
|
|
|
@@ -172,43 +203,49 @@ class Mt5Client(BaseModel):
|
|
|
172
203
|
self.logger.info("Retrieving last MetaTrader5 error")
|
|
173
204
|
return self.mt5.last_error()
|
|
174
205
|
|
|
206
|
+
@_log_mt5_last_status_code
|
|
175
207
|
def account_info(self) -> Any:
|
|
176
208
|
"""Get info on the current trading account.
|
|
177
209
|
|
|
178
210
|
Returns:
|
|
179
211
|
AccountInfo structure or None.
|
|
180
212
|
"""
|
|
181
|
-
self.
|
|
213
|
+
self._initialize_if_needed()
|
|
182
214
|
self.logger.info("Retrieving account information.")
|
|
183
215
|
response = self.mt5.account_info()
|
|
184
|
-
self.
|
|
216
|
+
self._validate_mt5_response_is_not_none(
|
|
217
|
+
response=response, operation="account_info"
|
|
218
|
+
)
|
|
185
219
|
return response
|
|
186
220
|
|
|
221
|
+
@_log_mt5_last_status_code
|
|
187
222
|
def terminal_info(self) -> Any:
|
|
188
223
|
"""Get the connected MetaTrader 5 client terminal status and settings.
|
|
189
224
|
|
|
190
225
|
Returns:
|
|
191
226
|
TerminalInfo structure or None.
|
|
192
227
|
"""
|
|
193
|
-
self.
|
|
228
|
+
self._initialize_if_needed()
|
|
194
229
|
self.logger.info("Retrieving terminal information.")
|
|
195
230
|
response = self.mt5.terminal_info()
|
|
196
|
-
self.
|
|
231
|
+
self._validate_mt5_response_is_not_none(
|
|
197
232
|
response=response,
|
|
198
233
|
operation="terminal_info",
|
|
199
234
|
)
|
|
200
235
|
return response
|
|
201
236
|
|
|
237
|
+
@_log_mt5_last_status_code
|
|
202
238
|
def symbols_total(self) -> int:
|
|
203
239
|
"""Get the number of all financial instruments in the terminal.
|
|
204
240
|
|
|
205
241
|
Returns:
|
|
206
242
|
Total number of symbols.
|
|
207
243
|
"""
|
|
208
|
-
self.
|
|
244
|
+
self._initialize_if_needed()
|
|
209
245
|
self.logger.info("Retrieving total number of symbols.")
|
|
210
246
|
return self.mt5.symbols_total()
|
|
211
247
|
|
|
248
|
+
@_log_mt5_last_status_code
|
|
212
249
|
def symbols_get(self, group: str | None = None) -> tuple[Any, ...]:
|
|
213
250
|
"""Get all financial instruments from the terminal.
|
|
214
251
|
|
|
@@ -218,7 +255,7 @@ class Mt5Client(BaseModel):
|
|
|
218
255
|
Returns:
|
|
219
256
|
Tuple of symbol info structures or None.
|
|
220
257
|
"""
|
|
221
|
-
self.
|
|
258
|
+
self._initialize_if_needed()
|
|
222
259
|
if group is not None:
|
|
223
260
|
self.logger.info("Retrieving symbols for group: %s", group)
|
|
224
261
|
response = self.mt5.symbols_get(group=group)
|
|
@@ -227,13 +264,14 @@ class Mt5Client(BaseModel):
|
|
|
227
264
|
self.logger.info("Retrieving all symbols.")
|
|
228
265
|
response = self.mt5.symbols_get()
|
|
229
266
|
context = None
|
|
230
|
-
self.
|
|
267
|
+
self._validate_mt5_response_is_not_none(
|
|
231
268
|
response=response,
|
|
232
269
|
operation="symbols_get",
|
|
233
270
|
context=context,
|
|
234
271
|
)
|
|
235
272
|
return response
|
|
236
273
|
|
|
274
|
+
@_log_mt5_last_status_code
|
|
237
275
|
def symbol_info(self, symbol: str) -> Any:
|
|
238
276
|
"""Get data on the specified financial instrument.
|
|
239
277
|
|
|
@@ -243,16 +281,17 @@ class Mt5Client(BaseModel):
|
|
|
243
281
|
Returns:
|
|
244
282
|
Symbol info structure or None.
|
|
245
283
|
"""
|
|
246
|
-
self.
|
|
284
|
+
self._initialize_if_needed()
|
|
247
285
|
self.logger.info("Retrieving information for symbol: %s", symbol)
|
|
248
286
|
response = self.mt5.symbol_info(symbol)
|
|
249
|
-
self.
|
|
287
|
+
self._validate_mt5_response_is_not_none(
|
|
250
288
|
response=response,
|
|
251
289
|
operation="symbol_info",
|
|
252
290
|
context=f"symbol={symbol}",
|
|
253
291
|
)
|
|
254
292
|
return response
|
|
255
293
|
|
|
294
|
+
@_log_mt5_last_status_code
|
|
256
295
|
def symbol_info_tick(self, symbol: str) -> Any:
|
|
257
296
|
"""Get the last tick for the specified financial instrument.
|
|
258
297
|
|
|
@@ -262,16 +301,17 @@ class Mt5Client(BaseModel):
|
|
|
262
301
|
Returns:
|
|
263
302
|
Tick info structure or None.
|
|
264
303
|
"""
|
|
265
|
-
self.
|
|
304
|
+
self._initialize_if_needed()
|
|
266
305
|
self.logger.info("Retrieving last tick for symbol: %s", symbol)
|
|
267
306
|
response = self.mt5.symbol_info_tick(symbol)
|
|
268
|
-
self.
|
|
307
|
+
self._validate_mt5_response_is_not_none(
|
|
269
308
|
response=response,
|
|
270
309
|
operation="symbol_info_tick",
|
|
271
310
|
context=f"symbol={symbol}",
|
|
272
311
|
)
|
|
273
312
|
return response
|
|
274
313
|
|
|
314
|
+
@_log_mt5_last_status_code
|
|
275
315
|
def symbol_select(self, symbol: str, enable: bool = True) -> bool:
|
|
276
316
|
"""Select a symbol in the MarketWatch window or remove a symbol from the window.
|
|
277
317
|
|
|
@@ -282,16 +322,17 @@ class Mt5Client(BaseModel):
|
|
|
282
322
|
Returns:
|
|
283
323
|
True if successful, False otherwise.
|
|
284
324
|
"""
|
|
285
|
-
self.
|
|
325
|
+
self._initialize_if_needed()
|
|
286
326
|
self.logger.info("Selecting symbol: %s, enable=%s", symbol, enable)
|
|
287
327
|
response = self.mt5.symbol_select(symbol, enable)
|
|
288
|
-
self.
|
|
328
|
+
self._validate_mt5_response_is_not_none(
|
|
289
329
|
response=response,
|
|
290
330
|
operation="symbol_select",
|
|
291
331
|
context=f"symbol={symbol}, enable={enable}",
|
|
292
332
|
)
|
|
293
333
|
return response
|
|
294
334
|
|
|
335
|
+
@_log_mt5_last_status_code
|
|
295
336
|
def market_book_add(self, symbol: str) -> bool:
|
|
296
337
|
"""Subscribe the terminal to the Market Depth change events for a specified symbol.
|
|
297
338
|
|
|
@@ -301,16 +342,17 @@ class Mt5Client(BaseModel):
|
|
|
301
342
|
Returns:
|
|
302
343
|
True if successful, False otherwise.
|
|
303
344
|
""" # noqa: E501
|
|
304
|
-
self.
|
|
345
|
+
self._initialize_if_needed()
|
|
305
346
|
self.logger.info("Adding market book for symbol: %s", symbol)
|
|
306
347
|
response = self.mt5.market_book_add(symbol)
|
|
307
|
-
self.
|
|
348
|
+
self._validate_mt5_response_is_not_none(
|
|
308
349
|
response=response,
|
|
309
350
|
operation="market_book_add",
|
|
310
351
|
context=f"symbol={symbol}",
|
|
311
352
|
)
|
|
312
353
|
return response
|
|
313
354
|
|
|
355
|
+
@_log_mt5_last_status_code
|
|
314
356
|
def market_book_get(self, symbol: str) -> tuple[Any, ...]:
|
|
315
357
|
"""Return a tuple from BookInfo featuring Market Depth entries for the specified symbol.
|
|
316
358
|
|
|
@@ -320,16 +362,17 @@ class Mt5Client(BaseModel):
|
|
|
320
362
|
Returns:
|
|
321
363
|
Tuple of BookInfo structures or None.
|
|
322
364
|
""" # noqa: E501
|
|
323
|
-
self.
|
|
365
|
+
self._initialize_if_needed()
|
|
324
366
|
self.logger.info("Retrieving market book for symbol: %s", symbol)
|
|
325
367
|
response = self.mt5.market_book_get(symbol)
|
|
326
|
-
self.
|
|
368
|
+
self._validate_mt5_response_is_not_none(
|
|
327
369
|
response=response,
|
|
328
370
|
operation="market_book_get",
|
|
329
371
|
context=f"symbol={symbol}",
|
|
330
372
|
)
|
|
331
373
|
return response
|
|
332
374
|
|
|
375
|
+
@_log_mt5_last_status_code
|
|
333
376
|
def market_book_release(self, symbol: str) -> bool:
|
|
334
377
|
"""Cancels subscription of the terminal to the Market Depth change events for a specified symbol.
|
|
335
378
|
|
|
@@ -339,16 +382,17 @@ class Mt5Client(BaseModel):
|
|
|
339
382
|
Returns:
|
|
340
383
|
True if successful, False otherwise.
|
|
341
384
|
""" # noqa: E501
|
|
342
|
-
self.
|
|
385
|
+
self._initialize_if_needed()
|
|
343
386
|
self.logger.info("Releasing market book for symbol: %s", symbol)
|
|
344
387
|
response = self.mt5.market_book_release(symbol)
|
|
345
|
-
self.
|
|
388
|
+
self._validate_mt5_response_is_not_none(
|
|
346
389
|
response=response,
|
|
347
390
|
operation="market_book_release",
|
|
348
391
|
context=f"symbol={symbol}",
|
|
349
392
|
)
|
|
350
393
|
return response
|
|
351
394
|
|
|
395
|
+
@_log_mt5_last_status_code
|
|
352
396
|
def copy_rates_from(
|
|
353
397
|
self,
|
|
354
398
|
symbol: str,
|
|
@@ -367,7 +411,7 @@ class Mt5Client(BaseModel):
|
|
|
367
411
|
Returns:
|
|
368
412
|
Array of rates or None.
|
|
369
413
|
"""
|
|
370
|
-
self.
|
|
414
|
+
self._initialize_if_needed()
|
|
371
415
|
self.logger.info(
|
|
372
416
|
"Copying rates from symbol: %s, timeframe: %d, date_from: %s, count: %d",
|
|
373
417
|
symbol,
|
|
@@ -376,7 +420,7 @@ class Mt5Client(BaseModel):
|
|
|
376
420
|
count,
|
|
377
421
|
)
|
|
378
422
|
response = self.mt5.copy_rates_from(symbol, timeframe, date_from, count)
|
|
379
|
-
self.
|
|
423
|
+
self._validate_mt5_response_is_not_none(
|
|
380
424
|
response=response,
|
|
381
425
|
operation="copy_rates_from",
|
|
382
426
|
context=(
|
|
@@ -386,6 +430,7 @@ class Mt5Client(BaseModel):
|
|
|
386
430
|
)
|
|
387
431
|
return response
|
|
388
432
|
|
|
433
|
+
@_log_mt5_last_status_code
|
|
389
434
|
def copy_rates_from_pos(
|
|
390
435
|
self,
|
|
391
436
|
symbol: str,
|
|
@@ -404,7 +449,7 @@ class Mt5Client(BaseModel):
|
|
|
404
449
|
Returns:
|
|
405
450
|
Array of rates or None.
|
|
406
451
|
"""
|
|
407
|
-
self.
|
|
452
|
+
self._initialize_if_needed()
|
|
408
453
|
self.logger.info(
|
|
409
454
|
(
|
|
410
455
|
"Copying rates from position:"
|
|
@@ -416,7 +461,7 @@ class Mt5Client(BaseModel):
|
|
|
416
461
|
count,
|
|
417
462
|
)
|
|
418
463
|
response = self.mt5.copy_rates_from_pos(symbol, timeframe, start_pos, count)
|
|
419
|
-
self.
|
|
464
|
+
self._validate_mt5_response_is_not_none(
|
|
420
465
|
response=response,
|
|
421
466
|
operation="copy_rates_from_pos",
|
|
422
467
|
context=(
|
|
@@ -426,6 +471,7 @@ class Mt5Client(BaseModel):
|
|
|
426
471
|
)
|
|
427
472
|
return response
|
|
428
473
|
|
|
474
|
+
@_log_mt5_last_status_code
|
|
429
475
|
def copy_rates_range(
|
|
430
476
|
self,
|
|
431
477
|
symbol: str,
|
|
@@ -444,7 +490,7 @@ class Mt5Client(BaseModel):
|
|
|
444
490
|
Returns:
|
|
445
491
|
Array of rates or None.
|
|
446
492
|
"""
|
|
447
|
-
self.
|
|
493
|
+
self._initialize_if_needed()
|
|
448
494
|
self.logger.info(
|
|
449
495
|
"Copying rates range: symbol=%s, timeframe=%d, date_from=%s, date_to=%s",
|
|
450
496
|
symbol,
|
|
@@ -453,7 +499,7 @@ class Mt5Client(BaseModel):
|
|
|
453
499
|
date_to,
|
|
454
500
|
)
|
|
455
501
|
response = self.mt5.copy_rates_range(symbol, timeframe, date_from, date_to)
|
|
456
|
-
self.
|
|
502
|
+
self._validate_mt5_response_is_not_none(
|
|
457
503
|
response=response,
|
|
458
504
|
operation="copy_rates_range",
|
|
459
505
|
context=(
|
|
@@ -463,6 +509,7 @@ class Mt5Client(BaseModel):
|
|
|
463
509
|
)
|
|
464
510
|
return response
|
|
465
511
|
|
|
512
|
+
@_log_mt5_last_status_code
|
|
466
513
|
def copy_ticks_from(
|
|
467
514
|
self,
|
|
468
515
|
symbol: str,
|
|
@@ -481,7 +528,7 @@ class Mt5Client(BaseModel):
|
|
|
481
528
|
Returns:
|
|
482
529
|
Array of ticks or None.
|
|
483
530
|
"""
|
|
484
|
-
self.
|
|
531
|
+
self._initialize_if_needed()
|
|
485
532
|
self.logger.info(
|
|
486
533
|
"Copying ticks from symbol: %s, date_from: %s, count: %d, flags: %d",
|
|
487
534
|
symbol,
|
|
@@ -490,7 +537,7 @@ class Mt5Client(BaseModel):
|
|
|
490
537
|
flags,
|
|
491
538
|
)
|
|
492
539
|
response = self.mt5.copy_ticks_from(symbol, date_from, count, flags)
|
|
493
|
-
self.
|
|
540
|
+
self._validate_mt5_response_is_not_none(
|
|
494
541
|
response=response,
|
|
495
542
|
operation="copy_ticks_from",
|
|
496
543
|
context=(
|
|
@@ -499,6 +546,7 @@ class Mt5Client(BaseModel):
|
|
|
499
546
|
)
|
|
500
547
|
return response
|
|
501
548
|
|
|
549
|
+
@_log_mt5_last_status_code
|
|
502
550
|
def copy_ticks_range(
|
|
503
551
|
self,
|
|
504
552
|
symbol: str,
|
|
@@ -517,7 +565,7 @@ class Mt5Client(BaseModel):
|
|
|
517
565
|
Returns:
|
|
518
566
|
Array of ticks or None.
|
|
519
567
|
"""
|
|
520
|
-
self.
|
|
568
|
+
self._initialize_if_needed()
|
|
521
569
|
self.logger.info(
|
|
522
570
|
"Copying ticks range: symbol=%s, date_from=%s, date_to=%s, flags=%d",
|
|
523
571
|
symbol,
|
|
@@ -526,7 +574,7 @@ class Mt5Client(BaseModel):
|
|
|
526
574
|
flags,
|
|
527
575
|
)
|
|
528
576
|
response = self.mt5.copy_ticks_range(symbol, date_from, date_to, flags)
|
|
529
|
-
self.
|
|
577
|
+
self._validate_mt5_response_is_not_none(
|
|
530
578
|
response=response,
|
|
531
579
|
operation="copy_ticks_range",
|
|
532
580
|
context=(
|
|
@@ -536,16 +584,18 @@ class Mt5Client(BaseModel):
|
|
|
536
584
|
)
|
|
537
585
|
return response
|
|
538
586
|
|
|
587
|
+
@_log_mt5_last_status_code
|
|
539
588
|
def orders_total(self) -> int:
|
|
540
589
|
"""Get the number of active orders.
|
|
541
590
|
|
|
542
591
|
Returns:
|
|
543
592
|
Number of active orders.
|
|
544
593
|
"""
|
|
545
|
-
self.
|
|
594
|
+
self._initialize_if_needed()
|
|
546
595
|
self.logger.info("Retrieving total number of active orders.")
|
|
547
596
|
return self.mt5.orders_total()
|
|
548
597
|
|
|
598
|
+
@_log_mt5_last_status_code
|
|
549
599
|
def orders_get(
|
|
550
600
|
self,
|
|
551
601
|
symbol: str | None = None,
|
|
@@ -562,7 +612,7 @@ class Mt5Client(BaseModel):
|
|
|
562
612
|
Returns:
|
|
563
613
|
Tuple of order info structures or None.
|
|
564
614
|
"""
|
|
565
|
-
self.
|
|
615
|
+
self._initialize_if_needed()
|
|
566
616
|
if ticket is not None:
|
|
567
617
|
self.logger.info("Retrieving order with ticket: %d", ticket)
|
|
568
618
|
response = self.mt5.orders_get(ticket=ticket)
|
|
@@ -579,13 +629,14 @@ class Mt5Client(BaseModel):
|
|
|
579
629
|
self.logger.info("Retrieving all active orders.")
|
|
580
630
|
response = self.mt5.orders_get()
|
|
581
631
|
context = None
|
|
582
|
-
self.
|
|
632
|
+
self._validate_mt5_response_is_not_none(
|
|
583
633
|
response=response,
|
|
584
634
|
operation="orders_get",
|
|
585
635
|
context=context,
|
|
586
636
|
)
|
|
587
637
|
return response
|
|
588
638
|
|
|
639
|
+
@_log_mt5_last_status_code
|
|
589
640
|
def order_calc_margin(
|
|
590
641
|
self,
|
|
591
642
|
action: int,
|
|
@@ -604,7 +655,7 @@ class Mt5Client(BaseModel):
|
|
|
604
655
|
Returns:
|
|
605
656
|
Required margin amount or None.
|
|
606
657
|
""" # noqa: E501
|
|
607
|
-
self.
|
|
658
|
+
self._initialize_if_needed()
|
|
608
659
|
self.logger.info(
|
|
609
660
|
"Calculating margin: action=%d, symbol=%s, volume=%.2f, price=%.5f",
|
|
610
661
|
action,
|
|
@@ -613,13 +664,14 @@ class Mt5Client(BaseModel):
|
|
|
613
664
|
price,
|
|
614
665
|
)
|
|
615
666
|
response = self.mt5.order_calc_margin(action, symbol, volume, price)
|
|
616
|
-
self.
|
|
667
|
+
self._validate_mt5_response_is_not_none(
|
|
617
668
|
response=response,
|
|
618
669
|
operation="order_calc_margin",
|
|
619
670
|
context=f"action={action}, symbol={symbol}, volume={volume}, price={price}",
|
|
620
671
|
)
|
|
621
672
|
return response
|
|
622
673
|
|
|
674
|
+
@_log_mt5_last_status_code
|
|
623
675
|
def order_calc_profit(
|
|
624
676
|
self,
|
|
625
677
|
action: int,
|
|
@@ -640,7 +692,7 @@ class Mt5Client(BaseModel):
|
|
|
640
692
|
Returns:
|
|
641
693
|
Calculated profit or None.
|
|
642
694
|
"""
|
|
643
|
-
self.
|
|
695
|
+
self._initialize_if_needed()
|
|
644
696
|
self.logger.info(
|
|
645
697
|
(
|
|
646
698
|
"Calculating profit: action=%d, symbol=%s, volume=%.2f,"
|
|
@@ -655,7 +707,7 @@ class Mt5Client(BaseModel):
|
|
|
655
707
|
response = self.mt5.order_calc_profit(
|
|
656
708
|
action, symbol, volume, price_open, price_close
|
|
657
709
|
)
|
|
658
|
-
self.
|
|
710
|
+
self._validate_mt5_response_is_not_none(
|
|
659
711
|
response=response,
|
|
660
712
|
operation="order_calc_profit",
|
|
661
713
|
context=(
|
|
@@ -665,6 +717,7 @@ class Mt5Client(BaseModel):
|
|
|
665
717
|
)
|
|
666
718
|
return response
|
|
667
719
|
|
|
720
|
+
@_log_mt5_last_status_code
|
|
668
721
|
def order_check(self, request: dict[str, Any]) -> Any:
|
|
669
722
|
"""Check funds sufficiency for performing a required trading operation.
|
|
670
723
|
|
|
@@ -674,16 +727,17 @@ class Mt5Client(BaseModel):
|
|
|
674
727
|
Returns:
|
|
675
728
|
OrderCheckResult structure or None.
|
|
676
729
|
"""
|
|
677
|
-
self.
|
|
730
|
+
self._initialize_if_needed()
|
|
678
731
|
self.logger.info("Checking order with request: %s", request)
|
|
679
732
|
response = self.mt5.order_check(request)
|
|
680
|
-
self.
|
|
733
|
+
self._validate_mt5_response_is_not_none(
|
|
681
734
|
response=response,
|
|
682
735
|
operation="order_check",
|
|
683
736
|
context=f"request={request}",
|
|
684
737
|
)
|
|
685
738
|
return response
|
|
686
739
|
|
|
740
|
+
@_log_mt5_last_status_code
|
|
687
741
|
def order_send(self, request: dict[str, Any]) -> Any:
|
|
688
742
|
"""Send a request to perform a trading operation from the terminal to the trade server.
|
|
689
743
|
|
|
@@ -693,26 +747,28 @@ class Mt5Client(BaseModel):
|
|
|
693
747
|
Returns:
|
|
694
748
|
OrderSendResult structure or None.
|
|
695
749
|
""" # noqa: E501
|
|
696
|
-
self.
|
|
750
|
+
self._initialize_if_needed()
|
|
697
751
|
self.logger.info("Sending order with request: %s", request)
|
|
698
752
|
response = self.mt5.order_send(request)
|
|
699
|
-
self.
|
|
753
|
+
self._validate_mt5_response_is_not_none(
|
|
700
754
|
response=response,
|
|
701
755
|
operation="order_send",
|
|
702
756
|
context=f"request={request}",
|
|
703
757
|
)
|
|
704
758
|
return response
|
|
705
759
|
|
|
760
|
+
@_log_mt5_last_status_code
|
|
706
761
|
def positions_total(self) -> int:
|
|
707
762
|
"""Get the number of open positions.
|
|
708
763
|
|
|
709
764
|
Returns:
|
|
710
765
|
Number of open positions.
|
|
711
766
|
"""
|
|
712
|
-
self.
|
|
767
|
+
self._initialize_if_needed()
|
|
713
768
|
self.logger.info("Retrieving total number of open positions.")
|
|
714
769
|
return self.mt5.positions_total()
|
|
715
770
|
|
|
771
|
+
@_log_mt5_last_status_code
|
|
716
772
|
def positions_get(
|
|
717
773
|
self,
|
|
718
774
|
symbol: str | None = None,
|
|
@@ -729,7 +785,7 @@ class Mt5Client(BaseModel):
|
|
|
729
785
|
Returns:
|
|
730
786
|
Tuple of position info structures or None.
|
|
731
787
|
"""
|
|
732
|
-
self.
|
|
788
|
+
self._initialize_if_needed()
|
|
733
789
|
if ticket is not None:
|
|
734
790
|
self.logger.info("Retrieving position with ticket: %d", ticket)
|
|
735
791
|
response = self.mt5.positions_get(ticket=ticket)
|
|
@@ -746,13 +802,14 @@ class Mt5Client(BaseModel):
|
|
|
746
802
|
self.logger.info("Retrieving all open positions.")
|
|
747
803
|
response = self.mt5.positions_get()
|
|
748
804
|
context = None
|
|
749
|
-
self.
|
|
805
|
+
self._validate_mt5_response_is_not_none(
|
|
750
806
|
response=response,
|
|
751
807
|
operation="positions_get",
|
|
752
808
|
context=context,
|
|
753
809
|
)
|
|
754
810
|
return response
|
|
755
811
|
|
|
812
|
+
@_log_mt5_last_status_code
|
|
756
813
|
def history_orders_total(
|
|
757
814
|
self,
|
|
758
815
|
date_from: datetime | int,
|
|
@@ -767,7 +824,7 @@ class Mt5Client(BaseModel):
|
|
|
767
824
|
Returns:
|
|
768
825
|
Number of historical orders.
|
|
769
826
|
"""
|
|
770
|
-
self.
|
|
827
|
+
self._initialize_if_needed()
|
|
771
828
|
self.logger.info(
|
|
772
829
|
"Retrieving total number of historical orders from %s to %s",
|
|
773
830
|
date_from,
|
|
@@ -775,6 +832,7 @@ class Mt5Client(BaseModel):
|
|
|
775
832
|
)
|
|
776
833
|
return self.mt5.history_orders_total(date_from, date_to)
|
|
777
834
|
|
|
835
|
+
@_log_mt5_last_status_code
|
|
778
836
|
def history_orders_get(
|
|
779
837
|
self,
|
|
780
838
|
date_from: datetime | int | None = None,
|
|
@@ -795,7 +853,7 @@ class Mt5Client(BaseModel):
|
|
|
795
853
|
Returns:
|
|
796
854
|
Tuple of historical order info structures or None.
|
|
797
855
|
""" # noqa: E501
|
|
798
|
-
self.
|
|
856
|
+
self._initialize_if_needed()
|
|
799
857
|
if ticket is not None:
|
|
800
858
|
self.logger.info("Retrieving order with ticket: %d", ticket)
|
|
801
859
|
response = self.mt5.history_orders_get(ticket=ticket)
|
|
@@ -821,13 +879,14 @@ class Mt5Client(BaseModel):
|
|
|
821
879
|
)
|
|
822
880
|
response = self.mt5.history_orders_get(date_from, date_to)
|
|
823
881
|
context = f"date_from={date_from}, date_to={date_to}"
|
|
824
|
-
self.
|
|
882
|
+
self._validate_mt5_response_is_not_none(
|
|
825
883
|
response=response,
|
|
826
884
|
operation="history_orders_get",
|
|
827
885
|
context=context,
|
|
828
886
|
)
|
|
829
887
|
return response
|
|
830
888
|
|
|
889
|
+
@_log_mt5_last_status_code
|
|
831
890
|
def history_deals_total(
|
|
832
891
|
self,
|
|
833
892
|
date_from: datetime | int,
|
|
@@ -842,7 +901,7 @@ class Mt5Client(BaseModel):
|
|
|
842
901
|
Returns:
|
|
843
902
|
Number of historical deals.
|
|
844
903
|
"""
|
|
845
|
-
self.
|
|
904
|
+
self._initialize_if_needed()
|
|
846
905
|
self.logger.info(
|
|
847
906
|
"Retrieving total number of historical deals from %s to %s",
|
|
848
907
|
date_from,
|
|
@@ -850,6 +909,7 @@ class Mt5Client(BaseModel):
|
|
|
850
909
|
)
|
|
851
910
|
return self.mt5.history_deals_total(date_from, date_to)
|
|
852
911
|
|
|
912
|
+
@_log_mt5_last_status_code
|
|
853
913
|
def history_deals_get(
|
|
854
914
|
self,
|
|
855
915
|
date_from: datetime | int | None = None,
|
|
@@ -870,7 +930,7 @@ class Mt5Client(BaseModel):
|
|
|
870
930
|
Returns:
|
|
871
931
|
Tuple of historical deal info structures or None.
|
|
872
932
|
""" # noqa: E501
|
|
873
|
-
self.
|
|
933
|
+
self._initialize_if_needed()
|
|
874
934
|
if ticket is not None:
|
|
875
935
|
self.logger.info("Retrieving deal with ticket: %d", ticket)
|
|
876
936
|
response = self.mt5.history_deals_get(ticket=ticket)
|
|
@@ -896,20 +956,25 @@ class Mt5Client(BaseModel):
|
|
|
896
956
|
)
|
|
897
957
|
response = self.mt5.history_deals_get(date_from, date_to)
|
|
898
958
|
context = f"date_from={date_from}, date_to={date_to}"
|
|
899
|
-
self.
|
|
959
|
+
self._validate_mt5_response_is_not_none(
|
|
900
960
|
response=response,
|
|
901
961
|
operation="history_deals_get",
|
|
902
962
|
context=context,
|
|
903
963
|
)
|
|
904
964
|
return response
|
|
905
965
|
|
|
906
|
-
def
|
|
966
|
+
def _initialize_if_needed(self) -> None:
|
|
967
|
+
"""Ensure the MetaTrader5 client is initialized before performing operations."""
|
|
968
|
+
if not self._is_initialized:
|
|
969
|
+
self.initialize()
|
|
970
|
+
|
|
971
|
+
def _validate_mt5_response_is_not_none(
|
|
907
972
|
self,
|
|
908
973
|
response: Any,
|
|
909
974
|
operation: str,
|
|
910
975
|
context: str | None = None,
|
|
911
976
|
) -> None:
|
|
912
|
-
"""Validate the response
|
|
977
|
+
"""Validate that the MetaTrader5 response is not None.
|
|
913
978
|
|
|
914
979
|
Args:
|
|
915
980
|
response: The response object to validate.
|
|
@@ -920,20 +985,10 @@ class Mt5Client(BaseModel):
|
|
|
920
985
|
Mt5RuntimeError: With error details from MetaTrader5.
|
|
921
986
|
"""
|
|
922
987
|
if response is None:
|
|
923
|
-
|
|
988
|
+
last_error_response = self.mt5.last_error()
|
|
924
989
|
error_message = (
|
|
925
|
-
f"{operation}
|
|
926
|
-
|
|
927
|
-
)
|
|
990
|
+
f"MetaTrader5 {operation} returned {response}:"
|
|
991
|
+
f" last_error={last_error_response}"
|
|
992
|
+
) + (f" context={context}" if context else "")
|
|
928
993
|
self.logger.error(error_message)
|
|
929
994
|
raise Mt5RuntimeError(error_message)
|
|
930
|
-
|
|
931
|
-
def _ensure_initialized(self) -> None:
|
|
932
|
-
"""Ensure MetaTrader5 is initialized.
|
|
933
|
-
|
|
934
|
-
Raises:
|
|
935
|
-
Mt5RuntimeError: If MetaTrader5 is not initialized.
|
|
936
|
-
"""
|
|
937
|
-
if not self._is_initialized:
|
|
938
|
-
error_message = "MetaTrader5 not initialized. Call initialize() first."
|
|
939
|
-
raise Mt5RuntimeError(error_message)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pdmt5"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.8"
|
|
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"}]
|
|
@@ -177,7 +177,7 @@ omit = [
|
|
|
177
177
|
|
|
178
178
|
[tool.coverage.report]
|
|
179
179
|
show_missing = true
|
|
180
|
-
fail_under =
|
|
180
|
+
fail_under = 100
|
|
181
181
|
exclude_lines = ["if TYPE_CHECKING:"]
|
|
182
182
|
|
|
183
183
|
[build-system]
|
|
@@ -82,6 +82,7 @@ def mock_mt5_import(
|
|
|
82
82
|
mock_mt5.market_book_add = mocker.MagicMock() # type: ignore[attr-defined]
|
|
83
83
|
mock_mt5.market_book_release = mocker.MagicMock() # type: ignore[attr-defined]
|
|
84
84
|
mock_mt5.market_book_get = mocker.MagicMock() # type: ignore[attr-defined]
|
|
85
|
+
mock_mt5.RES_S_OK = 1
|
|
85
86
|
yield mock_mt5
|
|
86
87
|
|
|
87
88
|
|
|
@@ -494,11 +495,11 @@ class TestMt5DataClient:
|
|
|
494
495
|
# Set _is_initialized to True to test the early return path
|
|
495
496
|
client._is_initialized = True
|
|
496
497
|
|
|
497
|
-
# Call initialize when already initialized - should
|
|
498
|
+
# Call initialize when already initialized - should still call mt5.initialize
|
|
498
499
|
result = client.initialize()
|
|
499
500
|
|
|
500
|
-
assert result is True # Method returns True when
|
|
501
|
-
mock_mt5_import.initialize.
|
|
501
|
+
assert result is True # Method returns True when successful
|
|
502
|
+
mock_mt5_import.initialize.assert_called_once()
|
|
502
503
|
|
|
503
504
|
def test_shutdown(self, mock_mt5_import: ModuleType | None) -> None:
|
|
504
505
|
"""Test shutdown."""
|
|
@@ -932,7 +933,7 @@ class TestMt5DataClient:
|
|
|
932
933
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
933
934
|
client.initialize()
|
|
934
935
|
with pytest.raises(
|
|
935
|
-
Mt5RuntimeError, match=r"
|
|
936
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_margin"
|
|
936
937
|
):
|
|
937
938
|
client.order_calc_margin(0, "EURUSD", 0.0, 1.1300)
|
|
938
939
|
|
|
@@ -948,7 +949,7 @@ class TestMt5DataClient:
|
|
|
948
949
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
949
950
|
client.initialize()
|
|
950
951
|
with pytest.raises(
|
|
951
|
-
Mt5RuntimeError, match=r"
|
|
952
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_margin"
|
|
952
953
|
):
|
|
953
954
|
client.order_calc_margin(0, "EURUSD", 0.1, 0.0)
|
|
954
955
|
|
|
@@ -963,7 +964,7 @@ class TestMt5DataClient:
|
|
|
963
964
|
client.initialize()
|
|
964
965
|
with pytest.raises(
|
|
965
966
|
Mt5RuntimeError,
|
|
966
|
-
match=r"
|
|
967
|
+
match=r"Mt5Client operation failed: order_calc_margin",
|
|
967
968
|
):
|
|
968
969
|
client.order_calc_margin(0, "EURUSD", 0.1, 1.1300)
|
|
969
970
|
|
|
@@ -991,7 +992,7 @@ class TestMt5DataClient:
|
|
|
991
992
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
992
993
|
client.initialize()
|
|
993
994
|
with pytest.raises(
|
|
994
|
-
Mt5RuntimeError, match=r"
|
|
995
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
|
|
995
996
|
):
|
|
996
997
|
client.order_calc_profit(0, "EURUSD", 0.0, 1.1300, 1.1400)
|
|
997
998
|
|
|
@@ -1007,7 +1008,7 @@ class TestMt5DataClient:
|
|
|
1007
1008
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1008
1009
|
client.initialize()
|
|
1009
1010
|
with pytest.raises(
|
|
1010
|
-
Mt5RuntimeError, match=r"
|
|
1011
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
|
|
1011
1012
|
):
|
|
1012
1013
|
client.order_calc_profit(0, "EURUSD", 0.1, 0.0, 1.1400)
|
|
1013
1014
|
|
|
@@ -1023,7 +1024,7 @@ class TestMt5DataClient:
|
|
|
1023
1024
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1024
1025
|
client.initialize()
|
|
1025
1026
|
with pytest.raises(
|
|
1026
|
-
Mt5RuntimeError, match=r"
|
|
1027
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: order_calc_profit"
|
|
1027
1028
|
):
|
|
1028
1029
|
client.order_calc_profit(0, "EURUSD", 0.1, 1.1300, 0.0)
|
|
1029
1030
|
|
|
@@ -1038,7 +1039,7 @@ class TestMt5DataClient:
|
|
|
1038
1039
|
client.initialize()
|
|
1039
1040
|
with pytest.raises(
|
|
1040
1041
|
Mt5RuntimeError,
|
|
1041
|
-
match=r"
|
|
1042
|
+
match=r"Mt5Client operation failed: order_calc_profit",
|
|
1042
1043
|
):
|
|
1043
1044
|
client.order_calc_profit(0, "EURUSD", 0.1, 1.1300, 1.1400)
|
|
1044
1045
|
|
|
@@ -1126,7 +1127,7 @@ class TestMt5DataClient:
|
|
|
1126
1127
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1127
1128
|
client.initialize()
|
|
1128
1129
|
with pytest.raises(
|
|
1129
|
-
Mt5RuntimeError, match=r"
|
|
1130
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: symbol_select"
|
|
1130
1131
|
):
|
|
1131
1132
|
client.symbol_select("EURUSD")
|
|
1132
1133
|
|
|
@@ -1152,7 +1153,7 @@ class TestMt5DataClient:
|
|
|
1152
1153
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1153
1154
|
client.initialize()
|
|
1154
1155
|
with pytest.raises(
|
|
1155
|
-
Mt5RuntimeError, match=r"
|
|
1156
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: market_book_add"
|
|
1156
1157
|
):
|
|
1157
1158
|
client.market_book_add("EURUSD")
|
|
1158
1159
|
|
|
@@ -1181,7 +1182,7 @@ class TestMt5DataClient:
|
|
|
1181
1182
|
client.initialize()
|
|
1182
1183
|
with pytest.raises(
|
|
1183
1184
|
Mt5RuntimeError,
|
|
1184
|
-
match=r"
|
|
1185
|
+
match=r"Mt5Client operation failed: market_book_release",
|
|
1185
1186
|
):
|
|
1186
1187
|
client.market_book_release("EURUSD")
|
|
1187
1188
|
|
|
@@ -1485,7 +1486,7 @@ class TestMt5DataClient:
|
|
|
1485
1486
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1486
1487
|
client.initialize()
|
|
1487
1488
|
with pytest.raises(
|
|
1488
|
-
Mt5RuntimeError, match=r"
|
|
1489
|
+
Mt5RuntimeError, match=r"Mt5Client operation failed: market_book_get"
|
|
1489
1490
|
):
|
|
1490
1491
|
client.market_book_get("EURUSD")
|
|
1491
1492
|
|
|
@@ -1497,9 +1498,9 @@ class TestMt5DataClient:
|
|
|
1497
1498
|
|
|
1498
1499
|
client = Mt5DataClient(mt5=mock_mt5_import)
|
|
1499
1500
|
# Don't initialize
|
|
1500
|
-
client.shutdown() # Should
|
|
1501
|
+
client.shutdown() # Should call mt5.shutdown()
|
|
1501
1502
|
|
|
1502
|
-
mock_mt5_import.shutdown.
|
|
1503
|
+
mock_mt5_import.shutdown.assert_called_once()
|
|
1503
1504
|
|
|
1504
1505
|
def test_orders_get_missing_time_columns(
|
|
1505
1506
|
self, mock_mt5_import: ModuleType | None
|
|
@@ -2202,7 +2203,8 @@ class TestMt5DataClientRetryLogic:
|
|
|
2202
2203
|
# Test last_error method from parent class
|
|
2203
2204
|
error = client.last_error()
|
|
2204
2205
|
assert error == (0, "No error")
|
|
2205
|
-
|
|
2206
|
+
# last_error is called multiple times (in decorators and explicitly)
|
|
2207
|
+
assert mock_mt5_import.last_error.call_count >= 1
|
|
2206
2208
|
|
|
2207
2209
|
def test_validate_history_input_with_ticket(
|
|
2208
2210
|
self, mock_mt5_import: ModuleType | None
|
|
@@ -2418,11 +2420,11 @@ class TestMt5DataClientRetryLogic:
|
|
|
2418
2420
|
# Reset the mock
|
|
2419
2421
|
mock_mt5_import.initialize.reset_mock()
|
|
2420
2422
|
|
|
2421
|
-
# Call initialize again - should
|
|
2423
|
+
# Call initialize again - should still call mt5.initialize()
|
|
2422
2424
|
client.initialize()
|
|
2423
2425
|
|
|
2424
|
-
# Initialize should
|
|
2425
|
-
mock_mt5_import.initialize.
|
|
2426
|
+
# Initialize should be called again in current implementation
|
|
2427
|
+
mock_mt5_import.initialize.assert_called_once()
|
|
2426
2428
|
# The method should still return True (or whatever the expected behavior is)
|
|
2427
2429
|
assert client._is_initialized is True
|
|
2428
2430
|
|
|
@@ -14,9 +14,12 @@ class TestInit:
|
|
|
14
14
|
def test_all_exports(self) -> None:
|
|
15
15
|
"""Test that all expected exports are available."""
|
|
16
16
|
expected_exports = [
|
|
17
|
+
"Mt5Client",
|
|
17
18
|
"Mt5Config",
|
|
18
19
|
"Mt5DataClient",
|
|
19
20
|
"Mt5RuntimeError",
|
|
21
|
+
"Mt5TradingClient",
|
|
22
|
+
"Mt5TradingError",
|
|
20
23
|
]
|
|
21
24
|
|
|
22
25
|
for export in expected_exports:
|
|
@@ -25,6 +28,9 @@ class TestInit:
|
|
|
25
28
|
|
|
26
29
|
def test_classes_accessible(self) -> None:
|
|
27
30
|
"""Test that main classes are accessible."""
|
|
31
|
+
assert hasattr(pdmt5, "Mt5Client")
|
|
28
32
|
assert hasattr(pdmt5, "Mt5Config")
|
|
29
33
|
assert hasattr(pdmt5, "Mt5DataClient")
|
|
30
34
|
assert hasattr(pdmt5, "Mt5RuntimeError")
|
|
35
|
+
assert hasattr(pdmt5, "Mt5TradingClient")
|
|
36
|
+
assert hasattr(pdmt5, "Mt5TradingError")
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import contextlib
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
@@ -22,6 +23,7 @@ def mock_metatrader5_import(mocker: MockerFixture) -> Mock:
|
|
|
22
23
|
"""Mock MetaTrader5 import globally for all tests."""
|
|
23
24
|
mock_mt5 = mocker.Mock()
|
|
24
25
|
mock_mt5.last_error.return_value = (1001, "Test error")
|
|
26
|
+
mock_mt5.RES_S_OK = 1
|
|
25
27
|
mocker.patch("pdmt5.mt5.importlib.import_module", return_value=mock_mt5)
|
|
26
28
|
return mock_mt5
|
|
27
29
|
|
|
@@ -122,12 +124,17 @@ class TestMt5Client:
|
|
|
122
124
|
assert error == (1001, "Test error")
|
|
123
125
|
mock_mt5.last_error.assert_called_once()
|
|
124
126
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
def test_initialize_if_needed_calls_initialize(
|
|
128
|
+
self, client: Mt5Client, mock_mt5: Mock
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Test _initialize_if_needed calls initialize when not initialized."""
|
|
131
|
+
mock_mt5.initialize.return_value = True
|
|
129
132
|
|
|
130
|
-
assert
|
|
133
|
+
assert client._is_initialized is False
|
|
134
|
+
client._initialize_if_needed()
|
|
135
|
+
|
|
136
|
+
mock_mt5.initialize.assert_called_once()
|
|
137
|
+
assert client._is_initialized is True
|
|
131
138
|
|
|
132
139
|
def test_login_success(self, initialized_client: Mt5Client, mock_mt5: Mock) -> None:
|
|
133
140
|
"""Test successful login."""
|
|
@@ -204,7 +211,7 @@ class TestMt5Client:
|
|
|
204
211
|
with pytest.raises(Mt5RuntimeError) as exc_info:
|
|
205
212
|
initialized_client.symbols_get()
|
|
206
213
|
|
|
207
|
-
assert "
|
|
214
|
+
assert "Mt5Client operation failed: symbols_get" in str(exc_info.value)
|
|
208
215
|
|
|
209
216
|
def test_symbol_info(
|
|
210
217
|
self,
|
|
@@ -624,9 +631,9 @@ class TestMt5Client:
|
|
|
624
631
|
|
|
625
632
|
for method_name, args in methods:
|
|
626
633
|
method = getattr(client, method_name)
|
|
627
|
-
|
|
634
|
+
# Methods should automatically initialize if not already done
|
|
635
|
+
with contextlib.suppress(Mt5RuntimeError):
|
|
628
636
|
method(*args)
|
|
629
|
-
assert "not initialized" in str(exc_info.value)
|
|
630
637
|
|
|
631
638
|
def test_error_handling_with_context(
|
|
632
639
|
self, initialized_client: Mt5Client, mock_mt5: Mock
|
|
@@ -638,9 +645,7 @@ class TestMt5Client:
|
|
|
638
645
|
initialized_client.symbol_info("EURUSD")
|
|
639
646
|
|
|
640
647
|
error_msg = str(exc_info.value)
|
|
641
|
-
assert "
|
|
642
|
-
assert "1001 - Test error" in error_msg
|
|
643
|
-
assert "symbol=EURUSD" in error_msg
|
|
648
|
+
assert "Mt5Client operation failed: symbol_info" in error_msg
|
|
644
649
|
|
|
645
650
|
def test_default_mt5_import(self, mock_metatrader5_import: MockerFixture) -> None:
|
|
646
651
|
"""Test default MetaTrader5 module import."""
|
|
@@ -649,7 +654,7 @@ class TestMt5Client:
|
|
|
649
654
|
assert client.mt5 is mock_metatrader5_import
|
|
650
655
|
|
|
651
656
|
def test_multiple_initializations(self, client: Mt5Client, mock_mt5: Mock) -> None:
|
|
652
|
-
"""Test that multiple initializations
|
|
657
|
+
"""Test that multiple initializations work correctly."""
|
|
653
658
|
mock_mt5.initialize.return_value = True
|
|
654
659
|
|
|
655
660
|
# First initialization
|
|
@@ -657,18 +662,18 @@ class TestMt5Client:
|
|
|
657
662
|
assert result1 is True
|
|
658
663
|
assert mock_mt5.initialize.call_count == 1
|
|
659
664
|
|
|
660
|
-
# Second initialization should
|
|
665
|
+
# Second initialization should call initialize again
|
|
661
666
|
result2 = client.initialize()
|
|
662
667
|
assert result2 is True
|
|
663
|
-
assert mock_mt5.initialize.call_count ==
|
|
668
|
+
assert mock_mt5.initialize.call_count == 2 # Called again
|
|
664
669
|
|
|
665
670
|
def test_shutdown_when_not_initialized(
|
|
666
671
|
self, client: Mt5Client, mock_mt5: Mock
|
|
667
672
|
) -> None:
|
|
668
|
-
"""Test shutdown when not initialized
|
|
673
|
+
"""Test shutdown when not initialized still calls mt5.shutdown."""
|
|
669
674
|
client.shutdown()
|
|
670
675
|
|
|
671
|
-
mock_mt5.shutdown.
|
|
676
|
+
mock_mt5.shutdown.assert_called_once()
|
|
672
677
|
|
|
673
678
|
def test_error_handling_methods(
|
|
674
679
|
self, initialized_client: Mt5Client, mock_mt5: Mock
|
|
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
|