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.
- {pdmt5-0.0.8 → pdmt5-0.0.9}/PKG-INFO +1 -1
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/dataframe.py +3 -3
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/mt5.py +18 -15
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/trading.py +53 -9
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pyproject.toml +1 -1
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_dataframe.py +13 -19
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_mt5.py +5 -3
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_trading.py +256 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/uv.lock +1 -1
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.claude/settings.json +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/FUNDING.yml +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/copilot-instructions.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/dependabot.yml +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.github/workflows/ci.yml +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/.gitignore +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/CLAUDE.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/LICENSE +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/README.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/dataframe.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/index.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/mt5.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/trading.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/api/utils.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/docs/index.md +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/mkdocs.yml +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/__init__.py +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/pdmt5/utils.py +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/renovate.json +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/__init__.py +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_init.py +0 -0
- {pdmt5-0.0.8 → pdmt5-0.0.9}/test/test_utils.py +0 -0
|
@@ -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
|
|
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("
|
|
95
|
+
self.logger.info("MT5 initialization successful.")
|
|
96
96
|
return
|
|
97
97
|
error_message = (
|
|
98
|
-
f"
|
|
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
|
-
|
|
62
|
+
response = func(self, *args, **kwargs)
|
|
63
63
|
except Exception as e:
|
|
64
|
-
error_message = f"
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
991
|
-
f" last_error={
|
|
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(
|
|
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
|
|
123
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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 "
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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
|
+
)
|
|
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
|