pdmt5 0.1.5__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {pdmt5-0.1.5 → pdmt5-0.1.6}/.github/workflows/ci.yml +1 -1
  2. {pdmt5-0.1.5 → pdmt5-0.1.6}/PKG-INFO +29 -20
  3. {pdmt5-0.1.5 → pdmt5-0.1.6}/README.md +28 -19
  4. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/api/trading.md +187 -9
  5. {pdmt5-0.1.5 → pdmt5-0.1.6}/pdmt5/trading.py +54 -34
  6. {pdmt5-0.1.5 → pdmt5-0.1.6}/pyproject.toml +1 -1
  7. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/test_trading.py +81 -45
  8. {pdmt5-0.1.5 → pdmt5-0.1.6}/uv.lock +1 -1
  9. {pdmt5-0.1.5 → pdmt5-0.1.6}/.claude/settings.json +0 -0
  10. {pdmt5-0.1.5 → pdmt5-0.1.6}/.github/FUNDING.yml +0 -0
  11. {pdmt5-0.1.5 → pdmt5-0.1.6}/.github/copilot-instructions.md +0 -0
  12. {pdmt5-0.1.5 → pdmt5-0.1.6}/.github/dependabot.yml +0 -0
  13. {pdmt5-0.1.5 → pdmt5-0.1.6}/.gitignore +0 -0
  14. {pdmt5-0.1.5 → pdmt5-0.1.6}/CLAUDE.md +0 -0
  15. {pdmt5-0.1.5 → pdmt5-0.1.6}/LICENSE +0 -0
  16. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/api/dataframe.md +0 -0
  17. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/api/index.md +0 -0
  18. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/api/mt5.md +0 -0
  19. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/api/utils.md +0 -0
  20. {pdmt5-0.1.5 → pdmt5-0.1.6}/docs/index.md +0 -0
  21. {pdmt5-0.1.5 → pdmt5-0.1.6}/mkdocs.yml +0 -0
  22. {pdmt5-0.1.5 → pdmt5-0.1.6}/pdmt5/__init__.py +0 -0
  23. {pdmt5-0.1.5 → pdmt5-0.1.6}/pdmt5/dataframe.py +0 -0
  24. {pdmt5-0.1.5 → pdmt5-0.1.6}/pdmt5/mt5.py +0 -0
  25. {pdmt5-0.1.5 → pdmt5-0.1.6}/pdmt5/utils.py +0 -0
  26. {pdmt5-0.1.5 → pdmt5-0.1.6}/renovate.json +0 -0
  27. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/__init__.py +0 -0
  28. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/test_dataframe.py +0 -0
  29. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/test_init.py +0 -0
  30. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/test_mt5.py +0 -0
  31. {pdmt5-0.1.5 → pdmt5-0.1.6}/test/test_utils.py +0 -0
@@ -42,7 +42,7 @@ jobs:
42
42
  - name: Checkout repository
43
43
  uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
44
44
  - name: Set up uv
45
- uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6.4.1
45
+ uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
46
46
  - name: Install the package
47
47
  run: >
48
48
  uv sync
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdmt5
3
- Version: 0.1.5
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>
@@ -42,6 +42,8 @@ Pandas-based data handler for MetaTrader 5
42
42
  - 🚀 **Context Manager Support**: Clean initialization and cleanup with `with` statements
43
43
  - 📈 **Time Series Ready**: OHLCV data with proper datetime indexing
44
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
45
47
 
46
48
  ## Requirements
47
49
 
@@ -195,9 +197,10 @@ Advanced trading operations client that extends Mt5DataClient:
195
197
  - **Position Management**:
196
198
  - `close_open_positions()` - Close all positions for specified symbol(s)
