pdmt5 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

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/trading.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import timedelta
6
+ from math import floor
6
7
  from typing import TYPE_CHECKING, Any, Literal
7
8
 
8
9
  from pydantic import ConfigDict, Field
@@ -31,12 +32,11 @@ class Mt5TradingClient(Mt5DataClient):
31
32
  description="Order filling mode: 'IOC' (Immediate or Cancel), "
32
33
  "'FOK' (Fill or Kill), 'RETURN' (Return if not filled)",
33
34
  )
34
- dry_run: bool = Field(default=False, description="Enable dry run mode for testing.")
35
35
 
36
36
  def close_open_positions(
37
37
  self,
38
38
  symbols: str | list[str] | tuple[str, ...] | None = None,
39
- dry_run: bool | None = None,
39
+ dry_run: bool = False,
40
40
  **kwargs: Any, # noqa: ANN401
41
41
  ) -> dict[str, list[dict[str, Any]]]:
42
42
  """Close all open positions for specified symbols.
@@ -44,8 +44,7 @@ class Mt5TradingClient(Mt5DataClient):
44
44
  Args:
45
45
  symbols: Optional symbol or list of symbols to filter positions.
46
46
  If None, all symbols will be considered.
47
- dry_run: Optional flag to enable dry run mode. If None, uses the instance's
48
- `dry_run` attribute.
47
+ dry_run: If True, only check the order without sending it.
49
48
  **kwargs: Additional keyword arguments for request parameters.
50
49
 
51
50
  Returns:
@@ -67,15 +66,14 @@ class Mt5TradingClient(Mt5DataClient):
67
66
  def _fetch_and_close_position(
68
67
  self,
69
68
  symbol: str | None = None,
70
- dry_run: bool | None = None,
69
+ dry_run: bool = False,
71
70
  **kwargs: Any, # noqa: ANN401
72
71
  ) -> list[dict[str, Any]]:
73
72
  """Close all open positions for a specific symbol.
74
73
 
75
74
  Args:
76
75
  symbol: Optional symbol filter.
77
- dry_run: Optional flag to enable dry run mode. If None, uses the instance's
78
- `dry_run` attribute.
76
+ dry_run: If True, only check the order without sending it.
79
77
  **kwargs: Additional keyword arguments for request parameters.
80
78
 
81
79
  Returns:
@@ -92,7 +90,7 @@ class Mt5TradingClient(Mt5DataClient):
92
90
  f"ORDER_FILLING_{self.order_filling_mode}",
93
91
  )
