pdmt5 0.1.4__tar.gz → 0.1.5__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.1.4 → pdmt5-0.1.5}/.claude/settings.json +2 -2
- {pdmt5-0.1.4 → pdmt5-0.1.5}/PKG-INFO +44 -7
- {pdmt5-0.1.4 → pdmt5-0.1.5}/README.md +43 -6
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pdmt5/trading.py +168 -17
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pyproject.toml +1 -1
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/test_trading.py +503 -42
- {pdmt5-0.1.4 → pdmt5-0.1.5}/uv.lock +1 -1
- {pdmt5-0.1.4 → pdmt5-0.1.5}/.github/FUNDING.yml +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/.github/copilot-instructions.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/.github/dependabot.yml +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/.github/workflows/ci.yml +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/.gitignore +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/CLAUDE.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/LICENSE +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/api/dataframe.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/api/index.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/api/mt5.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/api/trading.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/api/utils.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/docs/index.md +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/mkdocs.yml +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pdmt5/__init__.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pdmt5/dataframe.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pdmt5/mt5.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/pdmt5/utils.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/renovate.json +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/__init__.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/test_dataframe.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/test_init.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/test_mt5.py +0 -0
- {pdmt5-0.1.4 → pdmt5-0.1.5}/test/test_utils.py +0 -0
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "uv run ruff
|
|
9
|
+
"command": "uv run ruff format . && uv run ruff check --fix . && uv run pyright . && uv run pytest"
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
14
|
}
|
|
15
|
-
}
|
|
15
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pdmt5
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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>
|
|
@@ -29,7 +29,6 @@ Pandas-based data handler for MetaTrader 5
|
|
|
29
29
|
[](https://www.python.org/downloads/)
|
|
30
30
|
[](https://opensource.org/licenses/MIT)
|
|
31
31
|
[](https://www.microsoft.com/windows)
|
|
32
|
-
[](https://github.com/dceoy/pdmt5)
|
|
33
32
|
|
|
34
33
|
## Overview
|
|
35
34
|
|
|
@@ -195,10 +194,12 @@ Advanced trading operations client that extends Mt5DataClient:
|
|
|
195
194
|
- `dry_run` - Test mode flag for simulating trades without execution
|
|
196
195
|
- **Position Management**:
|
|
197
196
|
- `close_open_positions()` - Close all positions for specified symbol(s)
|
|
198
|
-
- `
|
|
197
|
+
- `place_market_order()` - Place market orders with configurable side, volume, and execution modes
|
|
198
|
+
- `update_open_position_sltp()` - Modify stop loss and take profit levels for open positions
|
|
199
199
|
- **Market Analysis**:
|
|
200
200
|
- `calculate_minimum_order_margins()` - Calculate minimum required margins for buy/sell orders
|
|
201
201
|
- `calculate_spread_ratio()` - Calculate normalized bid-ask spread ratio
|
|
202
|
+
- `calculate_new_position_margin_ratio()` - Calculate margin ratio for potential new positions
|
|
202
203
|
- **Simplified Data Access**:
|
|
203
204
|
- `fetch_latest_rates_as_df()` - Get recent OHLC data with timeframe strings (e.g., "M1", "H1", "D1")
|
|
204
205
|
- `fetch_latest_ticks_as_df()` - Get tick data for specified seconds around last tick
|
|
@@ -288,18 +289,54 @@ from pdmt5 import Mt5TradingClient
|
|
|
288
289
|
|
|
289
290
|
# Create trading client with specific order filling mode
|
|
290
291
|
with Mt5TradingClient(config=config, order_filling_mode="IOC") as trader:
|
|
292
|
+
# Place a market buy order
|
|
293
|
+
order_result = trader.place_market_order(
|
|
294
|
+
symbol="EURUSD",
|
|
295
|
+
volume=0.1,
|
|
296
|
+
order_side="BUY",
|
|
297
|
+
order_filling_mode="IOC", # Immediate or Cancel
|
|
298
|
+
order_time_mode="GTC" # Good Till Cancelled
|
|
299
|
+
)
|
|
300
|
+
print(f"Order placed: {order_result['retcode']}")
|
|
301
|
+
|
|
302
|
+
# Update stop loss and take profit for an open position
|
|
303
|
+
if positions := trader.positions_get_as_df(symbol="EURUSD"):
|
|
304
|
+
position_ticket = positions.iloc[0]['ticket']
|
|
305
|
+
update_result = trader.update_open_position_sltp(
|
|
306
|
+
symbol="EURUSD",
|
|
307
|
+
position_ticket=position_ticket,
|
|
308
|
+
sl=1.0950, # New stop loss
|
|
309
|
+
tp=1.1050 # New take profit
|
|
310
|
+
)
|
|
311
|
+
print(f"Position updated: {update_result['retcode']}")
|
|
312
|
+
|
|
313
|
+
# Calculate margin ratio for a new position
|
|
314
|
+
margin_ratio = trader.calculate_new_position_margin_ratio(
|
|
315
|
+
symbol="EURUSD",
|
|
316
|
+
new_side="SELL",
|
|
317
|
+
new_volume=0.2
|
|
318
|
+
)
|
|
319
|
+
print(f"New position margin ratio: {margin_ratio:.2%}")
|
|
320
|
+
|
|
291
321
|
# Close all EURUSD positions
|
|
292
322
|
results = trader.close_open_positions(symbols="EURUSD")
|
|
293
323
|
|
|
294
324
|
if results:
|
|
295
|
-
for
|
|
296
|
-
|
|
325
|
+
for symbol, close_results in results.items():
|
|
326
|
+
for result in close_results:
|
|
327
|
+
print(f"Closed position {result.get('position')} with result: {result['retcode']}")
|
|
297
328
|
|
|
298
329
|
# Using dry run mode for testing
|
|
299
330
|
trader_dry = Mt5TradingClient(config=config, dry_run=True)
|
|
300
331
|
with trader_dry:
|
|
301
|
-
# Test
|
|
302
|
-
|
|
332
|
+
# Test placing an order without actual execution
|
|
333
|
+
test_order = trader_dry.place_market_order(
|
|
334
|
+
symbol="GBPUSD",
|
|
335
|
+
volume=0.1,
|
|
336
|
+
order_side="SELL",
|
|
337
|
+
dry_run=True # Override instance setting
|
|
338
|
+
)
|
|
339
|
+
print(f"Test order validation: {test_order['retcode']}")
|
|
303
340
|
```
|
|
304
341
|
|
|
305
342
|
### Market Analysis with Mt5TradingClient
|
|
@@ -6,7 +6,6 @@ Pandas-based data handler for MetaTrader 5
|
|
|
6
6
|
[](https://www.python.org/downloads/)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://www.microsoft.com/windows)
|
|
9
|
-
[](https://github.com/dceoy/pdmt5)
|
|
10
9
|
|
|
11
10
|
## Overview
|
|
12
11
|
|
|
@@ -172,10 +171,12 @@ Advanced trading operations client that extends Mt5DataClient:
|
|
|
172
171
|
- `dry_run` - Test mode flag for simulating trades without execution
|
|
173
172
|
- **Position Management**:
|
|
174
173
|
- `close_open_positions()` - Close all positions for specified symbol(s)
|
|
175
|
-
- `
|
|
174
|
+
- `place_market_order()` - Place market orders with configurable side, volume, and execution modes
|
|
175
|
+
- `update_open_position_sltp()` - Modify stop loss and take profit levels for open positions
|
|
176
176
|
- **Market Analysis**:
|
|
177
177
|
- `calculate_minimum_order_margins()` - Calculate minimum required margins for buy/sell orders
|
|
178
178
|
- `calculate_spread_ratio()` - Calculate normalized bid-ask spread ratio
|
|
179
|
+
- `calculate_new_position_margin_ratio()` - Calculate margin ratio for potential new positions
|
|
179
180
|
- **Simplified Data Access**:
|
|
180
181
|
- `fetch_latest_rates_as_df()` - Get recent OHLC data with timeframe strings (e.g., "M1", "H1", "D1")
|
|
181
182
|
- `fetch_latest_ticks_as_df()` - Get tick data for specified seconds around last tick
|
|
@@ -265,18 +266,54 @@ from pdmt5 import Mt5TradingClient
|
|
|
265
266
|
|
|
266
267
|
# Create trading client with specific order filling mode
|
|
267
268
|
with Mt5TradingClient(config=config, order_filling_mode="IOC") as trader:
|
|
269
|
+
# Place a market buy order
|
|
270
|
+
order_result = trader.place_market_order(
|
|
271
|
+
symbol="EURUSD",
|
|
272
|
+
volume=0.1,
|
|
273
|
+
order_side="BUY",
|
|
274
|
+
order_filling_mode="IOC", # Immediate or Cancel
|
|
275
|
+
order_time_mode="GTC" # Good Till Cancelled
|
|
276
|
+
)
|
|
277
|
+
print(f"Order placed: {order_result['retcode']}")
|
|
278
|
+
|
|
279
|
+
# Update stop loss and take profit for an open position
|
|
280
|
+
if positions := trader.positions_get_as_df(symbol="EURUSD"):
|
|
281
|
+
position_ticket = positions.iloc[0]['ticket']
|
|
282
|
+
update_result = trader.update_open_position_sltp(
|
|
283
|
+
symbol="EURUSD",
|
|
284
|
+
position_ticket=position_ticket,
|
|
285
|
+
sl=1.0950, # New stop loss
|
|
286
|
+
tp=1.1050 # New take profit
|
|
287
|
+
)
|
|
288
|
+
print(f"Position updated: {update_result['retcode']}")
|
|
289
|
+
|
|
290
|
+
# Calculate margin ratio for a new position
|
|
291
|
+
margin_ratio = trader.calculate_new_position_margin_ratio(
|
|
292
|
+
symbol="EURUSD",
|
|
293
|
+
new_side="SELL",
|
|
294
|
+
new_volume=0.2
|
|
295
|
+
)
|
|
296
|
+
print(f"New position margin ratio: {margin_ratio:.2%}")
|
|
297
|
+
|
|
268
298
|
# Close all EURUSD positions
|
|
269
299
|
results = trader.close_open_positions(symbols="EURUSD")
|
|
270
300
|
|
|
271
301
|
if results:
|
|
272
|
-
for
|
|
273
|
-
|
|
302
|
+
for symbol, close_results in results.items():
|
|
303
|
+
for result in close_results:
|
|
304
|
+
print(f"Closed position {result.get('position')} with result: {result['retcode']}")
|
|
274
305
|
|
|
275
306
|
# Using dry run mode for testing
|
|
276
307
|
trader_dry = Mt5TradingClient(config=config, dry_run=True)
|
|
277
308
|
with trader_dry:
|
|
278
|
-
# Test
|
|
279
|
-
|
|
309
|
+
# Test placing an order without actual execution
|
|
310
|
+
test_order = trader_dry.place_market_order(
|
|
311
|
+
symbol="GBPUSD",
|
|
312
|
+
volume=0.1,
|
|
313
|
+
order_side="SELL",
|
|
314
|
+
dry_run=True # Override instance setting
|
|
315
|
+
)
|
|
316
|
+
print(f"Test order validation: {test_order['retcode']}")
|
|
280
317
|
```
|
|
281
318
|
|
|
282
319
|
### Market Analysis with Mt5TradingClient
|
|
@@ -31,12 +31,11 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
31
31
|
description="Order filling mode: 'IOC' (Immediate or Cancel), "
|
|
32
32
|
"'FOK' (Fill or Kill), 'RETURN' (Return if not filled)",
|
|
33
33
|
)
|
|
34
|
-
dry_run: bool = Field(default=False, description="Enable dry run mode for testing.")
|
|
35
34
|
|
|
36
35
|
def close_open_positions(
|
|
37
36
|
self,
|
|
38
37
|
symbols: str | list[str] | tuple[str, ...] | None = None,
|
|
39
|
-
dry_run: bool
|
|
38
|
+
dry_run: bool = False,
|
|
40
39
|
**kwargs: Any, # noqa: ANN401
|
|
41
40
|
) -> dict[str, list[dict[str, Any]]]:
|
|
42
41
|
"""Close all open positions for specified symbols.
|
|
@@ -44,8 +43,7 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
44
43
|
Args:
|
|
45
44
|
symbols: Optional symbol or list of symbols to filter positions.
|
|
46
45
|
If None, all symbols will be considered.
|
|
47
|
-
dry_run:
|
|
48
|
-
`dry_run` attribute.
|
|
46
|
+
dry_run: If True, only check the order without sending it.
|
|
49
47
|
**kwargs: Additional keyword arguments for request parameters.
|
|
50
48
|
|
|
51
49
|
Returns:
|
|
@@ -67,15 +65,14 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
67
65
|
def _fetch_and_close_position(
|
|
68
66
|
self,
|
|
69
67
|
symbol: str | None = None,
|
|
70
|
-
dry_run: bool
|
|
68
|
+
dry_run: bool = False,
|
|
71
69
|
**kwargs: Any, # noqa: ANN401
|
|
72
70
|
) -> list[dict[str, Any]]:
|
|
73
71
|
"""Close all open positions for a specific symbol.
|
|
74
72
|
|
|
75
73
|
Args:
|
|
76
74
|
symbol: Optional symbol filter.
|
|
77
|
-
dry_run:
|
|
78
|
-
`dry_run` attribute.
|
|
75
|
+
dry_run: If True, only check the order without sending it.
|
|
79
76
|
**kwargs: Additional keyword arguments for request parameters.
|
|
80
77
|
|
|
81
78
|
Returns:
|
|
@@ -92,7 +89,7 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
92
89
|
f"ORDER_FILLING_{self.order_filling_mode}",
|
|
93
90
|
)
|
|
94
91
|
return [
|
|
95
|
-
self.
|
|
92
|
+
self._send_or_check_order(
|
|
96
93
|
request={
|
|
97
94
|
"action": self.mt5.TRADE_ACTION_DEAL,
|
|
98
95
|
"symbol": p["symbol"],
|
|
@@ -112,17 +109,16 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
112
109
|
for p in positions_dict
|
|
113
110
|
]
|
|
114
111
|
|
|
115
|
-
def
|
|
112
|
+
def _send_or_check_order(
|
|
116
113
|
self,
|
|
117
114
|
request: dict[str, Any],
|
|
118
|
-
dry_run: bool
|
|
115
|
+
dry_run: bool = False,
|
|
119
116
|
) -> dict[str, Any]:
|
|
120
117
|
"""Send or check an order request.
|
|
121
118
|
|
|
122
119
|
Args:
|
|
123
120
|
request: Order request dictionary.
|
|
124
|
-
dry_run:
|
|
125
|
-
`dry_run` attribute.
|
|
121
|
+
dry_run: If True, only check the order without sending it.
|
|
126
122
|
|
|
127
123
|
Returns:
|
|
128
124
|
Dictionary with operation result.
|
|
@@ -131,17 +127,15 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
131
127
|
Mt5TradingError: If the order operation fails.
|
|
132
128
|
"""
|
|
133
129
|
self.logger.debug("request: %s", request)
|
|
134
|
-
|
|
135
|
-
self.logger.debug("is_dry_run: %s", is_dry_run)
|
|
136
|
-
if is_dry_run:
|
|
130
|
+
if dry_run:
|
|
137
131
|
response = self.order_check_as_dict(request=request)
|
|
138
132
|
order_func = "order_check"
|
|
139
133
|
else:
|
|
140
134
|
response = self.order_send_as_dict(request=request)
|
|
141
135
|
order_func = "order_send"
|
|
142
136
|
retcode = response.get("retcode")
|
|
143
|
-
if ((not
|
|
144
|
-
|
|
137
|
+
if ((not dry_run) and retcode == self.mt5.TRADE_RETCODE_DONE) or (
|
|
138
|
+
dry_run and retcode == 0
|
|
145
139
|
):
|
|
146
140
|
self.logger.info("response: %s", response)
|
|
147
141
|
return response
|
|
@@ -159,6 +153,116 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
159
153
|
error_message = f"{order_func}() failed and aborted. <= `{comment}`"
|
|
160
154
|
raise Mt5TradingError(error_message)
|
|
161
155
|
|
|
156
|
+
def place_market_order(
|
|
157
|
+
self,
|
|
158
|
+
symbol: str,
|
|
159
|
+
volume: float,
|
|
160
|
+
order_side: Literal["BUY", "SELL"],
|
|
161
|
+
order_filling_mode: Literal["IOC", "FOK", "RETURN"] = "IOC",
|
|
162
|
+
order_time_mode: Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] = "GTC",
|
|
163
|
+
dry_run: bool = False,
|
|
164
|
+
**kwargs: Any, # noqa: ANN401
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Send or check an order request to place a market order.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
symbol: Symbol for the order.
|
|
170
|
+
volume: Volume of the order.
|
|
171
|
+
order_side: Side of the order, either "BUY" or "SELL".
|
|
172
|
+
order_filling_mode: Order filling mode, either "IOC", "FOK", or "RETURN".
|
|
173
|
+
order_time_mode: Order time mode, either "GTC", "DAY", "SPECIFIED",
|
|
174
|
+
or "SPECIFIED_DAY".
|
|
175
|
+
dry_run: If True, only check the order without sending it.
|
|
176
|
+
**kwargs: Additional keyword arguments for request parameters.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dictionary with operation result.
|
|
180
|
+
"""
|
|
181
|
+
return self._send_or_check_order(
|
|
182
|
+
request={
|
|
183
|
+
"action": self.mt5.TRADE_ACTION_DEAL,
|
|
184
|
+
"symbol": symbol,
|
|
185
|
+
"volume": volume,
|
|
186
|
+
"type": getattr(self.mt5, f"ORDER_TYPE_{order_side.upper()}"),
|
|
187
|
+
"type_filling": getattr(
|
|
188
|
+
self.mt5, f"ORDER_FILLING_{order_filling_mode.upper()}"
|
|
189
|
+
),
|
|
190
|
+
"type_time": getattr(self.mt5, f"ORDER_TIME_{order_time_mode.upper()}"),
|
|
191
|
+
**kwargs,
|
|
192
|
+
},
|
|
193
|
+
dry_run=dry_run,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def update_sltp_for_open_positions(
|
|
197
|
+
self,
|
|
198
|
+
symbol: str,
|
|
199
|
+
stop_loss: float | None = None,
|
|
200
|
+
take_profit: float | None = None,
|
|
201
|
+
tickets: list[int] | None = None,
|
|
202
|
+
dry_run: bool = False,
|
|
203
|
+
**kwargs: Any, # noqa: ANN401
|
|
204
|
+
) -> list[dict[str, Any]]:
|
|
205
|
+
"""Change Stop Loss and Take Profit for open positions.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
symbol: Symbol for the position.
|
|
209
|
+
stop_loss: New Stop Loss price. If None, it will not be changed.
|
|
210
|
+
take_profit: New Take Profit price. If None, it will not be changed.
|
|
211
|
+
tickets: List of position tickets to filter positions. If None, all open
|
|
212
|
+
positions for the symbol will be considered.
|
|
213
|
+
dry_run: If True, only check the order without sending it.
|
|
214
|
+
**kwargs: Additional keyword arguments for request parameters.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
List of dictionaries with operation results for each updated position.
|
|
218
|
+
"""
|
|
219
|
+
positions_df = self.positions_get_as_df(symbol=symbol)
|
|
220
|
+
if positions_df.empty:
|
|
221
|
+
self.logger.warning("No open positions found for symbol: %s", symbol)
|
|
222
|
+
return []
|
|
223
|
+
elif tickets:
|
|
224
|
+
filtered_positions_df = positions_df.pipe(
|
|
225
|
+
lambda d: d[d["ticket"].isin(tickets)]
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
filtered_positions_df = positions_df
|
|
229
|
+
if filtered_positions_df.empty:
|
|
230
|
+
self.logger.warning(
|
|
231
|
+
"No open positions found for symbol: %s with specified tickets: %s",
|
|
232
|
+
symbol,
|
|
233
|
+
tickets,
|
|
234
|
+
)
|
|
235
|
+
return []
|
|
236
|
+
else:
|
|
237
|
+
symbol_info = self.symbol_info_as_dict(symbol=symbol)
|
|
238
|
+
sl = round(stop_loss, symbol_info["digits"]) if stop_loss else None
|
|
239
|
+
tp = round(take_profit, symbol_info["digits"]) if take_profit else None
|
|
240
|
+
order_requests = [
|
|
241
|
+
{
|
|
242
|
+
"action": self.mt5.TRADE_ACTION_SLTP,
|
|
243
|
+
"symbol": p["symbol"],
|
|
244
|
+
"position": p["ticket"],
|
|
245
|
+
"sl": (sl or p["sl"]),
|
|
246
|
+
"tp": (tp or p["tp"]),
|
|
247
|
+
**kwargs,
|
|
248
|
+
}
|
|
249
|
+
for _, p in filtered_positions_df.iterrows()
|
|
250
|
+
if sl != p["sl"] or tp != p["tp"]
|
|
251
|
+
]
|
|
252
|
+
if order_requests:
|
|
253
|
+
return [
|
|
254
|
+
self._send_or_check_order(request=r, dry_run=dry_run)
|
|
255
|
+
for r in order_requests
|
|
256
|
+
]
|
|
257
|
+
else:
|
|
258
|
+
self.logger.info(
|
|
259
|
+
"No positions to update for symbol: %s with SL: %s and TP: %s",
|
|
260
|
+
symbol,
|
|
261
|
+
sl,
|
|
262
|
+
tp,
|
|
263
|
+
)
|
|
264
|
+
return []
|
|
265
|
+
|
|
162
266
|
def calculate_minimum_order_margins(self, symbol: str) -> dict[str, float]:
|
|
163
267
|
"""Calculate minimum order margins for a given symbol.
|
|
164
268
|
|
|
@@ -369,3 +473,50 @@ class Mt5TradingClient(Mt5DataClient):
|
|
|
369
473
|
)
|
|
370
474
|
.drop(columns=["buy_i", "sell_i", "sign", "underlier_increase_ratio"])
|
|
371
475
|
)
|
|
476
|
+
|
|
477
|
+
def calculate_new_position_margin_ratio(
|
|
478
|
+
self,
|
|
479
|
+
symbol: str,
|
|
480
|
+
new_side: Literal["BUY", "SELL"] | None = None,
|
|
481
|
+
new_volume: float = 0,
|
|
482
|
+
) -> float:
|
|
483
|
+
"""Calculate the margin ratio for a new position.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
symbol: Symbol for which to calculate the margin ratio.
|
|
487
|
+
new_side: Side of the new position, either "BUY" or "SELL".
|
|
488
|
+
new_volume: Volume of the new position.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
float: Margin ratio for the new position as a fraction of account equity.
|
|
492
|
+
"""
|
|
493
|
+
account_info = self.account_info_as_dict()
|
|
494
|
+
if not account_info["equity"]:
|
|
495
|
+
return 0.0
|
|
496
|
+
else:
|
|
497
|
+
positions_df = self.fetch_positions_with_metrics_as_df(symbol=symbol)
|
|
498
|
+
current_signed_margin = (
|
|
499
|
+
positions_df["signed_margin"].sum() if positions_df.size else 0
|
|
500
|
+
)
|
|
501
|
+
symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
|
|
502
|
+
if new_volume == 0:
|
|
503
|
+
new_signed_margin = 0
|
|
504
|
+
elif new_side == "BUY":
|
|
505
|
+
new_signed_margin = self.order_calc_margin(
|
|
506
|
+
action=self.mt5.ORDER_TYPE_BUY,
|
|
507
|
+
symbol=symbol,
|
|
508
|
+
volume=new_volume,
|
|
509
|
+
price=symbol_info_tick["ask"],
|
|
510
|
+
)
|
|
511
|
+
elif new_side == "SELL":
|
|
512
|
+
new_signed_margin = -self.order_calc_margin(
|
|
513
|
+
action=self.mt5.ORDER_TYPE_SELL,
|
|
514
|
+
symbol=symbol,
|
|
515
|
+
volume=new_volume,
|
|
516
|
+
price=symbol_info_tick["bid"],
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
new_signed_margin = 0
|
|
520
|
+
return abs(
|
|
521
|
+
(new_signed_margin + current_signed_margin) / account_info["equity"]
|
|
522
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pdmt5"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
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"}]
|
|
@@ -183,17 +183,14 @@ class TestMt5TradingClient:
|
|
|
183
183
|
"""Test client initialization with default parameters."""
|
|
184
184
|
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
185
185
|
assert client.order_filling_mode == "IOC"
|
|
186
|
-
assert client.dry_run is False
|
|
187
186
|
|
|
188
187
|
def test_client_initialization_custom(self, mock_mt5_import: ModuleType) -> None:
|
|
189
188
|
"""Test client initialization with custom parameters."""
|
|
190
189
|
client = Mt5TradingClient(
|
|
191
190
|
mt5=mock_mt5_import,
|
|
192
191
|
order_filling_mode="FOK",
|
|
193
|
-
dry_run=True,
|
|
194
192
|
)
|
|
195
193
|
assert client.order_filling_mode == "FOK"
|
|
196
|
-
assert client.dry_run is True
|
|
197
194
|
|
|
198
195
|
def test_client_initialization_invalid_filling_mode(
|
|
199
196
|
self, mock_mt5_import: ModuleType
|
|
@@ -250,7 +247,7 @@ class TestMt5TradingClient:
|
|
|
250
247
|
mock_position_buy: MockPositionInfo,
|
|
251
248
|
) -> None:
|
|
252
249
|
"""Test close_position with existing positions in dry run mode."""
|
|
253
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
250
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
254
251
|
mock_mt5_import.initialize.return_value = True
|
|
255
252
|
client.initialize()
|
|
256
253
|
|
|
@@ -263,7 +260,7 @@ class TestMt5TradingClient:
|
|
|
263
260
|
"result": "check_success",
|
|
264
261
|
}
|
|
265
262
|
|
|
266
|
-
result = client.close_open_positions("EURUSD")
|
|
263
|
+
result = client.close_open_positions("EURUSD", dry_run=True)
|
|
267
264
|
|
|
268
265
|
assert len(result["EURUSD"]) == 1
|
|
269
266
|
assert result["EURUSD"][0]["retcode"] == 0
|
|
@@ -276,8 +273,8 @@ class TestMt5TradingClient:
|
|
|
276
273
|
mock_position_buy: MockPositionInfo,
|
|
277
274
|
) -> None:
|
|
278
275
|
"""Test close_position with dry_run parameter override."""
|
|
279
|
-
# Client initialized
|
|
280
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
276
|
+
# Client initialized without dry_run
|
|
277
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
281
278
|
mock_mt5_import.initialize.return_value = True
|
|
282
279
|
client.initialize()
|
|
283
280
|
|
|
@@ -305,8 +302,8 @@ class TestMt5TradingClient:
|
|
|
305
302
|
mock_position_buy: MockPositionInfo,
|
|
306
303
|
) -> None:
|
|
307
304
|
"""Test close_position with real mode override."""
|
|
308
|
-
# Client initialized
|
|
309
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
305
|
+
# Client initialized without dry_run
|
|
306
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
310
307
|
mock_mt5_import.initialize.return_value = True
|
|
311
308
|
client.initialize()
|
|
312
309
|
|
|
@@ -425,7 +422,7 @@ class TestMt5TradingClient:
|
|
|
425
422
|
mock_position_buy: MockPositionInfo,
|
|
426
423
|
) -> None:
|
|
427
424
|
"""Test close_open_positions with additional kwargs and dry_run override."""
|
|
428
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
425
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
429
426
|
mock_mt5_import.initialize.return_value = True
|
|
430
427
|
client.initialize()
|
|
431
428
|
|
|
@@ -456,8 +453,8 @@ class TestMt5TradingClient:
|
|
|
456
453
|
self,
|
|
457
454
|
mock_mt5_import: ModuleType,
|
|
458
455
|
) -> None:
|
|
459
|
-
"""Test
|
|
460
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
456
|
+
"""Test _send_or_check_order in dry run mode with success."""
|
|
457
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
461
458
|
mock_mt5_import.initialize.return_value = True
|
|
462
459
|
client.initialize()
|
|
463
460
|
|
|
@@ -475,7 +472,7 @@ class TestMt5TradingClient:
|
|
|
475
472
|
"result": "check_success",
|
|
476
473
|
}
|
|
477
474
|
|
|
478
|
-
result = client.
|
|
475
|
+
result = client._send_or_check_order(request, dry_run=True)
|
|
479
476
|
|
|
480
477
|
assert result["retcode"] == 0
|
|
481
478
|
assert result["result"] == "check_success"
|
|
@@ -485,8 +482,8 @@ class TestMt5TradingClient:
|
|
|
485
482
|
self,
|
|
486
483
|
mock_mt5_import: ModuleType,
|
|
487
484
|
) -> None:
|
|
488
|
-
"""Test
|
|
489
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
485
|
+
"""Test _send_or_check_order in real mode with success."""
|
|
486
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
490
487
|
mock_mt5_import.initialize.return_value = True
|
|
491
488
|
client.initialize()
|
|
492
489
|
|
|
@@ -504,7 +501,7 @@ class TestMt5TradingClient:
|
|
|
504
501
|
"result": "send_success",
|
|
505
502
|
}
|
|
506
503
|
|
|
507
|
-
result = client.
|
|
504
|
+
result = client._send_or_check_order(request)
|
|
508
505
|
|
|
509
506
|
assert result["retcode"] == 10009
|
|
510
507
|
assert result["result"] == "send_success"
|
|
@@ -514,8 +511,8 @@ class TestMt5TradingClient:
|
|
|
514
511
|
self,
|
|
515
512
|
mock_mt5_import: ModuleType,
|
|
516
513
|
) -> None:
|
|
517
|
-
"""Test
|
|
518
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
514
|
+
"""Test _send_or_check_order with trade disabled."""
|
|
515
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
519
516
|
mock_mt5_import.initialize.return_value = True
|
|
520
517
|
client.initialize()
|
|
521
518
|
|
|
@@ -533,7 +530,7 @@ class TestMt5TradingClient:
|
|
|
533
530
|
"comment": "Trade disabled",
|
|
534
531
|
}
|
|
535
532
|
|
|
536
|
-
result = client.
|
|
533
|
+
result = client._send_or_check_order(request)
|
|
537
534
|
|
|
538
535
|
assert result["retcode"] == 10017
|
|
539
536
|
|
|
@@ -541,8 +538,8 @@ class TestMt5TradingClient:
|
|
|
541
538
|
self,
|
|
542
539
|
mock_mt5_import: ModuleType,
|
|
543
540
|
) -> None:
|
|
544
|
-
"""Test
|
|
545
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
541
|
+
"""Test _send_or_check_order with market closed."""
|
|
542
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
546
543
|
mock_mt5_import.initialize.return_value = True
|
|
547
544
|
client.initialize()
|
|
548
545
|
|
|
@@ -560,7 +557,7 @@ class TestMt5TradingClient:
|
|
|
560
557
|
"comment": "Market closed",
|
|
561
558
|
}
|
|
562
559
|
|
|
563
|
-
result = client.
|
|
560
|
+
result = client._send_or_check_order(request)
|
|
564
561
|
|
|
565
562
|
assert result["retcode"] == 10018
|
|
566
563
|
|
|
@@ -568,8 +565,8 @@ class TestMt5TradingClient:
|
|
|
568
565
|
self,
|
|
569
566
|
mock_mt5_import: ModuleType,
|
|
570
567
|
) -> None:
|
|
571
|
-
"""Test
|
|
572
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
568
|
+
"""Test _send_or_check_order with failure."""
|
|
569
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
573
570
|
mock_mt5_import.initialize.return_value = True
|
|
574
571
|
client.initialize()
|
|
575
572
|
|
|
@@ -588,14 +585,14 @@ class TestMt5TradingClient:
|
|
|
588
585
|
}
|
|
589
586
|
|
|
590
587
|
with pytest.raises(Mt5TradingError, match=r"order_send\(\) failed and aborted"):
|
|
591
|
-
client.
|
|
588
|
+
client._send_or_check_order(request)
|
|
592
589
|
|
|
593
590
|
def test_send_or_check_order_dry_run_failure(
|
|
594
591
|
self,
|
|
595
592
|
mock_mt5_import: ModuleType,
|
|
596
593
|
) -> None:
|
|
597
|
-
"""Test
|
|
598
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
594
|
+
"""Test _send_or_check_order in dry run mode with failure."""
|
|
595
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
599
596
|
mock_mt5_import.initialize.return_value = True
|
|
600
597
|
client.initialize()
|
|
601
598
|
|
|
@@ -616,15 +613,15 @@ class TestMt5TradingClient:
|
|
|
616
613
|
with pytest.raises(
|
|
617
614
|
Mt5TradingError, match=r"order_check\(\) failed and aborted"
|
|
618
615
|
):
|
|
619
|
-
client.
|
|
616
|
+
client._send_or_check_order(request, dry_run=True)
|
|
620
617
|
|
|
621
618
|
def test_send_or_check_order_dry_run_override(
|
|
622
619
|
self,
|
|
623
620
|
mock_mt5_import: ModuleType,
|
|
624
621
|
) -> None:
|
|
625
|
-
"""Test
|
|
626
|
-
# Client initialized
|
|
627
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
622
|
+
"""Test _send_or_check_order with dry_run parameter override."""
|
|
623
|
+
# Client initialized without dry_run
|
|
624
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
628
625
|
mock_mt5_import.initialize.return_value = True
|
|
629
626
|
client.initialize()
|
|
630
627
|
|
|
@@ -643,7 +640,7 @@ class TestMt5TradingClient:
|
|
|
643
640
|
}
|
|
644
641
|
|
|
645
642
|
# Override with dry_run=True
|
|
646
|
-
result = client.
|
|
643
|
+
result = client._send_or_check_order(request, dry_run=True)
|
|
647
644
|
|
|
648
645
|
assert result["retcode"] == 0
|
|
649
646
|
assert result["result"] == "check_success"
|
|
@@ -655,9 +652,9 @@ class TestMt5TradingClient:
|
|
|
655
652
|
self,
|
|
656
653
|
mock_mt5_import: ModuleType,
|
|
657
654
|
) -> None:
|
|
658
|
-
"""Test
|
|
659
|
-
# Client initialized
|
|
660
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
655
|
+
"""Test _send_or_check_order with real mode override."""
|
|
656
|
+
# Client initialized without dry_run
|
|
657
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
661
658
|
mock_mt5_import.initialize.return_value = True
|
|
662
659
|
client.initialize()
|
|
663
660
|
|
|
@@ -676,7 +673,7 @@ class TestMt5TradingClient:
|
|
|
676
673
|
}
|
|
677
674
|
|
|
678
675
|
# Override with dry_run=False
|
|
679
|
-
result = client.
|
|
676
|
+
result = client._send_or_check_order(request, dry_run=False)
|
|
680
677
|
|
|
681
678
|
assert result["retcode"] == 10009
|
|
682
679
|
assert result["result"] == "send_success"
|
|
@@ -684,6 +681,52 @@ class TestMt5TradingClient:
|
|
|
684
681
|
mock_mt5_import.order_send.assert_called_once_with(request)
|
|
685
682
|
mock_mt5_import.order_check.assert_not_called()
|
|
686
683
|
|
|
684
|
+
def test_place_market_order(
|
|
685
|
+
self,
|
|
686
|
+
mock_mt5_import: ModuleType,
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Test place_market_order method."""
|
|
689
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
690
|
+
mock_mt5_import.initialize.return_value = True
|
|
691
|
+
client.initialize()
|
|
692
|
+
|
|
693
|
+
# Mock MT5 constants
|
|
694
|
+
mock_mt5_import.ORDER_TYPE_BUY = 0
|
|
695
|
+
mock_mt5_import.ORDER_FILLING_IOC = 1
|
|
696
|
+
mock_mt5_import.ORDER_TIME_GTC = 0
|
|
697
|
+
mock_mt5_import.TRADE_ACTION_DEAL = 1
|
|
698
|
+
|
|
699
|
+
# Mock successful order send
|
|
700
|
+
mock_mt5_import.order_send.return_value.retcode = 10009
|
|
701
|
+
mock_mt5_import.order_send.return_value._asdict.return_value = {
|
|
702
|
+
"retcode": 10009,
|
|
703
|
+
"deal": 123456,
|
|
704
|
+
"order": 789012,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
result = client.place_market_order(
|
|
708
|
+
symbol="EURUSD",
|
|
709
|
+
volume=0.1,
|
|
710
|
+
order_side="BUY",
|
|
711
|
+
order_filling_mode="IOC",
|
|
712
|
+
order_time_mode="GTC",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
assert result["retcode"] == 10009
|
|
716
|
+
assert result["deal"] == 123456
|
|
717
|
+
assert result["order"] == 789012
|
|
718
|
+
|
|
719
|
+
# Verify the request was built correctly
|
|
720
|
+
expected_request = {
|
|
721
|
+
"action": 1, # TRADE_ACTION_DEAL
|
|
722
|
+
"symbol": "EURUSD",
|
|
723
|
+
"volume": 0.1,
|
|
724
|
+
"type": 0, # ORDER_TYPE_BUY
|
|
725
|
+
"type_filling": 1, # ORDER_FILLING_IOC
|
|
726
|
+
"type_time": 0, # ORDER_TIME_GTC
|
|
727
|
+
}
|
|
728
|
+
mock_mt5_import.order_send.assert_called_once_with(expected_request)
|
|
729
|
+
|
|
687
730
|
def test_order_filling_mode_constants(
|
|
688
731
|
self,
|
|
689
732
|
mock_mt5_import: ModuleType,
|
|
@@ -751,7 +794,7 @@ class TestMt5TradingClient:
|
|
|
751
794
|
mock_position_sell: MockPositionInfo,
|
|
752
795
|
) -> None:
|
|
753
796
|
"""Test _fetch_and_close_position with dry_run parameter."""
|
|
754
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
797
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
755
798
|
mock_mt5_import.initialize.return_value = True
|
|
756
799
|
client.initialize()
|
|
757
800
|
|
|
@@ -780,9 +823,9 @@ class TestMt5TradingClient:
|
|
|
780
823
|
mock_mt5_import: ModuleType,
|
|
781
824
|
mock_position_buy: MockPositionInfo,
|
|
782
825
|
) -> None:
|
|
783
|
-
"""Test _fetch_and_close_position
|
|
784
|
-
# Client initialized
|
|
785
|
-
client = Mt5TradingClient(mt5=mock_mt5_import
|
|
826
|
+
"""Test _fetch_and_close_position does not inherit dry_run from instance."""
|
|
827
|
+
# Client initialized without dry_run
|
|
828
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
786
829
|
mock_mt5_import.initialize.return_value = True
|
|
787
830
|
client.initialize()
|
|
788
831
|
|
|
@@ -794,8 +837,8 @@ class TestMt5TradingClient:
|
|
|
794
837
|
"result": "check_success",
|
|
795
838
|
}
|
|
796
839
|
|
|
797
|
-
# Call
|
|
798
|
-
result = client._fetch_and_close_position(symbol="EURUSD")
|
|
840
|
+
# Call with dry_run=True explicitly
|
|
841
|
+
result = client._fetch_and_close_position(symbol="EURUSD", dry_run=True)
|
|
799
842
|
|
|
800
843
|
assert len(result) == 1
|
|
801
844
|
assert result[0]["retcode"] == 0
|
|
@@ -1300,3 +1343,421 @@ class TestMt5TradingClient:
|
|
|
1300
1343
|
|
|
1301
1344
|
# Verify order_calc_margin was called twice (ask and bid)
|
|
1302
1345
|
assert mock_mt5_import.order_calc_margin.call_count == 2
|
|
1346
|
+
|
|
1347
|
+
def test_calculate_new_position_margin_ratio_no_equity(
|
|
1348
|
+
self, mock_mt5_import: ModuleType
|
|
1349
|
+
) -> None:
|
|
1350
|
+
"""Test calculating margin ratio when account has no equity."""
|
|
1351
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1352
|
+
mock_mt5_import.initialize.return_value = True
|
|
1353
|
+
client.initialize()
|
|
1354
|
+
|
|
1355
|
+
# Mock account info with zero equity
|
|
1356
|
+
mock_mt5_import.account_info.return_value._asdict.return_value = {
|
|
1357
|
+
"equity": 0.0,
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
result = client.calculate_new_position_margin_ratio(
|
|
1361
|
+
symbol="EURUSD", new_side="BUY", new_volume=0.1
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
assert result == 0.0
|
|
1365
|
+
|
|
1366
|
+
def test_calculate_new_position_margin_ratio_buy_position(
|
|
1367
|
+
self, mock_mt5_import: ModuleType, mocker: MockerFixture
|
|
1368
|
+
) -> None:
|
|
1369
|
+
"""Test calculating margin ratio for a new buy position."""
|
|
1370
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1371
|
+
mock_mt5_import.initialize.return_value = True
|
|
1372
|
+
client.initialize()
|
|
1373
|
+
|
|
1374
|
+
# Mock account info
|
|
1375
|
+
mock_mt5_import.account_info.return_value._asdict.return_value = {
|
|
1376
|
+
"equity": 10000.0,
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
# Mock existing positions
|
|
1380
|
+
mock_position = mocker.MagicMock()
|
|
1381
|
+
mock_position._asdict.return_value = {
|
|
1382
|
+
"ticket": 12345,
|
|
1383
|
+
"symbol": "EURUSD",
|
|
1384
|
+
"volume": 0.1,
|
|
1385
|
+
"type": 0, # POSITION_TYPE_BUY
|
|
1386
|
+
"time": 1234567890,
|
|
1387
|
+
"price_open": 1.2,
|
|
1388
|
+
"price_current": 1.205,
|
|
1389
|
+
"profit": 5.0,
|
|
1390
|
+
"sl": 0.0,
|
|
1391
|
+
"tp": 0.0,
|
|
1392
|
+
"identifier": 12345,
|
|
1393
|
+
"reason": 0,
|
|
1394
|
+
"swap": 0.0,
|
|
1395
|
+
"magic": 0,
|
|
1396
|
+
"comment": "test",
|
|
1397
|
+
"external_id": "",
|
|
1398
|
+
}
|
|
1399
|
+
mock_mt5_import.positions_get.return_value = [mock_position]
|
|
1400
|
+
|
|
1401
|
+
# Mock symbol tick info
|
|
1402
|
+
mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
|
|
1403
|
+
"time": pd.Timestamp("2009-02-14 00:31:30"),
|
|
1404
|
+
"ask": 1.1002,
|
|
1405
|
+
"bid": 1.1000,
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
# Mock order calc margin
|
|
1409
|
+
mock_mt5_import.order_calc_margin.return_value = 1000.0
|
|
1410
|
+
|
|
1411
|
+
result = client.calculate_new_position_margin_ratio(
|
|
1412
|
+
symbol="EURUSD", new_side="BUY", new_volume=0.1
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
# Should return (new_margin + current_margin) / equity
|
|
1416
|
+
# current_margin = 100.0 (from position), new_margin = 1000.0
|
|
1417
|
+
expected_ratio = abs((1000.0 + 100.0) / 10000.0)
|
|
1418
|
+
assert result == expected_ratio
|
|
1419
|
+
|
|
1420
|
+
def test_calculate_new_position_margin_ratio_sell_position(
|
|
1421
|
+
self, mock_mt5_import: ModuleType
|
|
1422
|
+
) -> None:
|
|
1423
|
+
"""Test calculating margin ratio for a new sell position."""
|
|
1424
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1425
|
+
mock_mt5_import.initialize.return_value = True
|
|
1426
|
+
client.initialize()
|
|
1427
|
+
|
|
1428
|
+
# Mock account info
|
|
1429
|
+
mock_mt5_import.account_info.return_value._asdict.return_value = {
|
|
1430
|
+
"equity": 10000.0,
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
# Mock empty positions
|
|
1434
|
+
mock_mt5_import.positions_get.return_value = []
|
|
1435
|
+
|
|
1436
|
+
# Mock symbol tick info
|
|
1437
|
+
mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
|
|
1438
|
+
"time": pd.Timestamp("2009-02-14 00:31:30"),
|
|
1439
|
+
"ask": 1.1002,
|
|
1440
|
+
"bid": 1.1000,
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
# Mock order calc margin
|
|
1444
|
+
mock_mt5_import.order_calc_margin.return_value = 1000.0
|
|
1445
|
+
|
|
1446
|
+
result = client.calculate_new_position_margin_ratio(
|
|
1447
|
+
symbol="EURUSD", new_side="SELL", new_volume=0.1
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
# Should return abs(-new_margin / equity) for sell
|
|
1451
|
+
expected_ratio = abs(-1000.0 / 10000.0)
|
|
1452
|
+
assert result == expected_ratio
|
|
1453
|
+
|
|
1454
|
+
def test_calculate_new_position_margin_ratio_zero_volume(
|
|
1455
|
+
self, mock_mt5_import: ModuleType
|
|
1456
|
+
) -> None:
|
|
1457
|
+
"""Test calculating margin ratio with zero volume."""
|
|
1458
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1459
|
+
mock_mt5_import.initialize.return_value = True
|
|
1460
|
+
client.initialize()
|
|
1461
|
+
|
|
1462
|
+
# Mock account info
|
|
1463
|
+
mock_mt5_import.account_info.return_value._asdict.return_value = {
|
|
1464
|
+
"equity": 10000.0,
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
# Mock empty positions
|
|
1468
|
+
mock_mt5_import.positions_get.return_value = []
|
|
1469
|
+
|
|
1470
|
+
# Mock symbol tick info
|
|
1471
|
+
mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
|
|
1472
|
+
"time": pd.Timestamp("2009-02-14 00:31:30"),
|
|
1473
|
+
"ask": 1.1002,
|
|
1474
|
+
"bid": 1.1000,
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
result = client.calculate_new_position_margin_ratio(
|
|
1478
|
+
symbol="EURUSD", new_side="BUY", new_volume=0
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
# Should return 0 since new_volume is 0
|
|
1482
|
+
assert result == 0.0
|
|
1483
|
+
|
|
1484
|
+
def test_calculate_new_position_margin_ratio_invalid_side(
|
|
1485
|
+
self, mock_mt5_import: ModuleType
|
|
1486
|
+
) -> None:
|
|
1487
|
+
"""Test calculating margin ratio with invalid side."""
|
|
1488
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1489
|
+
mock_mt5_import.initialize.return_value = True
|
|
1490
|
+
client.initialize()
|
|
1491
|
+
|
|
1492
|
+
# Mock account info
|
|
1493
|
+
mock_mt5_import.account_info.return_value._asdict.return_value = {
|
|
1494
|
+
"equity": 10000.0,
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
# Mock empty positions
|
|
1498
|
+
mock_mt5_import.positions_get.return_value = []
|
|
1499
|
+
|
|
1500
|
+
# Mock symbol tick info
|
|
1501
|
+
mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
|
|
1502
|
+
"time": pd.Timestamp("2009-02-14 00:31:30"),
|
|
1503
|
+
"ask": 1.1002,
|
|
1504
|
+
"bid": 1.1000,
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
result = client.calculate_new_position_margin_ratio(
|
|
1508
|
+
symbol="EURUSD", new_side=None, new_volume=0.1
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
# Should return 0 since side is invalid
|
|
1512
|
+
assert result == 0.0
|
|
1513
|
+
|
|
1514
|
+
def test_update_sltp_for_open_positions(self, mock_mt5_import: ModuleType) -> None:
|
|
1515
|
+
"""Test update_sltp_for_open_positions method."""
|
|
1516
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1517
|
+
mock_mt5_import.initialize.return_value = True
|
|
1518
|
+
client.initialize()
|
|
1519
|
+
|
|
1520
|
+
# Mock MT5 constants
|
|
1521
|
+
mock_mt5_import.TRADE_ACTION_SLTP = 6
|
|
1522
|
+
|
|
1523
|
+
# Mock symbol info
|
|
1524
|
+
mock_mt5_import.symbol_info.return_value._asdict.return_value = {
|
|
1525
|
+
"digits": 5,
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
# Mock positions for the symbol
|
|
1529
|
+
mock_position = MockPositionInfo(
|
|
1530
|
+
ticket=123456,
|
|
1531
|
+
time=123456789,
|
|
1532
|
+
type=0, # buy
|
|
1533
|
+
magic=0,
|
|
1534
|
+
identifier=123456,
|
|
1535
|
+
reason=0,
|
|
1536
|
+
volume=0.1,
|
|
1537
|
+
price_open=1.1000,
|
|
1538
|
+
sl=1.0900,
|
|
1539
|
+
tp=1.1100,
|
|
1540
|
+
price_current=1.1050,
|
|
1541
|
+
swap=0.0,
|
|
1542
|
+
profit=50.0,
|
|
1543
|
+
symbol="EURUSD",
|
|
1544
|
+
comment="test",
|
|
1545
|
+
external_id="",
|
|
1546
|
+
)
|
|
1547
|
+
mock_mt5_import.positions_get.return_value = [mock_position]
|
|
1548
|
+
|
|
1549
|
+
# Mock successful order send
|
|
1550
|
+
mock_mt5_import.order_send.return_value.retcode = 10009
|
|
1551
|
+
mock_mt5_import.order_send.return_value._asdict.return_value = {
|
|
1552
|
+
"retcode": 10009,
|
|
1553
|
+
"deal": 0,
|
|
1554
|
+
"order": 789012,
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
result = client.update_sltp_for_open_positions(
|
|
1558
|
+
symbol="EURUSD",
|
|
1559
|
+
tickets=[123456],
|
|
1560
|
+
stop_loss=1.0950,
|
|
1561
|
+
take_profit=1.1050,
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# Now returns a list of dictionaries
|
|
1565
|
+
assert isinstance(result, list)
|
|
1566
|
+
assert len(result) == 1
|
|
1567
|
+
assert result[0]["retcode"] == 10009
|
|
1568
|
+
assert result[0]["order"] == 789012
|
|
1569
|
+
|
|
1570
|
+
def test_update_sltp_for_open_positions_no_positions(
|
|
1571
|
+
self, mock_mt5_import: ModuleType
|
|
1572
|
+
) -> None:
|
|
1573
|
+
"""Test update_sltp_for_open_positions when no positions exist for symbol."""
|
|
1574
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1575
|
+
mock_mt5_import.initialize.return_value = True
|
|
1576
|
+
client.initialize()
|
|
1577
|
+
|
|
1578
|
+
# Mock empty positions result
|
|
1579
|
+
mock_mt5_import.positions_get.return_value = []
|
|
1580
|
+
|
|
1581
|
+
result = client.update_sltp_for_open_positions(
|
|
1582
|
+
symbol="EURUSD",
|
|
1583
|
+
tickets=[123456],
|
|
1584
|
+
stop_loss=1.0950,
|
|
1585
|
+
take_profit=1.1050,
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
# Should return empty list and log warning
|
|
1589
|
+
assert result == []
|
|
1590
|
+
# Verify positions_get was called with correct symbol
|
|
1591
|
+
mock_mt5_import.positions_get.assert_called_with(symbol="EURUSD")
|
|
1592
|
+
|
|
1593
|
+
def test_update_sltp_for_open_positions_no_matching_tickets(
|
|
1594
|
+
self, mock_mt5_import: ModuleType
|
|
1595
|
+
) -> None:
|
|
1596
|
+
"""Test update_sltp_for_open_positions when positions exist but no tickets match.""" # noqa: E501
|
|
1597
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1598
|
+
mock_mt5_import.initialize.return_value = True
|
|
1599
|
+
client.initialize()
|
|
1600
|
+
|
|
1601
|
+
# Mock MT5 constants
|
|
1602
|
+
mock_mt5_import.TRADE_ACTION_SLTP = 6
|
|
1603
|
+
|
|
1604
|
+
# Mock symbol info
|
|
1605
|
+
mock_mt5_import.symbol_info.return_value._asdict.return_value = {
|
|
1606
|
+
"digits": 5,
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
# Mock positions with different tickets
|
|
1610
|
+
mock_position = MockPositionInfo(
|
|
1611
|
+
ticket=999999, # Different ticket
|
|
1612
|
+
time=123456789,
|
|
1613
|
+
type=0, # buy
|
|
1614
|
+
magic=0,
|
|
1615
|
+
identifier=999999,
|
|
1616
|
+
reason=0,
|
|
1617
|
+
volume=0.1,
|
|
1618
|
+
price_open=1.1000,
|
|
1619
|
+
sl=1.0900,
|
|
1620
|
+
tp=1.1100,
|
|
1621
|
+
price_current=1.1050,
|
|
1622
|
+
swap=0.0,
|
|
1623
|
+
profit=50.0,
|
|
1624
|
+
symbol="EURUSD",
|
|
1625
|
+
comment="test",
|
|
1626
|
+
external_id="",
|
|
1627
|
+
)
|
|
1628
|
+
mock_mt5_import.positions_get.return_value = [mock_position]
|
|
1629
|
+
|
|
1630
|
+
result = client.update_sltp_for_open_positions(
|
|
1631
|
+
symbol="EURUSD",
|
|
1632
|
+
tickets=[123456], # This ticket doesn't exist
|
|
1633
|
+
stop_loss=1.0950,
|
|
1634
|
+
take_profit=1.1050,
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
# Should return empty list and log warning
|
|
1638
|
+
assert result == []
|
|
1639
|
+
|
|
1640
|
+
def test_update_sltp_for_open_positions_same_sltp_values(
|
|
1641
|
+
self, mock_mt5_import: ModuleType
|
|
1642
|
+
) -> None:
|
|
1643
|
+
"""Test update_sltp_for_open_positions when SL/TP values are already the same.""" # noqa: E501
|
|
1644
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1645
|
+
mock_mt5_import.initialize.return_value = True
|
|
1646
|
+
client.initialize()
|
|
1647
|
+
|
|
1648
|
+
# Mock MT5 constants
|
|
1649
|
+
mock_mt5_import.TRADE_ACTION_SLTP = 6
|
|
1650
|
+
|
|
1651
|
+
# Mock symbol info
|
|
1652
|
+
mock_mt5_import.symbol_info.return_value._asdict.return_value = {
|
|
1653
|
+
"digits": 5,
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
# Mock positions with same SL/TP as requested
|
|
1657
|
+
mock_position = MockPositionInfo(
|
|
1658
|
+
ticket=123456,
|
|
1659
|
+
time=123456789,
|
|
1660
|
+
type=0, # buy
|
|
1661
|
+
magic=0,
|
|
1662
|
+
identifier=123456,
|
|
1663
|
+
reason=0,
|
|
1664
|
+
volume=0.1,
|
|
1665
|
+
price_open=1.1000,
|
|
1666
|
+
sl=1.0950, # Same as requested stop_loss
|
|
1667
|
+
tp=1.1050, # Same as requested take_profit
|
|
1668
|
+
price_current=1.1050,
|
|
1669
|
+
swap=0.0,
|
|
1670
|
+
profit=50.0,
|
|
1671
|
+
symbol="EURUSD",
|
|
1672
|
+
comment="test",
|
|
1673
|
+
external_id="",
|
|
1674
|
+
)
|
|
1675
|
+
mock_mt5_import.positions_get.return_value = [mock_position]
|
|
1676
|
+
|
|
1677
|
+
result = client.update_sltp_for_open_positions(
|
|
1678
|
+
symbol="EURUSD",
|
|
1679
|
+
tickets=[123456],
|
|
1680
|
+
stop_loss=1.0950, # Same as position's sl
|
|
1681
|
+
take_profit=1.1050, # Same as position's tp
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
# Should return empty list since no update is needed
|
|
1685
|
+
assert result == []
|
|
1686
|
+
# Verify order_send was NOT called
|
|
1687
|
+
mock_mt5_import.order_send.assert_not_called()
|
|
1688
|
+
|
|
1689
|
+
def test_update_sltp_for_open_positions_no_tickets(
|
|
1690
|
+
self, mock_mt5_import: ModuleType
|
|
1691
|
+
) -> None:
|
|
1692
|
+
"""Test update_sltp_for_open_positions without specifying tickets."""
|
|
1693
|
+
client = Mt5TradingClient(mt5=mock_mt5_import)
|
|
1694
|
+
mock_mt5_import.initialize.return_value = True
|
|
1695
|
+
client.initialize()
|
|
1696
|
+
|
|
1697
|
+
# Mock MT5 constants
|
|
1698
|
+
mock_mt5_import.TRADE_ACTION_SLTP = 6
|
|
1699
|
+
|
|
1700
|
+
# Mock symbol info
|
|
1701
|
+
mock_mt5_import.symbol_info.return_value._asdict.return_value = {
|
|
1702
|
+
"digits": 5,
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
# Mock positions for the symbol
|
|
1706
|
+
mock_position1 = MockPositionInfo(
|
|
1707
|
+
ticket=123456,
|
|
1708
|
+
time=123456789,
|
|
1709
|
+
type=0, # buy
|
|
1710
|
+
magic=0,
|
|
1711
|
+
identifier=123456,
|
|
1712
|
+
reason=0,
|
|
1713
|
+
volume=0.1,
|
|
1714
|
+
price_open=1.1000,
|
|
1715
|
+
sl=1.0900,
|
|
1716
|
+
tp=1.1100,
|
|
1717
|
+
price_current=1.1050,
|
|
1718
|
+
swap=0.0,
|
|
1719
|
+
profit=50.0,
|
|
1720
|
+
symbol="EURUSD",
|
|
1721
|
+
comment="test",
|
|
1722
|
+
external_id="",
|
|
1723
|
+
)
|
|
1724
|
+
mock_position2 = MockPositionInfo(
|
|
1725
|
+
ticket=654321,
|
|
1726
|
+
time=123456789,
|
|
1727
|
+
type=1, # sell
|
|
1728
|
+
magic=0,
|
|
1729
|
+
identifier=654321,
|
|
1730
|
+
reason=0,
|
|
1731
|
+
volume=0.2,
|
|
1732
|
+
price_open=1.1050,
|
|
1733
|
+
sl=1.1150,
|
|
1734
|
+
tp=1.0950,
|
|
1735
|
+
price_current=1.1050,
|
|
1736
|
+
swap=0.0,
|
|
1737
|
+
profit=-20.0,
|
|
1738
|
+
symbol="EURUSD",
|
|
1739
|
+
comment="test2",
|
|
1740
|
+
external_id="",
|
|
1741
|
+
)
|
|
1742
|
+
mock_mt5_import.positions_get.return_value = [mock_position1, mock_position2]
|
|
1743
|
+
|
|
1744
|
+
# Mock successful order send
|
|
1745
|
+
mock_mt5_import.order_send.return_value.retcode = 10009
|
|
1746
|
+
mock_mt5_import.order_send.return_value._asdict.return_value = {
|
|
1747
|
+
"retcode": 10009,
|
|
1748
|
+
"deal": 0,
|
|
1749
|
+
"order": 789012,
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
# Call without tickets to update all positions
|
|
1753
|
+
result = client.update_sltp_for_open_positions(
|
|
1754
|
+
symbol="EURUSD",
|
|
1755
|
+
tickets=None, # No tickets specified
|
|
1756
|
+
stop_loss=1.0950,
|
|
1757
|
+
take_profit=1.1050,
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
# Should return results for both positions
|
|
1761
|
+
assert isinstance(result, list)
|
|
1762
|
+
assert len(result) == 2
|
|
1763
|
+
assert all(r["retcode"] == 10009 for r in result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|