197
199
  - `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
- - **Market Analysis**:
200
- - `calculate_minimum_order_margins()` - Calculate minimum required margins for buy/sell orders
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
202
205
  - `calculate_new_position_margin_ratio()` - Calculate margin ratio for potential new positions
203
206
  - **Simplified Data Access**:
@@ -299,22 +302,20 @@ with Mt5TradingClient(config=config, order_filling_mode="IOC") as trader:
299
302
  )
300
303
  print(f"Order placed: {order_result['retcode']}")
301
304
 
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']}")
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']}")
312
313
 
313
314
  # Calculate margin ratio for a new position
314
315
  margin_ratio = trader.calculate_new_position_margin_ratio(
315
316
  symbol="EURUSD",
316
- new_side="SELL",
317
- new_volume=0.2
317
+ new_position_side="SELL",
318
+ new_position_volume=0.2
318
319
  )
319
320
  print(f"New position margin ratio: {margin_ratio:.2%}")
320
321
 
@@ -347,10 +348,18 @@ with Mt5TradingClient(config=config) as trader:
347
348
  spread_ratio = trader.calculate_spread_ratio("EURUSD")
348
349
  print(f"EURUSD spread ratio: {spread_ratio:.5f}")
349
350
 
350
- # Get minimum order margins
351
- margins = trader.calculate_minimum_order_margins("EURUSD")
352
- print(f"Minimum ask margin: {margins['ask']}")
353
- 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}")
354
363
 
355
364
  # Get recent OHLC data with custom timeframe
356
365
  rates_df = trader.fetch_latest_rates_as_df(
@@ -19,6 +19,8 @@ Pandas-based data handler for MetaTrader 5
19
19
  - 🚀 **Context Manager Support**: Clean initialization and cleanup with `with` statements
20
20
  - 📈 **Time Series Ready**: OHLCV data with proper datetime indexing
21
21
  - 🛡️ **Robust Error Handling**: Custom exceptions with detailed MT5 error information
22
+ - 💰 **Advanced Trading Operations**: Position management, margin calculations, and risk analysis tools
23
+ - 🧪 **Dry Run Mode**: Test trading strategies without executing real trades
22
24
 
23
25
  ## Requirements
24
26
 
@@ -172,9 +174,10 @@ Advanced trading operations client that extends Mt5DataClient:
172
174
  - **Position Management**:
173
175
  - `close_open_positions()` - Close all positions for specified symbol(s)
174
176
  - `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
- - **Market Analysis**:
177
- - `calculate_minimum_order_margins()` - Calculate minimum required margins for buy/sell orders
177
+ - `update_sltp_for_open_positions()` - Modify stop loss and take profit levels for open positions
178
+ - **Margin Calculations**:
179
+ - `calculate_minimum_order_margin()` - Calculate minimum required margin for a specific order side
180
+ - `calculate_volume_by_margin()` - Calculate maximum volume for given margin amount
178
181
  - `calculate_spread_ratio()` - Calculate normalized bid-ask spread ratio
179
182
  - `calculate_new_position_margin_ratio()` - Calculate margin ratio for potential new positions
180
183
  - **Simplified Data Access**:
@@ -276,22 +279,20 @@ with Mt5TradingClient(config=config, order_filling_mode="IOC") as trader:
276
279
  )
277
280
  print(f"Order placed: {order_result['retcode']}")
278
281
 
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']}")
282
+ # Update stop loss and take profit for open positions
283
+ update_results = trader.update_sltp_for_open_positions(
284
+ symbol="EURUSD",
285
+ stop_loss=1.0950, # New stop loss
286
+ take_profit=1.1050 # New take profit
287
+ )
288
+ for result in update_results:
289
+ print(f"Position updated: {result['retcode']}")
289
290
 
290
291
  # Calculate margin ratio for a new position
291
292
  margin_ratio = trader.calculate_new_position_margin_ratio(
292
293
  symbol="EURUSD",
293
- new_side="SELL",
294
- new_volume=0.2
294
+ new_position_side="SELL",
295
+ new_position_volume=0.2
295
296
  )
296
297
  print(f"New position margin ratio: {margin_ratio:.2%}")
297
298
 
@@ -324,10 +325,18 @@ with Mt5TradingClient(config=config) as trader:
324
325
  spread_ratio = trader.calculate_spread_ratio("EURUSD")
325
326
  print(f"EURUSD spread ratio: {spread_ratio:.5f}")
326
327
 
327
- # Get minimum order margins
328
- margins = trader.calculate_minimum_order_margins("EURUSD")
329
- print(f"Minimum ask margin: {margins['ask']}")
330
- print(f"Minimum bid margin: {margins['bid']}")
328
+ # Get minimum order margin for BUY and SELL
329
+ buy_margin = trader.calculate_minimum_order_margin("EURUSD", "BUY")
330
+ sell_margin = trader.calculate_minimum_order_margin("EURUSD", "SELL")
331
+ print(f"Minimum BUY margin: {buy_margin['margin']} (volume: {buy_margin['volume']})")
332
+ print(f"Minimum SELL margin: {sell_margin['margin']} (volume: {sell_margin['volume']})")
333
+
334
+ # Calculate volume by margin
335
+ available_margin = 1000.0
336
+ max_buy_volume = trader.calculate_volume_by_margin("EURUSD", available_margin, "BUY")
337
+ max_sell_volume = trader.calculate_volume_by_margin("EURUSD", available_margin, "SELL")
338
+ print(f"Max BUY volume for ${available_margin}: {max_buy_volume}")
339
+ print(f"Max SELL volume for ${available_margin}: {max_sell_volume}")
331
340
 