94
92
  return [
95
- self.send_or_check_order(
93
+ self._send_or_check_order(
96
94
  request={
97
95
  "action": self.mt5.TRADE_ACTION_DEAL,
98
96
  "symbol": p["symbol"],
@@ -112,17 +110,16 @@ class Mt5TradingClient(Mt5DataClient):
112
110
  for p in positions_dict
113
111
  ]
114
112
 
115
- def send_or_check_order(
113
+ def _send_or_check_order(
116
114
  self,
117
115
  request: dict[str, Any],
118
- dry_run: bool | None = None,
116
+ dry_run: bool = False,
119
117
  ) -> dict[str, Any]:
120
118
  """Send or check an order request.
121
119
 
122
120
  Args:
123
121
  request: Order request dictionary.
124
- dry_run: Optional flag to enable dry run mode. If None, uses the instance's
125
- `dry_run` attribute.
122
+ dry_run: If True, only check the order without sending it.
126
123
 
127
124
  Returns:
128
125
  Dictionary with operation result.
@@ -131,17 +128,15 @@ class Mt5TradingClient(Mt5DataClient):
131
128
  Mt5TradingError: If the order operation fails.
132
129
  """
133
130
  self.logger.debug("request: %s", request)
134
- is_dry_run = dry_run if dry_run is not None else self.dry_run
135
- self.logger.debug("is_dry_run: %s", is_dry_run)
136
- if is_dry_run:
131
+ if dry_run:
137
132
  response = self.order_check_as_dict(request=request)
138
133
  order_func = "order_check"
139
134
  else:
140
135
  response = self.order_send_as_dict(request=request)
141
136
  order_func = "order_send"
142
137
  retcode = response.get("retcode")
143
- if ((not is_dry_run) and retcode == self.mt5.TRADE_RETCODE_DONE) or (
144
- is_dry_run and retcode == 0
138
+ if ((not dry_run) and retcode == self.mt5.TRADE_RETCODE_DONE) or (
139
+ dry_run and retcode == 0
145
140
  ):
146
141
  self.logger.info("response: %s", response)
147
142
  return response
@@ -159,41 +154,170 @@ class Mt5TradingClient(Mt5DataClient):
159
154
  error_message = f"{order_func}() failed and aborted. <= `{comment}`"
160
155
  raise Mt5TradingError(error_message)
161
156
 
162
- def calculate_minimum_order_margins(self, symbol: str) -> dict[str, float]:
163
- """Calculate minimum order margins for a given symbol.
157
+ def place_market_order(
158
+ self,
159
+ symbol: str,
160
+ volume: float,
161
+ order_side: Literal["BUY", "SELL"],
162
+ order_filling_mode: Literal["IOC", "FOK", "RETURN"] = "IOC",
163
+ order_time_mode: Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] = "GTC",
164
+ dry_run: bool = False,
165
+ **kwargs: Any, # noqa: ANN401
166
+ ) -> dict[str, Any]:
167
+ """Send or check an order request to place a market order.
164
168
 
165
169
  Args:
166
- symbol: Symbol for which to calculate minimum order margins.
170
+ symbol: Symbol for the order.
171
+ volume: Volume of the order.
172
+ order_side: Side of the order, either "BUY" or "SELL".
173
+ order_filling_mode: Order filling mode, either "IOC", "FOK", or "RETURN".
174
+ order_time_mode: Order time mode, either "GTC", "DAY", "SPECIFIED",
175
+ or "SPECIFIED_DAY".
176
+ dry_run: If True, only check the order without sending it.
177
+ **kwargs: Additional keyword arguments for request parameters.
167
178
 
168
179
  Returns:
169
- Dictionary with margin information.
180
+ Dictionary with operation result.
181
+ """
182
+ return self._send_or_check_order(
183
+ request={
184
+ "action": self.mt5.TRADE_ACTION_DEAL,
185
+ "symbol": symbol,
186
+ "volume": volume,
187
+ "type": getattr(self.mt5, f"ORDER_TYPE_{order_side.upper()}"),
188
+ "type_filling": getattr(
189
+ self.mt5, f"ORDER_FILLING_{order_filling_mode.upper()}"
190
+ ),
191
+ "type_time": getattr(self.mt5, f"ORDER_TIME_{order_time_mode.upper()}"),
192
+ **kwargs,
193
+ },
194
+ dry_run=dry_run,
195
+ )
170
196
 
171
- Raises:
172
- Mt5TradingError: If margin calculation fails.
197
+ def update_sltp_for_open_positions(
198
+ self,
199
+ symbol: str,
200
+ stop_loss: float | None = None,
201
+ take_profit: float | None = None,
202
+ tickets: list[int] | None = None,
203
+ dry_run: bool = False,
204
+ **kwargs: Any, # noqa: ANN401
205
+ ) -> list[dict[str, Any]]:
206
+ """Change Stop Loss and Take Profit for open positions.
207
+
208
+ Args:
209
+ symbol: Symbol for the position.
210
+ stop_loss: New Stop Loss price. If None, it will not be changed.
211
+ take_profit: New Take Profit price. If None, it will not be changed.
212
+ tickets: List of position tickets to filter positions. If None, all open
213
+ positions for the symbol will be considered.
214
+ dry_run: If True, only check the order without sending it.
215
+ **kwargs: Additional keyword arguments for request parameters.
216
+
217
+ Returns:
218
+ List of dictionaries with operation results for each updated position.
219
+ """
220
+ positions_df = self.positions_get_as_df(symbol=symbol)
221
+ if positions_df.empty:
222
+ self.logger.warning("No open positions found for symbol: %s", symbol)
223
+ return []
224
+ elif tickets:
225
+ filtered_positions_df = positions_df.pipe(
226
+ lambda d: d[d["ticket"].isin(tickets)]
227
+ )
228
+ else:
229
+ filtered_positions_df = positions_df
230
+ if filtered_positions_df.empty:
231
+ self.logger.warning(
232
+ "No open positions found for symbol: %s with specified tickets: %s",
233
+ symbol,
234
+ tickets,
235
+ )
236
+ return []
237
+ else:
238
+ symbol_info = self.symbol_info_as_dict(symbol=symbol)
239
+ sl = round(stop_loss, symbol_info["digits"]) if stop_loss else None
240
+ tp = round(take_profit, symbol_info["digits"]) if take_profit else None
241
+ order_requests = [
242
+ {
243
+ "action": self.mt5.TRADE_ACTION_SLTP,
244
+ "symbol": p["symbol"],
245
+ "position": p["ticket"],
246
+ "sl": (sl or p["sl"]),
247
+ "tp": (tp or p["tp"]),
248
+ **kwargs,
249
+ }
250
+ for _, p in filtered_positions_df.iterrows()
251
+ if sl != p["sl"] or tp != p["tp"]
252
+ ]
253
+ if order_requests:
254
+ return [
255
+ self._send_or_check_order(request=r, dry_run=dry_run)
256
+ for r in order_requests
257
+ ]
258
+ else:
259
+ self.logger.info(
260
+ "No positions to update for symbol: %s with SL: %s and TP: %s",
261
+ symbol,
262
+ sl,
263
+ tp,
264
+ )
265
+ return []
266
+
267
+ def calculate_minimum_order_margin(
268
+ self,
269
+ symbol: str,
270
+ order_side: Literal["BUY", "SELL"],
271
+ ) -> dict[str, float]:
272
+ """Calculate the minimum order margins for a given symbol.
273
+
274
+ Args:
275
+ symbol: Symbol for which to calculate the minimum order margins.
276
+ order_side: Optional side of the order, either "BUY" or "SELL".
277
+
278
+ Returns:
279
+ Dictionary with minimum volume and margin for the specified order side.
173
280
  """
174
281
  symbol_info = self.symbol_info_as_dict(symbol=symbol)
175
282
  symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
176
- min_ask_order_margin = self.order_calc_margin(
177
- action=self.mt5.ORDER_TYPE_BUY,
283
+ return {
284
+ "volume": symbol_info["volume_min"],
285
+ "margin": self.order_calc_margin(
286
+ action=getattr(self.mt5, f"ORDER_TYPE_{order_side.upper()}"),
287
+ symbol=symbol,
288
+ volume=symbol_info["volume_min"],
289
+ price=(
290
+ symbol_info_tick["bid"]
291
+ if order_side == "SELL"
292
+ else symbol_info_tick["ask"]
293
+ ),
294
+ ),
295
+ }
296
+
297
+ def calculate_volume_by_margin(
298
+ self,
299
+ symbol: str,
300
+ margin: float,
301
+ order_side: Literal["BUY", "SELL"],
302
+ ) -> float:
303
+ """Calculate volume based on margin for a given symbol and order side.
304
+
305
+ Args:
306
+ symbol: Symbol for which to calculate the volume.
307
+ margin: Margin amount to use for the calculation.
308
+ order_side: Side of the order, either "BUY" or "SELL".
309
+
310
+ Returns:
311
+ Calculated volume as a float.
312
+ """
313
+ min_order_margin_dict = self.calculate_minimum_order_margin(
178
314
  symbol=symbol,
179
- volume=symbol_info["volume_min"],
180
- price=symbol_info_tick["ask"],
315
+ order_side=order_side,
181
316
  )
182
- min_bid_order_margin = self.order_calc_margin(
183
- action=self.mt5.ORDER_TYPE_SELL,
184
- symbol=symbol,
185
- volume=symbol_info["volume_min"],
186
- price=symbol_info_tick["bid"],
317
+ return (
318
+ floor(margin / min_order_margin_dict["margin"])
319
+ * min_order_margin_dict["volume"]
187
320
  )
188
- min_order_margins = {"ask": min_ask_order_margin, "bid": min_bid_order_margin}
189
- self.logger.info("Minimum order margins for %s: %s", symbol, min_order_margins)
190
- if all(min_order_margins.values()):
191
- return min_order_margins
192
- else:
193
- error_message = (
194
- f"Failed to calculate minimum order margins for symbol: {symbol}."
195
- )
196
- raise Mt5TradingError(error_message)
197
321
 
198
322
  def calculate_spread_ratio(
199
323
  self,
@@ -369,3 +493,50 @@ class Mt5TradingClient(Mt5DataClient):
369
493
  )
370
494
  .drop(columns=["buy_i", "sell_i", "sign", "underlier_increase_ratio"])
371
495
  )
496
+
497
+ def calculate_new_position_margin_ratio(
498
+ self,
499
+ symbol: str,
500
+ new_position_side: Literal["BUY", "SELL"] | None = None,
501
+ new_position_volume: float = 0,
502
+ ) -> float:
503
+ """Calculate the margin ratio for a new position.
504
+
505
+ Args:
506
+ symbol: Symbol for which to calculate the margin ratio.
507
+ new_position_side: Side of the new position, either "BUY" or "SELL".
508
+ new_position_volume: Volume of the new position.
509
+
510
+ Returns:
511
+ float: Margin ratio for the new position as a fraction of account equity.
512
+ """
513
+ account_info = self.account_info_as_dict()
514
+ if not account_info["equity"]:
515
+ return 0.0
516
+ else:
517
+ positions_df = self.fetch_positions_with_metrics_as_df(symbol=symbol)
518
+ current_signed_margin = (
519
+ positions_df["signed_margin"].sum() if positions_df.size else 0
520
+ )
521
+ symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
522
+ if not (new_position_side and new_position_volume):
523
+ new_signed_margin = 0
524
+ elif new_position_side.upper() == "BUY":
525
+ new_signed_margin = self.order_calc_margin(
526
+ action=self.mt5.ORDER_TYPE_BUY,
527
+ symbol=symbol,
528
+ volume=new_position_volume,
529
+ price=symbol_info_tick["ask"],
530
+ )
531
+ elif new_position_side.upper() == "SELL":
532
+ new_signed_margin = -self.order_calc_margin(
533
+ action=self.mt5.ORDER_TYPE_SELL,
534
+ symbol=symbol,
535
+ volume=new_position_volume,
536
+ price=symbol_info_tick["bid"],
537
+ )
538
+ else:
539
+ new_signed_margin = 0
540
+ return abs(
541
+ (new_signed_margin + current_signed_margin) / account_info["equity"]
542
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdmt5
3
- Version: 0.1.4
3
+ Version: 0.1.6
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
  [![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
30
30
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
31
  [![Platform](https://img.shields.io/badge/platform-Windows-blue.svg)](https://www.microsoft.com/windows)
32
- [![Version](https://img.shields.io/badge/version-0.1.4-green.svg)](https://github.com/dceoy/pdmt5)
33
32
 
34
33
  ## Overview
35
34
 
@@ -43,6 +42,8 @@ Pandas-based data handler for MetaTrader 5
43
42
  - 🚀 **Context Manager Support**: Clean initialization and cleanup with `with` statements
44
43
  - 📈 **Time Series Ready**: OHLCV data with proper datetime indexing
45
44
  - 🛡️ **Robust Error Handling**: Custom exceptions with detailed MT5 error information
45
+ - 💰 **Advanced Trading Operations**: Position management, margin calculations, and risk analysis tools
46
+ - 🧪 **Dry Run Mode**: Test trading strategies without executing real trades
46
47
 
47
48
  ## Requirements
48
49
 
@@ -195,10 +196,13 @@ Advanced trading operations client that extends Mt5DataClient:
195
196
  - `dry_run` - Test mode flag for simulating trades without execution
196
197
  - **Position Management**:
197
198
  - `close_open_positions()` - Close all positions for specified symbol(s)
198
- - `send_or_check_order()` - Execute or validate orders based on dry_run mode
199
- - **Market Analysis**:
200
- - `calculate_minimum_order_margins()` - Calculate minimum required margins for buy/sell orders
199
+ - `place_market_order()` - Place market orders with configurable side, volume, and execution modes
200
+ - `update_sltp_for_open_positions()` - Modify stop loss and take profit levels for open positions
201
+ - **Margin Calculations**:
202
+ - `calculate_minimum_order_margin()` - Calculate minimum required margin for a specific order side
203
+ - `calculate_volume_by_margin()` - Calculate maximum volume for given margin amount
201
204
  - `calculate_spread_ratio()` - Calculate normalized bid-ask spread ratio
205
+ - `calculate_new_position_margin_ratio()` - Calculate margin ratio for potential new positions
202
206
  - **Simplified Data Access**:
203
207
  - `fetch_latest_rates_as_df()` - Get recent OHLC data with timeframe strings (e.g., "M1", "H1", "D1")
204
208
  - `fetch_latest_ticks_as_df()` - Get tick data for specified seconds around last tick
@@ -288,18 +292,52 @@ from pdmt5 import Mt5TradingClient
288
292
 
289
293
  # Create trading client with specific order filling mode
290
294
  with Mt5TradingClient(config=config, order_filling_mode="IOC") as trader:
295
+ # Place a market buy order
296
+ order_result = trader.place_market_order(
297
+ symbol="EURUSD",
298
+ volume=0.1,
299
+ order_side="BUY",
300
+ order_filling_mode="IOC", # Immediate or Cancel
301
+ order_time_mode="GTC" # Good Till Cancelled
302
+ )
303
+ print(f"Order placed: {order_result['retcode']}")
304
+
305
+ # Update stop loss and take profit for open positions
306
+ update_results = trader.update_sltp_for_open_positions(
307
+ symbol="EURUSD",
308
+ stop_loss=1.0950, # New stop loss
309
+ take_profit=1.1050 # New take profit
310
+ )
311
+ for result in update_results:
312
+ print(f"Position updated: {result['retcode']}")
313
+
314
+ # Calculate margin ratio for a new position
315
+ margin_ratio = trader.calculate_new_position_margin_ratio(
316
+ symbol="EURUSD",
317
+ new_position_side="SELL",
318
+ new_position_volume=0.2
319
+ )
320
+ print(f"New position margin ratio: {margin_ratio:.2%}")
321
+
291
322
  # Close all EURUSD positions
292
323
  results = trader.close_open_positions(symbols="EURUSD")
293
324
 
294
325
  if results:
295
- for result in results:
296
- print(f"Closed position {result['position']} with result: {result['retcode']}")
326
+ for symbol, close_results in results.items():
327
+ for result in close_results:
328
+ print(f"Closed position {result.get('position')} with result: {result['retcode']}")
297
329
 
298
330
  # Using dry run mode for testing
299
331
  trader_dry = Mt5TradingClient(config=config, dry_run=True)
300
332
  with trader_dry:
301
- # Test closing positions without actual execution
302
- test_results = trader_dry.close_open_positions(symbols=["EURUSD", "GBPUSD"])
333
+ # Test placing an order without actual execution
334
+ test_order = trader_dry.place_market_order(
335
+ symbol="GBPUSD",
336
+ volume=0.1,
337
+ order_side="SELL",
338
+ dry_run=True # Override instance setting
339
+ )
340
+ print(f"Test order validation: {test_order['retcode']}")
303
341
  ```
304
342
 
305
343
  ### Market Analysis with Mt5TradingClient
@@ -310,10 +348,18 @@ with Mt5TradingClient(config=config) as trader:
310
348
  spread_ratio = trader.calculate_spread_ratio("EURUSD")
311
349
  print(f"EURUSD spread ratio: {spread_ratio:.5f}")
312
350
 
313
- # Get minimum order margins
314
- margins = trader.calculate_minimum_order_margins("EURUSD")
315
- print(f"Minimum ask margin: {margins['ask']}")
316
- print(f"Minimum bid margin: {margins['bid']}")
351
+ # Get minimum order margin for BUY and SELL
352
+ buy_margin = trader.calculate_minimum_order_margin("EURUSD", "BUY")
353
+ sell_margin = trader.calculate_minimum_order_margin("EURUSD", "SELL")
354
+ print(f"Minimum BUY margin: {buy_margin['margin']} (volume: {buy_margin['volume']})")
355
+ print(f"Minimum SELL margin: {sell_margin['margin']} (volume: {sell_margin['volume']})")
356
+
357
+ # Calculate volume by margin
358
+ available_margin = 1000.0
359
+ max_buy_volume = trader.calculate_volume_by_margin("EURUSD", available_margin, "BUY")
360
+ max_sell_volume = trader.calculate_volume_by_margin("EURUSD", available_margin, "SELL")
361
+ print(f"Max BUY volume for ${available_margin}: {max_buy_volume}")
362
+ print(f"Max SELL volume for ${available_margin}: {max_sell_volume}")
317
363
 
318
364
  # Get recent OHLC data with custom timeframe
319
365
  rates_df = trader.fetch_latest_rates_as_df(
@@ -1,9 +1,9 @@
1
1
  pdmt5/__init__.py,sha256=QbSFrsi7_bgFzb-ma4DmmUjR90UvrqKMnRZq1wPRmoI,446
2
2
  pdmt5/dataframe.py,sha256=rUWtR23hrXBdBqzJhbOlIemNy73RrjSTZZJUhwoL6io,38084
3
3
  pdmt5/mt5.py,sha256=KgxHapIrh5b4L0wIOAQIjfXNZafalihbFrh9fhYHmrI,32254
4
- pdmt5/trading.py,sha256=qZFORyBlSQNp_DQPeuEIqFHPAnVrCJK_6GhFRc5vDR8,13683
4
+ pdmt5/trading.py,sha256=GOOYsbsjKtp26dw1VB6siZ1luBttD8cv-KNZi6pPUEs,20107
5
5
  pdmt5/utils.py,sha256=Ll5Q3OE5h1A_sZ_qVEnOPGniFlT6_MmHfuu0zqeLdeU,3913
6
- pdmt5-0.1.4.dist-info/METADATA,sha256=XFIW5O6a7bePMxnDVc7MG6mnkXwNTY_53_mVb_BJxnY,14678
7
- pdmt5-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- pdmt5-0.1.4.dist-info/licenses/LICENSE,sha256=iABrdaUGOBWLYotFupB_PGe8arV5o7rVhn-_vK6P704,1073
9
- pdmt5-0.1.4.dist-info/RECORD,,
6
+ pdmt5-0.1.6.dist-info/METADATA,sha256=cBYUp4lIyQuw7aRN7dphpgtejgW5jamAvYImYQiQS9o,16824
7
+ pdmt5-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ pdmt5-0.1.6.dist-info/licenses/LICENSE,sha256=iABrdaUGOBWLYotFupB_PGe8arV5o7rVhn-_vK6P704,1073
9
+ pdmt5-0.1.6.dist-info/RECORD,,
File without changes