332
341
  # Get recent OHLC data with custom timeframe
333
342
  rates_df = trader.fetch_latest_rates_as_df(
@@ -47,7 +47,7 @@ with client:
47
47
  # Get current positions as DataFrame
48
48
  positions_df = client.get_positions_as_df()
49
49
  print(f"Open positions: {len(positions_df)}")
50
-
50
+
51
51
  # Close positions for specific symbol
52
52
  results = client.close_open_positions("EURUSD")
53
53
  print(f"Closed positions: {results}")
@@ -62,7 +62,7 @@ client = Mt5TradingClient(config=config, dry_run=False)
62
62
  with client:
63
63
  # Close all positions for multiple symbols
64
64
  results = client.close_open_positions(["EURUSD", "GBPUSD", "USDJPY"])
65
-
65
+
66
66
  # Close all positions (all symbols)
67
67
  all_results = client.close_open_positions()
68
68
  ```
@@ -73,19 +73,19 @@ with client:
73
73
  # Configure different order filling modes
74
74
  # IOC (Immediate or Cancel) - default
75
75
  client_ioc = Mt5TradingClient(
76
- config=config,
76
+ config=config,
77
77
  order_filling_mode="IOC"
78
78
  )
79
79
 
80
80
  # FOK (Fill or Kill)
81
81
  client_fok = Mt5TradingClient(
82
- config=config,
82
+ config=config,
83
83
  order_filling_mode="FOK"
84
84
  )
85
85
 
86
86
  # RETURN (Return if not filled)
87
87
  client_return = Mt5TradingClient(
88
- config=config,
88
+ config=config,
89
89
  order_filling_mode="RETURN"
90
90
  )
91
91
  ```
@@ -121,7 +121,7 @@ except Mt5TradingError as e:
121
121
  with client:
122
122
  # Check order (note: send_or_check_order is an internal method)
123
123
  # For trading operations, use the provided methods like close_open_positions
124
-
124
+
125
125
  # Example: Check if we can close a position
126
126
  positions = client.get_positions_as_df()
127
127
  if not positions.empty:
@@ -196,6 +196,173 @@ The `close_open_positions()` method returns a dictionary with symbols as keys:
196
196
  - `TRADE_RETCODE_NO_MONEY`: Insufficient funds
197
197
  - `TRADE_RETCODE_INVALID_VOLUME`: Invalid trade volume
198
198
 
199
+ ## Margin Calculation Methods
200
+
201
+ The trading client provides advanced margin calculation capabilities:
202
+
203
+ ### Calculate Minimum Order Margin
204
+
205
+ ```python
206
+ with client:
207
+ # Calculate minimum margin required for BUY order
208
+ min_margin_buy = client.calculate_minimum_order_margin("EURUSD", "BUY")
209
+ print(f"Minimum volume: {min_margin_buy['volume']}")
210
+ print(f"Minimum margin: {min_margin_buy['margin']}")
211
+
212
+ # Calculate minimum margin required for SELL order
213
+ min_margin_sell = client.calculate_minimum_order_margin("EURUSD", "SELL")
214
+ ```
215
+
216
+ ### Calculate Volume by Margin
217
+
218
+ ```python
219
+ with client:
220
+ # Calculate maximum volume for given margin amount
221
+ available_margin = 1000.0 # USD
222
+ max_volume_buy = client.calculate_volume_by_margin("EURUSD", available_margin, "BUY")
223
+ max_volume_sell = client.calculate_volume_by_margin("EURUSD", available_margin, "SELL")
224
+
225
+ print(f"Max BUY volume for ${available_margin}: {max_volume_buy}")
226
+ print(f"Max SELL volume for ${available_margin}: {max_volume_sell}")
227
+ ```
228
+
229
+ ### Calculate New Position Margin Ratio
230
+
231
+ ```python
232
+ with client:
233
+ # Calculate margin ratio for potential new position
234
+ margin_ratio = client.calculate_new_position_margin_ratio(
235
+ symbol="EURUSD",
236
+ new_position_side="BUY",
237
+ new_position_volume=1.0
238
+ )
239
+ print(f"New position would use {margin_ratio:.2%} of account equity")
240
+
241
+ # Check if adding position would exceed risk limits
242
+ if margin_ratio > 0.1: # 10% risk limit
243
+ print("Position size too large for risk management")
244
+ ```
245
+
246
+ ## Market Order Placement
247
+
248
+ Place market orders with flexible configuration:
249
+
250
+ ```python
251
+ with client:
252
+ # Place a BUY market order
253
+ result = client.place_market_order(
254
+ symbol="EURUSD",
255
+ volume=1.0,
256
+ order_side="BUY",
257
+ order_filling_mode="IOC", # Immediate or Cancel
258
+ order_time_mode="GTC", # Good Till Cancelled
259
+ dry_run=False, # Set to True for testing
260
+ comment="My buy order"
261
+ )
262
+
263
+ # Place a SELL market order with FOK filling
264
+ result = client.place_market_order(
265
+ symbol="EURUSD",
266
+ volume=0.5,
267
+ order_side="SELL",
268
+ order_filling_mode="FOK", # Fill or Kill
269
+ dry_run=True # Test mode
270
+ )
271
+
272
+ print(f"Order result: {result}")
273
+ ```
274
+
275
+ ## Stop Loss and Take Profit Management
276
+
277
+ Update SL/TP for existing positions:
278
+
279
+ ```python
280
+ with client:
281
+ # Update SL/TP for all EURUSD positions
282
+ results = client.update_sltp_for_open_positions(
283
+ symbol="EURUSD",
284
+ stop_loss=1.0950,
285
+ take_profit=1.1100,
286
+ dry_run=False
287
+ )
288
+
289
+ # Update only specific positions by ticket
290
+ results = client.update_sltp_for_open_positions(
291
+ symbol="EURUSD",
292
+ stop_loss=1.0950,
293
+ tickets=[123456, 789012], # Specific position tickets
294
+ dry_run=True
295
+ )
296
+ ```
297
+
298
+ ## Market Data and Analysis Methods
299
+
300
+ ### Spread Analysis
301
+
302
+ ```python
303
+ with client:
304
+ # Calculate spread ratio for symbol
305
+ spread_ratio = client.calculate_spread_ratio("EURUSD")
306
+ print(f"EURUSD spread ratio: {spread_ratio:.6f}")
307
+ ```
308
+
309
+ ### OHLC Data Retrieval
310
+
311
+ ```python
312
+ with client:
313
+ # Fetch latest rate data as DataFrame
314
+ rates_df = client.fetch_latest_rates_as_df(
315
+ symbol="EURUSD",
316
+ granularity="M1", # 1-minute bars
317
+ count=1440, # Last 24 hours
318
+ index_keys="time"
319
+ )
320
+ print(f"Latest rates: {rates_df.tail()}")
321
+ ```
322
+
323
+ ### Tick Data Analysis
324
+
325
+ ```python
326
+ with client:
327
+ # Fetch recent tick data
328
+ ticks_df = client.fetch_latest_ticks_as_df(
329
+ symbol="EURUSD",
330
+ seconds=300, # Last 5 minutes
331
+ index_keys="time_msc"
332
+ )
333
+ print(f"Tick count: {len(ticks_df)}")
334
+ ```
335
+
336
+ ### Position Analytics with Enhanced Metrics
337
+
338
+ ```python
339
+ with client:
340
+ # Get positions with additional calculated metrics
341
+ positions_df = client.fetch_positions_with_metrics_as_df("EURUSD")
342
+
343
+ if not positions_df.empty:
344
+ print("Position metrics:")
345
+ print(f"Total signed volume: {positions_df['signed_volume'].sum()}")
346
+ print(f"Total signed margin: {positions_df['signed_margin'].sum()}")
347
+ print(f"Average profit ratio: {positions_df['underlier_profit_ratio'].mean():.4f}")
348
+ ```
349
+
350
+ ### Deal History Analysis
351
+
352
+ ```python
353
+ with client:
354
+ # Collect entry deals for analysis
355
+ deals_df = client.collect_entry_deals_as_df(
356
+ symbol="EURUSD",
357
+ history_seconds=3600, # Last hour
358
+ index_keys="ticket"
359
+ )
360
+
361
+ if not deals_df.empty:
362
+ print(f"Entry deals found: {len(deals_df)}")
363
+ print(f"Deal types: {deals_df['type'].value_counts()}")
364
+ ```
365
+
199
366
  ## Integration with Mt5DataClient
200
367
 
201
368
  Since Mt5TradingClient inherits from Mt5DataClient, all data retrieval methods are available:
@@ -204,14 +371,25 @@ Since Mt5TradingClient inherits from Mt5DataClient, all data retrieval methods a
204
371
  with Mt5TradingClient(config=config) as client:
205
372
  # Get current positions as DataFrame
206
373
  positions_df = client.get_positions_as_df()
207
-
374
+
208
375
  # Analyze positions
209
376
  if not positions_df.empty:
210
377
  # Calculate total exposure
211
378
  total_volume = positions_df['volume'].sum()
212
-
379
+
213
380
  # Close losing positions
214
381
  losing_positions = positions_df[positions_df['profit'] < 0]
215
382
  for symbol in losing_positions['symbol'].unique():
216
383
  client.close_open_positions(symbol)
217
- ```
384
+
385
+ # Risk management with margin calculations
386
+ for symbol in ["EURUSD", "GBPUSD", "USDJPY"]:
387
+ # Calculate current margin usage
388
+ current_ratio = client.calculate_new_position_margin_ratio(symbol)
389
+ print(f"{symbol} current margin ratio: {current_ratio:.2%}")
390
+
391
+ # Calculate maximum safe position size
392
+ safe_margin = 500.0 # USD
393
+ max_safe_volume = client.calculate_volume_by_margin(symbol, safe_margin, "BUY")
394
+ print(f"{symbol} max safe volume: {max_safe_volume}")
395
+ ```
@@ -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
@@ -263,41 +264,60 @@ class Mt5TradingClient(Mt5DataClient):
263
264
  )
264
265
  return []
265
266
 
266
- def calculate_minimum_order_margins(self, symbol: str) -> dict[str, float]:
267
- """Calculate minimum order margins for a given symbol.
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.
268
273
 
269
274
  Args:
270
- symbol: Symbol for which to calculate minimum order margins.
275
+ symbol: Symbol for which to calculate the minimum order margins.
276
+ order_side: Optional side of the order, either "BUY" or "SELL".
271
277
 
272
278
  Returns:
273
- Dictionary with margin information.
274
-
275
- Raises:
276
- Mt5TradingError: If margin calculation fails.
279
+ Dictionary with minimum volume and margin for the specified order side.
277
280
  """
278
281
  symbol_info = self.symbol_info_as_dict(symbol=symbol)
279
282
  symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
280
- min_ask_order_margin = self.order_calc_margin(
281
- 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(
282
314
  symbol=symbol,
283
- volume=symbol_info["volume_min"],
284
- price=symbol_info_tick["ask"],
315
+ order_side=order_side,
285
316
  )
286
- min_bid_order_margin = self.order_calc_margin(
287
- action=self.mt5.ORDER_TYPE_SELL,
288
- symbol=symbol,
289
- volume=symbol_info["volume_min"],
290
- price=symbol_info_tick["bid"],
317
+ return (
318
+ floor(margin / min_order_margin_dict["margin"])
319
+ * min_order_margin_dict["volume"]
291
320
  )
292
- min_order_margins = {"ask": min_ask_order_margin, "bid": min_bid_order_margin}
293
- self.logger.info("Minimum order margins for %s: %s", symbol, min_order_margins)
294
- if all(min_order_margins.values()):
295
- return min_order_margins
296
- else:
297
- error_message = (
298
- f"Failed to calculate minimum order margins for symbol: {symbol}."
299
- )
300
- raise Mt5TradingError(error_message)
301
321
 
302
322
  def calculate_spread_ratio(
303
323
  self,
@@ -477,15 +497,15 @@ class Mt5TradingClient(Mt5DataClient):
477
497
  def calculate_new_position_margin_ratio(
478
498
  self,
479
499
  symbol: str,
480
- new_side: Literal["BUY", "SELL"] | None = None,
481
- new_volume: float = 0,
500
+ new_position_side: Literal["BUY", "SELL"] | None = None,
501
+ new_position_volume: float = 0,
482
502
  ) -> float:
483
503
  """Calculate the margin ratio for a new position.
484
504
 
485
505
  Args:
486
506
  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.
507
+ new_position_side: Side of the new position, either "BUY" or "SELL".
508
+ new_position_volume: Volume of the new position.
489
509
 
490
510
  Returns:
491
511
  float: Margin ratio for the new position as a fraction of account equity.
@@ -499,20 +519,20 @@ class Mt5TradingClient(Mt5DataClient):
499
519
  positions_df["signed_margin"].sum() if positions_df.size else 0
500
520
  )
501
521
  symbol_info_tick = self.symbol_info_tick_as_dict(symbol=symbol)
502
- if new_volume == 0:
522
+ if not (new_position_side and new_position_volume):
503
523
  new_signed_margin = 0
504
- elif new_side == "BUY":
524
+ elif new_position_side.upper() == "BUY":
505
525
  new_signed_margin = self.order_calc_margin(
506
526
  action=self.mt5.ORDER_TYPE_BUY,
507
527
  symbol=symbol,
508
- volume=new_volume,
528
+ volume=new_position_volume,
509
529
  price=symbol_info_tick["ask"],
510
530
  )
511
- elif new_side == "SELL":
531
+ elif new_position_side.upper() == "SELL":
512
532
  new_signed_margin = -self.order_calc_margin(
513
533
  action=self.mt5.ORDER_TYPE_SELL,
514
534
  symbol=symbol,
515
- volume=new_volume,
535
+ volume=new_position_volume,
516
536
  price=symbol_info_tick["bid"],
517
537
  )
518
538
  else:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdmt5"
3
- version = "0.1.5"
3
+ version = "0.1.6"
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"}]
@@ -845,11 +845,11 @@ class TestMt5TradingClient:
845
845
  mock_mt5_import.order_check.assert_called_once()
846
846
  mock_mt5_import.order_send.assert_not_called()
847
847
 
848
- def test_calculate_minimum_order_margins_success(
848
+ def test_calculate_minimum_order_margin_buy_success(
849
849
  self,
850
850
  mock_mt5_import: ModuleType,
851
851
  ) -> None:
852
- """Test successful calculation of minimum order margins."""
852
+ """Test successful calculation of minimum order margin for BUY orders."""
853
853
  client = Mt5TradingClient(mt5=mock_mt5_import)
854
854
  mock_mt5_import.initialize.return_value = True
855
855
  client.initialize()
@@ -866,33 +866,24 @@ class TestMt5TradingClient:
866
866
  "bid": 1.0998,
867
867
  }
868
868
 
869
- # Mock order_calc_margin to return successful results
870
- mock_mt5_import.order_calc_margin.side_effect = [100.5, 99.8]
869
+ # Mock order_calc_margin to return successful result
870
+ mock_mt5_import.order_calc_margin.return_value = 100.5
871
871
 
872
- result = client.calculate_minimum_order_margins("EURUSD")
872
+ result = client.calculate_minimum_order_margin("EURUSD", "BUY")
873
873
 
874
- assert result == {"ask": 100.5, "bid": 99.8}
875
- assert mock_mt5_import.order_calc_margin.call_count == 2
874
+ assert result == {"volume": 0.01, "margin": 100.5}
875
+ mock_mt5_import.order_calc_margin.assert_called_once_with(
876
+ mock_mt5_import.ORDER_TYPE_BUY,
877
+ "EURUSD",
878
+ 0.01,
879
+ 1.1000,
880
+ )
876
881
 
877
- # Verify first call (buy order)
878
- first_call = mock_mt5_import.order_calc_margin.call_args_list[0]
879
- assert first_call[0][0] == mock_mt5_import.ORDER_TYPE_BUY # action
880
- assert first_call[0][1] == "EURUSD" # symbol
881
- assert first_call[0][2] == 0.01 # volume
882
- assert first_call[0][3] == 1.1000 # price
883
-
884
- # Verify second call (sell order)
885
- second_call = mock_mt5_import.order_calc_margin.call_args_list[1]
886
- assert second_call[0][0] == mock_mt5_import.ORDER_TYPE_SELL # action
887
- assert second_call[0][1] == "EURUSD" # symbol
888
- assert second_call[0][2] == 0.01 # volume
889
- assert second_call[0][3] == 1.0998 # price
890
-
891
- def test_calculate_minimum_order_margins_failure_ask(
882
+ def test_calculate_minimum_order_margin_sell_success(
892
883
  self,
893
884
  mock_mt5_import: ModuleType,
894
885
  ) -> None:
895
- """Test failed calculation of minimum order margins - ask margin fails."""
886
+ """Test successful calculation of minimum order margin for SELL orders."""
896
887
  client = Mt5TradingClient(mt5=mock_mt5_import)
897
888
  mock_mt5_import.initialize.return_value = True
898
889
  client.initialize()
@@ -909,17 +900,24 @@ class TestMt5TradingClient:
909
900
  "bid": 1.0998,
910
901
  }
911
902
 
912
- # Mock order_calc_margin to return None for ask margin
913
- mock_mt5_import.order_calc_margin.side_effect = [None, 99.8]
903
+ # Mock order_calc_margin to return successful result
904
+ mock_mt5_import.order_calc_margin.return_value = 99.8
914
905
 
915
- with pytest.raises(Mt5RuntimeError):
916
- client.calculate_minimum_order_margins("EURUSD")
906
+ result = client.calculate_minimum_order_margin("EURUSD", "SELL")
917
907
 
918
- def test_calculate_minimum_order_margins_failure_bid(
908
+ assert result == {"volume": 0.01, "margin": 99.8}
909
+ mock_mt5_import.order_calc_margin.assert_called_once_with(
910
+ mock_mt5_import.ORDER_TYPE_SELL,
911
+ "EURUSD",
912
+ 0.01,
913
+ 1.0998,
914
+ )
915
+
916
+ def test_calculate_volume_by_margin_buy_success(
919
917
  self,
920
918
  mock_mt5_import: ModuleType,
921
919
  ) -> None:
922
- """Test failed calculation of minimum order margins - bid margin fails."""
920
+ """Test successful calculation of volume by margin for BUY orders."""
923
921
  client = Mt5TradingClient(mt5=mock_mt5_import)
924
922
  mock_mt5_import.initialize.return_value = True
925
923
  client.initialize()
@@ -936,17 +934,20 @@ class TestMt5TradingClient:
936
934
  "bid": 1.0998,
937
935
  }
938
936
 
939
- # Mock order_calc_margin to return None for bid margin
940
- mock_mt5_import.order_calc_margin.side_effect = [100.5, None]
937
+ # Mock order_calc_margin to return margin for minimum volume
938
+ mock_mt5_import.order_calc_margin.return_value = 100.5
939
+
940
+ result = client.calculate_volume_by_margin("EURUSD", 1000.0, "BUY")
941
941
 
942
- with pytest.raises(Mt5RuntimeError):
943
- client.calculate_minimum_order_margins("EURUSD")
942
+ # Should return floor(1000 / 100.5) * 0.01 = 9 * 0.01 = 0.09
943
+ expected_volume = 9 * 0.01
944
+ assert result == expected_volume
944
945
 
945
- def test_calculate_minimum_order_margins_failure_both(
946
+ def test_calculate_volume_by_margin_sell_success(
946
947
  self,
947
948
  mock_mt5_import: ModuleType,
948
949
  ) -> None:
949
- """Test failed calculation of minimum order margins - both margins fail."""
950
+ """Test successful calculation of volume by margin for SELL orders."""
950
951
  client = Mt5TradingClient(mt5=mock_mt5_import)
951
952
  mock_mt5_import.initialize.return_value = True
952
953
  client.initialize()
@@ -963,11 +964,14 @@ class TestMt5TradingClient:
963
964
  "bid": 1.0998,
964
965
  }
965
966
 
966
- # Mock order_calc_margin to return 0.0 for both margins (indicates failure)
967
- mock_mt5_import.order_calc_margin.side_effect = [0.0, 0.0]
967
+ # Mock order_calc_margin to return margin for minimum volume
968
+ mock_mt5_import.order_calc_margin.return_value = 99.8
968
969
 
969
- with pytest.raises(Mt5TradingError):
970
- client.calculate_minimum_order_margins("EURUSD")
970
+ result = client.calculate_volume_by_margin("EURUSD", 500.0, "SELL")
971
+
972
+ # Should return floor(500 / 99.8) * 0.01 = 5 * 0.01 = 0.05
973
+ expected_volume = 5 * 0.01
974
+ assert result == expected_volume
971
975
 
972
976
  def test_calculate_spread_ratio(
973
977
  self,
@@ -1358,7 +1362,7 @@ class TestMt5TradingClient:
1358
1362
  }
1359
1363
 
1360
1364
  result = client.calculate_new_position_margin_ratio(
1361
- symbol="EURUSD", new_side="BUY", new_volume=0.1
1365
+ symbol="EURUSD", new_position_side="BUY", new_position_volume=0.1
1362
1366
  )
1363
1367
 
1364
1368
  assert result == 0.0
@@ -1409,7 +1413,7 @@ class TestMt5TradingClient:
1409
1413
  mock_mt5_import.order_calc_margin.return_value = 1000.0
1410
1414
 
1411
1415
  result = client.calculate_new_position_margin_ratio(
1412
- symbol="EURUSD", new_side="BUY", new_volume=0.1
1416
+ symbol="EURUSD", new_position_side="BUY", new_position_volume=0.1
1413
1417
  )
1414
1418
 
1415
1419
  # Should return (new_margin + current_margin) / equity
@@ -1444,7 +1448,7 @@ class TestMt5TradingClient:
1444
1448
  mock_mt5_import.order_calc_margin.return_value = 1000.0
1445
1449
 
1446
1450
  result = client.calculate_new_position_margin_ratio(
1447
- symbol="EURUSD", new_side="SELL", new_volume=0.1
1451
+ symbol="EURUSD", new_position_side="SELL", new_position_volume=0.1
1448
1452
  )
1449
1453
 
1450
1454
  # Should return abs(-new_margin / equity) for sell
@@ -1475,10 +1479,10 @@ class TestMt5TradingClient:
1475
1479
  }
1476
1480
 
1477
1481
  result = client.calculate_new_position_margin_ratio(
1478
- symbol="EURUSD", new_side="BUY", new_volume=0
1482
+ symbol="EURUSD", new_position_side="BUY", new_position_volume=0
1479
1483
  )
1480
1484
 
1481
- # Should return 0 since new_volume is 0
1485
+ # Should return 0 since new_position_volume is 0
1482
1486
  assert result == 0.0
1483
1487
 
1484
1488
  def test_calculate_new_position_margin_ratio_invalid_side(
@@ -1505,12 +1509,44 @@ class TestMt5TradingClient:
1505
1509
  }
1506
1510
 
1507
1511
  result = client.calculate_new_position_margin_ratio(
1508
- symbol="EURUSD", new_side=None, new_volume=0.1
1512
+ symbol="EURUSD", new_position_side=None, new_position_volume=0.1
1509
1513
  )
1510
1514
 
1511
1515
  # Should return 0 since side is invalid
1512
1516
  assert result == 0.0
1513
1517
 
1518
+ def test_calculate_new_position_margin_ratio_invalid_side_string(
1519
+ self, mock_mt5_import: ModuleType
1520
+ ) -> None:
1521
+ """Test calculating margin ratio with invalid side string."""
1522
+ client = Mt5TradingClient(mt5=mock_mt5_import)
1523
+ mock_mt5_import.initialize.return_value = True
1524
+ client.initialize()
1525
+
1526
+ # Mock account info
1527
+ mock_mt5_import.account_info.return_value._asdict.return_value = {
1528
+ "equity": 10000.0,
1529
+ }
1530
+
1531
+ # Mock empty positions
1532
+ mock_mt5_import.positions_get.return_value = []
1533
+
1534
+ # Mock symbol tick info
1535
+ mock_mt5_import.symbol_info_tick.return_value._asdict.return_value = {
1536
+ "time": pd.Timestamp("2009-02-14 00:31:30"),
1537
+ "ask": 1.1002,
1538
+ "bid": 1.1000,
1539
+ }
1540
+
1541
+ result = client.calculate_new_position_margin_ratio(
1542
+ symbol="EURUSD",
1543
+ new_position_side="INVALID", # type: ignore[arg-type]
1544
+ new_position_volume=0.1,
1545
+ )
1546
+
1547
+ # Should return 0 since side is invalid string
1548
+ assert result == 0.0
1549
+
1514
1550
  def test_update_sltp_for_open_positions(self, mock_mt5_import: ModuleType) -> None:
1515
1551
  """Test update_sltp_for_open_positions method."""
1516
1552
  client = Mt5TradingClient(mt5=mock_mt5_import)
@@ -613,7 +613,7 @@ wheels = [
613
613
 
614
614
  [[package]]
615
615
  name = "pdmt5"
616
- version = "0.1.5"
616
+ version = "0.1.6"
617
617
  source = { editable = "." }
618
618
  dependencies = [
619
619
  { name = "metatrader5", marker = "sys_platform == 'win32'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes