bbstrader 0.3.1__py3-none-any.whl → 0.3.2__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.

Potentially problematic release.


This version of bbstrader might be problematic. Click here for more details.

@@ -1,7 +1,7 @@
1
- import multiprocessing
2
- import threading
1
+ import multiprocessing as mp
3
2
  import time
4
3
  from datetime import datetime
4
+ from multiprocessing.synchronize import Event
5
5
  from pathlib import Path
6
6
  from typing import Dict, List, Literal, Tuple
7
7
 
@@ -9,7 +9,7 @@ from loguru import logger as log
9
9
 
10
10
  from bbstrader.config import BBSTRADER_DIR
11
11
  from bbstrader.metatrader.account import Account, check_mt5_connection
12
- from bbstrader.metatrader.trade import Trade
12
+ from bbstrader.metatrader.trade import FILLING_TYPE
13
13
  from bbstrader.metatrader.utils import TradeOrder, TradePosition, trade_retcode_message
14
14
 
15
15
  try:
@@ -18,8 +18,13 @@ except ImportError:
18
18
  import bbstrader.compat # noqa: F401
19
19
 
20
20
 
21
- __all__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
22
-
21
+ __all__ = [
22
+ "TradeCopier",
23
+ "copier_worker_process",
24
+ "RunCopier",
25
+ "RunMultipleCopier",
26
+ "config_copier",
27
+ ]
23
28
 
24
29
  log.add(
25
30
  f"{BBSTRADER_DIR}/logs/copier.log",
@@ -31,6 +36,18 @@ global logger
31
36
  logger = log
32
37
 
33
38
 
39
+ ORDER_TYPE = {
40
+ 0: (Mt5.ORDER_TYPE_BUY, "BUY"),
41
+ 1: (Mt5.ORDER_TYPE_SELL, "SELL"),
42
+ 2: (Mt5.ORDER_TYPE_BUY_LIMIT, "BUY LIMIT"),
43
+ 3: (Mt5.ORDER_TYPE_SELL_LIMIT, "SELL LIMIT"),
44
+ 4: (Mt5.ORDER_TYPE_BUY_STOP, "BUY STOP"),
45
+ 5: (Mt5.ORDER_TYPE_SELL_STOP, "SELL STOP"),
46
+ 6: (Mt5.ORDER_TYPE_BUY_STOP_LIMIT, "BUY STOP LIMIT"),
47
+ 7: (Mt5.ORDER_TYPE_SELL_STOP_LIMIT, "SELL STOP LIMIT"),
48
+ }
49
+
50
+
34
51
  def fix_lot(fixed):
35
52
  if fixed == 0 or fixed is None:
36
53
  raise ValueError("Fixed lot must be a number")
@@ -116,7 +133,34 @@ def calculate_copy_lot(
116
133
  raise ValueError("Invalid mode selected")
117
134
 
118
135
 
119
- def get_copy_symbols(destination: dict, source: dict):
136
+ def get_symbols_from_string(symbols_string: str):
137
+ if not symbols_string:
138
+ raise ValueError("Input Error", "Tickers string cannot be empty.")
139
+ string = (
140
+ symbols_string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
141
+ )
142
+ if ":" in string and "," in string:
143
+ if string.endswith(","):
144
+ string = string[:-1]
145
+ return dict(item.split(":") for item in string.split(","))
146
+ elif ":" in string and "," not in string:
147
+ raise ValueError("Each key pairs value must be separeted by ','")
148
+ elif "," in string and ":" not in string:
149
+ return string.split(",")
150
+ else:
151
+ raise ValueError("""
152
+ Invalid symbols format.
153
+ You can use comma separated symbols in one line or multiple lines using triple quotes.
154
+ You can also use a dictionary to map source symbols to destination symbols as shown below.
155
+ Or if you want to copy all symbols, use "all" or "*".
156
+
157
+ symbols = EURUSD,GBPUSD,USDJPY (comma separated)
158
+ symbols = EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i (dictionary)
159
+ symbols = all (copy all symbols)
160
+ symbols = * (copy all symbols) """)
161
+
162
+
163
+ def get_copy_symbols(destination: dict, source: dict) -> List[str] | Dict[str, str]:
120
164
  symbols = destination.get("symbols", "all")
121
165
  src_account = Account(**source)
122
166
  dest_account = Account(**destination)
@@ -129,15 +173,18 @@ def get_copy_symbols(destination: dict, source: dict):
129
173
  f"To use 'all' or '*', Source account@{src_account.number} "
130
174
  f"and destination account@{dest_account.number} "
131
175
  f"must be the same type and have the same symbols"
176
+ f"If not Use a dictionary to map source symbols to destination symbols "
177
+ f"(e.g., EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i"
178
+ f"Where EURUSD.s is the source symbols and EURUSD_i is the corresponding symbol"
132
179
  )
133
180
  raise ValueError(err_msg)
181
+ return dest_symbols
134
182
  elif isinstance(symbols, (list, dict)):
135
183
  return symbols
136
184
  elif isinstance(symbols, str):
137
- if "," in symbols:
138
- return symbols.split(",")
139
- if " " in symbols:
140
- return symbols.split()
185
+ return get_symbols_from_string(symbols)
186
+ else:
187
+ raise ValueError("Invalide symbols provided")
141
188
 
142
189
 
143
190
  class TradeCopier(object):
@@ -151,6 +198,8 @@ class TradeCopier(object):
151
198
 
152
199
  __slots__ = (
153
200
  "source",
201
+ "source_id",
202
+ "source_isunique",
154
203
  "destinations",
155
204
  "errors",
156
205
  "sleeptime",
@@ -158,18 +207,30 @@ class TradeCopier(object):
158
207
  "end_time",
159
208
  "shutdown_event",
160
209
  "custom_logger",
210
+ "log_queue",
211
+ "_last_session",
212
+ "_running",
161
213
  )
162
- shutdown_event: threading.Event
214
+
215
+ source: Dict
216
+ source_id: int
217
+ source_isunique: bool
218
+ destinations: List[dict]
219
+ shutdown_event: Event
220
+ log_queue: mp.Queue
163
221
 
164
222
  def __init__(
165
223
  self,
166
224
  source: Dict,
167
225
  destinations: List[dict],
226
+ /,
168
227
  sleeptime: float = 0.1,
169
228
  start_time: str = None,
170
229
  end_time: str = None,
171
- shutdown_event=None,
230
+ *,
172
231
  custom_logger=None,
232
+ shutdown_event=None,
233
+ log_queue=None,
173
234
  ):
174
235
  """
175
236
  Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
@@ -185,7 +246,12 @@ class TradeCopier(object):
185
246
  - `password`: The account password (string).
186
247
  - `server`: The server address (string), e.g., "Broker-Demo".
187
248
  - `path`: The path to the MetaTrader 5 installation directory (string).
188
- - ``portable``: A boolean indicating whether to open MetaTrader 5 installation in portable mode.
249
+ - `portable`: A boolean indicating whether to open MetaTrader 5 installation in portable mode.
250
+ - `id`: A unique identifier for all trades opened buy the source source account.
251
+ This Must be a positive number greater than 0 and less than 2^32 / 2.
252
+ - `unique`: A boolean indication whehter to allow destination accounts to copy from other sources.
253
+ If Set to True, all destination accounts won't be allow to accept trades from other accounts even
254
+ manually opened positions or orders will be removed.
189
255
 
190
256
  destinations (List[dict]):
191
257
  A list of dictionaries, where each dictionary represents a destination trading account to which
@@ -237,20 +303,41 @@ class TradeCopier(object):
237
303
  sleeptime (float, optional):
238
304
  The time interval in seconds between each iteration of the trade copying process.
239
305
  Defaults to 0.1 seconds. It can be useful if you know the frequency of new trades on the source account.
306
+
307
+ start_time (str, optional): The time (HH:MM) from which the copier start copying from the source.
308
+ end_time (str, optional): The time (HH:MM) from which the copier stop copying from the source.
309
+ sleeptime (float, optional): The delay between each check from the source account.
310
+ custom_logger (Any, Optional): Used to set a cutum logger (default is ``loguru.logger``)
311
+ shutdown_event (Any, Otional): Use to terminal the copy process when runs in a custum environment like web App or GUI.
312
+ log_queue (multiprocessing.Queue, Optional): Use to send log to an external program, usefule in GUI apps
313
+
240
314
  Note:
241
315
  The source account and the destination accounts must be connected to different MetaTrader 5 platforms.
242
316
  you can copy the initial installation of MetaTrader 5 to a different directory and rename it to create a new instance
243
317
  Then you can connect destination accounts to the new instance while the source account is connected to the original instance.
244
318
  """
245
319
  self.source = source
320
+ self.source_id = source.get("id", 0)
321
+ self.source_isunique = source.get("unique", True)
246
322
  self.destinations = destinations
247
323
  self.sleeptime = sleeptime
248
324
  self.start_time = start_time
249
325
  self.end_time = end_time
250
- self.shutdown_event = shutdown_event
326
+ self.errors = set()
327
+ self.log_queue = log_queue
251
328
  self._add_logger(custom_logger)
329
+ self._validate_source()
252
330
  self._add_copy()
253
- self.errors = set()
331
+ self.shutdown_event = (
332
+ shutdown_event if shutdown_event is not None else mp.Event()
333
+ )
334
+ self._last_session = datetime.now().date()
335
+ self._running = True
336
+
337
+ @property
338
+ def running(self):
339
+ """Check if the Trade Copier is running."""
340
+ return self._running
254
341
 
255
342
  def _add_logger(self, custom_logger):
256
343
  if custom_logger:
@@ -258,9 +345,67 @@ class TradeCopier(object):
258
345
  logger = custom_logger
259
346
 
260
347
  def _add_copy(self):
261
- self.source["copy"] = True
348
+ self.source["copy"] = self.source.get("copy", True)
262
349
  for destination in self.destinations:
350
+ destination["copy"] = destination.get("copy", True)
351
+
352
+ def _log_message(self, message, type="info"):
353
+ if self.log_queue:
354
+ try:
355
+ now = datetime.now()
356
+ formatted = (
357
+ now.strftime("%Y-%m-%d %H:%M:%S.")
358
+ + f"{int(now.microsecond / 1000):03d}"
359
+ )
360
+ space = len("warning") # longest log name
361
+ self.log_queue.put(
362
+ f"{formatted} |{type.upper()} {' '*(space - len(type))}| - {message}"
363
+ )
364
+ except Exception:
365
+ pass
366
+ else:
367
+ logmethod = logger.info if type == "info" else logger.error
368
+ logmethod(message)
369
+
370
+ def log_error(self, e, symbol=None):
371
+ if datetime.now().date() > self._last_session:
372
+ self._last_session = datetime.now().date()
373
+ self.errors.clear()
374
+ error_msg = repr(e)
375
+ if error_msg not in self.errors:
376
+ self.errors.add(error_msg)
377
+ add_msg = f"SYMBOL={symbol}" if symbol else ""
378
+ message = f"Error encountered: {error_msg}, {add_msg}"
379
+ self._log_message(message, type="error")
380
+
381
+ def _validate_source(self):
382
+ if not self.source_isunique:
383
+ try:
384
+ assert self.source_id >= 1
385
+ except AssertionError:
386
+ raise ValueError(
387
+ "Non Unique source account must have a valide ID , (e.g., source['id'] = 1234)"
388
+ )
389
+
390
+ def add_destinations(self, destination_accounts: List[dict]):
391
+ self.stop()
392
+ destinations = destination_accounts.copy()
393
+ for destination in destinations:
263
394
  destination["copy"] = True
395
+ self.destinations.append(destination)
396
+ self.run()
397
+
398
+ def _get_magic(self, ticket: int) -> int:
399
+ return int(str(self.source_id) + str(ticket)) if self.source_id >= 1 else ticket
400
+
401
+ def _select_symbol(self, symbol: str, destination: dict):
402
+ selected = Mt5.symbol_select(symbol, True)
403
+ if not selected:
404
+ self._log_message(
405
+ f"Failed to select {destination.get('login')}::{symbol}, error code =",
406
+ Mt5.last_error(),
407
+ type="error",
408
+ )
264
409
 
265
410
  def source_orders(self, symbol=None):
266
411
  check_mt5_connection(**self.source)
@@ -294,7 +439,7 @@ class TradeCopier(object):
294
439
  raise ValueError(f"Symbol {symbol} not found in {type} account")
295
440
 
296
441
  def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
297
- if source.type == dest.type and source.ticket == dest.magic:
442
+ if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
298
443
  return (
299
444
  source.sl != dest.sl
300
445
  or source.tp != dest.tp
@@ -304,7 +449,7 @@ class TradeCopier(object):
304
449
  return False
305
450
 
306
451
  def isposition_modified(self, source: TradePosition, dest: TradePosition):
307
- if source.type == dest.type and source.ticket == dest.magic:
452
+ if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
308
453
  return source.sl != dest.sl or source.tp != dest.tp
309
454
  return False
310
455
 
@@ -332,14 +477,24 @@ class TradeCopier(object):
332
477
  return True
333
478
  return False
334
479
 
335
- def copy_new_trade(
336
- self, trade: TradeOrder | TradePosition, action_type: dict, destination: dict
337
- ):
480
+ def _update_filling_type(self, request, result):
481
+ new_result = result
482
+ if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL:
483
+ for fill in FILLING_TYPE:
484
+ request["type_filling"] = fill
485
+ new_result = Mt5.order_send(request)
486
+ if new_result.retcode == Mt5.TRADE_RETCODE_DONE:
487
+ break
488
+ return new_result
489
+
490
+ def copy_new_trade(self, trade: TradeOrder | TradePosition, destination: dict):
338
491
  if not self.iscopy_time():
339
492
  return
340
493
  check_mt5_connection(**destination)
341
- volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
342
494
  symbol = self.get_copy_symbol(trade.symbol, destination)
495
+ self._select_symbol(symbol, destination)
496
+
497
+ volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
343
498
  lot = calculate_copy_lot(
344
499
  volume,
345
500
  symbol,
@@ -349,53 +504,56 @@ class TradeCopier(object):
349
504
  source_eqty=Account(**self.source).get_account_info().margin_free,
350
505
  dest_eqty=Account(**destination).get_account_info().margin_free,
351
506
  )
352
-
353
- trade_instance = Trade(
354
- symbol=symbol, **destination, max_risk=100.0, logger=None
507
+ trade_action = (
508
+ Mt5.TRADE_ACTION_DEAL if trade.type in [0, 1] else Mt5.TRADE_ACTION_PENDING
355
509
  )
510
+ action = ORDER_TYPE[trade.type][1]
511
+ tick = Mt5.symbol_info_tick(symbol)
512
+ price = tick.ask if trade.type == 0 else tick.bid
356
513
  try:
357
- action = action_type[trade.type]
358
- except KeyError:
359
- return
360
- try:
361
- if trade_instance.open_position(
362
- action,
514
+ request = dict(
515
+ symbol=symbol,
516
+ action=trade_action,
363
517
  volume=lot,
518
+ price=price,
364
519
  sl=trade.sl,
365
520
  tp=trade.tp,
366
- id=trade.ticket,
367
- symbol=symbol,
368
- mm=trade.sl != 0 and trade.tp != 0,
369
- price=trade.price_open if trade.type not in [0, 1] else None,
370
- stoplimit=trade.price_stoplimit if trade.type in [6, 7] else None,
521
+ type=ORDER_TYPE[trade.type][0],
522
+ magic=self._get_magic(trade.ticket),
523
+ deviation=Mt5.symbol_info(symbol).spread,
371
524
  comment=destination.get("comment", trade.comment + "#Copied"),
372
- ):
373
- logger.info(
525
+ type_time=Mt5.ORDER_TIME_GTC,
526
+ type_filling=Mt5.ORDER_FILLING_FOK,
527
+ )
528
+ if trade.type not in [0, 1]:
529
+ request["price"] = trade.price_open
530
+
531
+ if trade.type in [6, 7]:
532
+ request["stoplimit"] = trade.price_stoplimit
533
+
534
+ result = Mt5.order_send(request)
535
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
536
+ result = self._update_filling_type(request, result)
537
+ if result.retcode == Mt5.TRADE_RETCODE_DONE:
538
+ self._log_message(
374
539
  f"Copy {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
375
- f"to @{destination.get('login')}::{symbol}"
540
+ f"to @{destination.get('login')}::{symbol}",
376
541
  )
377
- else:
378
- logger.error(
542
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
543
+ self._log_message(
379
544
  f"Error copying {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
380
- f"to @{destination.get('login')}::{symbol}"
545
+ f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
546
+ type="error",
381
547
  )
382
548
  except Exception as e:
383
549
  self.log_error(e, symbol=symbol)
384
550
 
385
551
  def copy_new_order(self, order: TradeOrder, destination: dict):
386
- action_type = {
387
- 2: "BLMT",
388
- 3: "SLMT",
389
- 4: "BSTP",
390
- 5: "SSTP",
391
- 6: "BSTPLMT",
392
- 7: "SSTPLMT",
393
- }
394
- self.copy_new_trade(order, action_type, destination)
552
+ self.copy_new_trade(order, destination)
395
553
 
396
554
  def modify_order(self, ticket, symbol, source_order: TradeOrder, destination: dict):
397
555
  check_mt5_connection(**destination)
398
- account = Account(**destination)
556
+ self._select_symbol(symbol, destination)
399
557
  request = {
400
558
  "action": Mt5.TRADE_ACTION_MODIFY,
401
559
  "order": ticket,
@@ -405,42 +563,51 @@ class TradeCopier(object):
405
563
  "tp": source_order.tp,
406
564
  "stoplimit": source_order.price_stoplimit,
407
565
  }
408
- result = account.send_order(request)
566
+ result = Mt5.order_send(request)
409
567
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
410
- msg = trade_retcode_message(result.retcode)
411
- logger.error(
412
- f"Error modifying Order #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
413
- f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
414
- )
568
+ result = self._update_filling_type(request, result)
415
569
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
416
- logger.info(
417
- f"Modify Order #{ticket} on @{destination.get('login')}::{symbol}, "
570
+ self._log_message(
571
+ f"Modify {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol}, "
418
572
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
419
573
  )
574
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
575
+ self._log_message(
576
+ f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
577
+ f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
578
+ type="error",
579
+ )
420
580
 
421
581
  def remove_order(self, src_symbol, order: TradeOrder, destination: dict):
422
582
  check_mt5_connection(**destination)
423
- trade = Trade(symbol=order.symbol, **destination, logger=None)
424
- if trade.close_order(order.ticket, id=order.magic):
425
- logger.info(
426
- f"Close Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
583
+ self._select_symbol(order.symbol, destination)
584
+ request = {
585
+ "action": Mt5.TRADE_ACTION_REMOVE,
586
+ "order": order.ticket,
587
+ }
588
+ result = Mt5.order_send(request)
589
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
590
+ result = self._update_filling_type(request, result)
591
+ if result.retcode == Mt5.TRADE_RETCODE_DONE:
592
+ self._log_message(
593
+ f"Close {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
427
594
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
428
595
  )
429
- else:
430
- logger.error(
431
- f"Error closing Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
432
- f"SOURCE=@{self.source.get('login')}::{src_symbol}"
596
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
597
+ self._log_message(
598
+ f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
599
+ f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
600
+ type="error",
433
601
  )
434
602
 
435
603
  def copy_new_position(self, position: TradePosition, destination: dict):
436
- action_type = {0: "BMKT", 1: "SMKT"}
437
- self.copy_new_trade(position, action_type, destination)
604
+ self.copy_new_trade(position, destination)
438
605
 
439
606
  def modify_position(
440
607
  self, ticket, symbol, source_pos: TradePosition, destination: dict
441
608
  ):
442
609
  check_mt5_connection(**destination)
443
- account = Account(**destination)
610
+ self._select_symbol(symbol, destination)
444
611
  request = {
445
612
  "action": Mt5.TRADE_ACTION_SLTP,
446
613
  "position": ticket,
@@ -448,31 +615,54 @@ class TradeCopier(object):
448
615
  "sl": source_pos.sl,
449
616
  "tp": source_pos.tp,
450
617
  }
451
- result = account.send_order(request)
618
+ result = Mt5.order_send(request)
452
619
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
453
- msg = trade_retcode_message(result.retcode)
454
- logger.error(
455
- f"Error modifying Position #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
456
- f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
457
- )
620
+ result = self._update_filling_type(request, result)
458
621
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
459
- logger.info(
460
- f"Modify Position #{ticket} on @{destination.get('login')}::{symbol}, "
622
+ self._log_message(
623
+ f"Modify {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
461
624
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
462
625
  )
626
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
627
+ self._log_message(
628
+ f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
629
+ f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
630
+ type="error",
631
+ )
463
632
 
464
633
  def remove_position(self, src_symbol, position: TradePosition, destination: dict):
465
634
  check_mt5_connection(**destination)
466
- trade = Trade(symbol=position.symbol, **destination, logger=None)
467
- if trade.close_position(position.ticket, id=position.magic):
468
- logger.info(
469
- f"Close Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
635
+ self._select_symbol(position.symbol, destination)
636
+ position_type = (
637
+ Mt5.ORDER_TYPE_SELL if position.type == 0 else Mt5.ORDER_TYPE_BUY
638
+ )
639
+ request = {
640
+ "action": Mt5.TRADE_ACTION_DEAL,
641
+ "symbol": position.symbol,
642
+ "volume": position.volume,
643
+ "type": position_type,
644
+ "position": position.ticket,
645
+ "price": position.price_current,
646
+ "deviation": int(Mt5.symbol_info(position.symbol).spread),
647
+ "type_time": Mt5.ORDER_TIME_GTC,
648
+ "type_filling": Mt5.ORDER_FILLING_FOK,
649
+ }
650
+ result = Mt5.order_send(request)
651
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
652
+ result = self._update_filling_type(request, result)
653
+
654
+ if result.retcode == Mt5.TRADE_RETCODE_DONE:
655
+ self._log_message(
656
+ f"Close {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
657
+ f"on @{destination.get('login')}::{position.symbol}, "
470
658
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
471
659
  )
472
- else:
473
- logger.error(
474
- f"Error closing Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
475
- f"SOURCE=@{self.source.get('login')}::{src_symbol}"
660
+ if result.retcode != Mt5.TRADE_RETCODE_DONE:
661
+ self._log_message(
662
+ f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
663
+ f"on @{destination.get('login')}::{position.symbol}, "
664
+ f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
665
+ type="error",
476
666
  )
477
667
 
478
668
  def filter_positions_and_orders(self, pos_or_orders, symbols=None):
@@ -487,7 +677,9 @@ class TradeCopier(object):
487
677
  if pos.symbol in symbols.keys() or pos.symbol in symbols.values()
488
678
  ]
489
679
 
490
- def get_positions(self, destination: dict):
680
+ def get_positions(
681
+ self, destination: dict
682
+ ) -> Tuple[List[TradePosition], List[TradePosition]]:
491
683
  source_positions = self.source_positions() or []
492
684
  dest_symbols = get_copy_symbols(destination, self.source)
493
685
  dest_positions = self.destination_positions(destination) or []
@@ -499,7 +691,9 @@ class TradeCopier(object):
499
691
  )
500
692
  return source_positions, dest_positions
501
693
 
502
- def get_orders(self, destination: dict):
694
+ def get_orders(
695
+ self, destination: dict
696
+ ) -> Tuple[List[TradeOrder], List[TradeOrder]]:
503
697
  source_orders = self.source_orders() or []
504
698
  dest_symbols = get_copy_symbols(destination, self.source)
505
699
  dest_orders = self.destination_orders(destination) or []
@@ -511,12 +705,27 @@ class TradeCopier(object):
511
705
  )
512
706
  return source_orders, dest_orders
513
707
 
708
+ def _copy_what(self, destination):
709
+ if not destination.get("copy", False):
710
+ raise ValueError("Destination account not set to copy mode")
711
+ return destination.get("copy_what", "all")
712
+
713
+ def _isvalide_magic(self, magic):
714
+ ticket = str(magic)
715
+ id = str(self.source_id)
716
+ return (
717
+ ticket != id
718
+ and ticket.startswith(id)
719
+ and ticket[: len(id)] == id
720
+ and int(ticket[: len(id)]) == self.source_id
721
+ )
722
+
514
723
  def _copy_new_orders(self, destination):
515
724
  source_orders, destination_orders = self.get_orders(destination)
516
725
  # Check for new orders
517
726
  dest_ids = [order.magic for order in destination_orders]
518
727
  for source_order in source_orders:
519
- if source_order.ticket not in dest_ids:
728
+ if self._get_magic(source_order.ticket) not in dest_ids:
520
729
  if not self.slippage(source_order, destination):
521
730
  self.copy_new_order(source_order, destination)
522
731
 
@@ -525,7 +734,7 @@ class TradeCopier(object):
525
734
  source_orders, destination_orders = self.get_orders(destination)
526
735
  for source_order in source_orders:
527
736
  for destination_order in destination_orders:
528
- if source_order.ticket == destination_order.magic:
737
+ if self._get_magic(source_order.ticket) == destination_order.magic:
529
738
  if self.isorder_modified(source_order, destination_order):
530
739
  ticket = destination_order.ticket
531
740
  symbol = destination_order.symbol
@@ -534,27 +743,16 @@ class TradeCopier(object):
534
743
  def _copy_closed_orders(self, destination):
535
744
  # Check for closed orders
536
745
  source_orders, destination_orders = self.get_orders(destination)
537
- source_ids = [order.ticket for order in source_orders]
746
+ source_ids = [self._get_magic(order.ticket) for order in source_orders]
538
747
  for destination_order in destination_orders:
539
748
  if destination_order.magic not in source_ids:
540
- src_symbol = self.get_copy_symbol(
541
- destination_order.symbol, destination, type="source"
542
- )
543
- self.remove_order(src_symbol, destination_order, destination)
544
-
545
- def _sync_positions(self, what, destination):
546
- # Update postions
547
- source_positions, _ = self.get_positions(destination)
548
- _, destination_orders = self.get_orders(destination)
549
- for source_position in source_positions:
550
- for destination_order in destination_orders:
551
- if source_position.ticket == destination_order.magic:
552
- self.remove_order(
553
- source_position.symbol, destination_order, destination
749
+ if self.source_isunique or self._isvalide_magic(
750
+ destination_order.magic
751
+ ):
752
+ src_symbol = self.get_copy_symbol(
753
+ destination_order.symbol, destination, type="source"
554
754
  )
555
- if what in ["all", "positions"]:
556
- if not self.slippage(source_position, destination):
557
- self.copy_new_position(source_position, destination)
755
+ self.remove_order(src_symbol, destination_order, destination)
558
756
 
559
757
  def _sync_orders(self, destination):
560
758
  # Update orders
@@ -562,18 +760,13 @@ class TradeCopier(object):
562
760
  source_orders, _ = self.get_orders(destination)
563
761
  for destination_position in destination_positions:
564
762
  for source_order in source_orders:
565
- if destination_position.magic == source_order.ticket:
763
+ if destination_position.magic == self._get_magic(source_order.ticket):
566
764
  self.remove_position(
567
765
  source_order.symbol, destination_position, destination
568
766
  )
569
767
  if not self.slippage(source_order, destination):
570
768
  self.copy_new_order(source_order, destination)
571
769
 
572
- def _copy_what(self, destination):
573
- if not destination.get("copy", False):
574
- raise ValueError("Destination account not set to copy mode")
575
- return destination.get("copy_what", "all")
576
-
577
770
  def copy_orders(self, destination: dict):
578
771
  what = self._copy_what(destination)
579
772
  if what not in ["all", "orders"]:
@@ -590,7 +783,7 @@ class TradeCopier(object):
590
783
  # Check for new positions
591
784
  dest_ids = [pos.magic for pos in destination_positions]
592
785
  for source_position in source_positions:
593
- if source_position.ticket not in dest_ids:
786
+ if self._get_magic(source_position.ticket) not in dest_ids:
594
787
  if not self.slippage(source_position, destination):
595
788
  self.copy_new_position(source_position, destination)
596
789
 
@@ -599,7 +792,10 @@ class TradeCopier(object):
599
792
  source_positions, destination_positions = self.get_positions(destination)
600
793
  for source_position in source_positions:
601
794
  for destination_position in destination_positions:
602
- if source_position.ticket == destination_position.magic:
795
+ if (
796
+ self._get_magic(source_position.ticket)
797
+ == destination_position.magic
798
+ ):
603
799
  if self.isposition_modified(source_position, destination_position):
604
800
  ticket = destination_position.ticket
605
801
  symbol = destination_position.symbol
@@ -610,13 +806,30 @@ class TradeCopier(object):
610
806
  def _copy_closed_position(self, destination):
611
807
  # Check for closed positions
612
808
  source_positions, destination_positions = self.get_positions(destination)
613
- source_ids = [pos.ticket for pos in source_positions]
809
+ source_ids = [self._get_magic(pos.ticket) for pos in source_positions]
614
810
  for destination_position in destination_positions:
615
811
  if destination_position.magic not in source_ids:
616
- src_symbol = self.get_copy_symbol(
617
- destination_position.symbol, destination, type="source"
618
- )
619
- self.remove_position(src_symbol, destination_position, destination)
812
+ if self.source_isunique or self._isvalide_magic(
813
+ destination_position.magic
814
+ ):
815
+ src_symbol = self.get_copy_symbol(
816
+ destination_position.symbol, destination, type="source"
817
+ )
818
+ self.remove_position(src_symbol, destination_position, destination)
819
+
820
+ def _sync_positions(self, what, destination):
821
+ # Update postions
822
+ source_positions, _ = self.get_positions(destination)
823
+ _, destination_orders = self.get_orders(destination)
824
+ for source_position in source_positions:
825
+ for destination_order in destination_orders:
826
+ if self._get_magic(source_position.ticket) == destination_order.magic:
827
+ self.remove_order(
828
+ source_position.symbol, destination_order, destination
829
+ )
830
+ if what in ["all", "positions"]:
831
+ if not self.slippage(source_position, destination):
832
+ self.copy_new_position(source_position, destination)
620
833
 
621
834
  def copy_positions(self, destination: dict):
622
835
  what = self._copy_what(destination)
@@ -627,61 +840,133 @@ class TradeCopier(object):
627
840
  self._copy_modified_positions(destination)
628
841
  self._copy_closed_position(destination)
629
842
 
630
- def log_error(self, e, symbol=None):
631
- error_msg = repr(e)
632
- if error_msg not in self.errors:
633
- self.errors.add(error_msg)
634
- add_msg = f"SYMBOL={symbol}" if symbol else ""
635
- logger.error(f"Error encountered: {error_msg}, {add_msg}")
843
+ def start_copy_process(self, destination: dict):
844
+ """
845
+ Worker process: copy orders and positions for a single destination account.
846
+ """
847
+ if destination.get("path") == self.source.get("path"):
848
+ self._log_message(
849
+ f"Source and destination accounts are on the same "
850
+ f"MetaTrader 5 installation {self.source.get('path')} which is not allowed."
851
+ )
852
+ return
636
853
 
637
- def run(self):
638
- logger.info("Trade Copier Running ...")
639
- logger.info(f"Source Account: {self.source.get('login')}")
640
- while True:
641
- if self.shutdown_event and self.shutdown_event.is_set():
642
- logger.info(
643
- "Shutdown event received, stopping Trade Copier gracefully."
644
- )
645
- break
854
+ self._log_message(
855
+ f"Copy started for source @{self.source.get('login')} "
856
+ f" and destination @{destination.get('login')}"
857
+ )
858
+ while not self.shutdown_event.is_set():
646
859
  try:
860
+ self.copy_positions(destination)
861
+ self.copy_orders(destination)
862
+ except KeyboardInterrupt:
863
+ self._log_message(
864
+ "KeyboardInterrupt received, stopping the Trade Copier ..."
865
+ )
866
+ self.stop()
867
+ except Exception as e:
868
+ self.log_error(e)
869
+
870
+ self._log_message(
871
+ f"Process exiting for destination @{destination.get('login')} due to shutdown event."
872
+ )
873
+
874
+ def run(self):
875
+ """
876
+ Entry point to start the copier.
877
+ This will loop through the destinations it was given and process them.
878
+ """
879
+ self._log_message(
880
+ f"Copier instance started for source @{self.source.get('login')}"
881
+ )
882
+ try:
883
+ while not self.shutdown_event.is_set():
647
884
  for destination in self.destinations:
648
- if self.shutdown_event and self.shutdown_event.is_set():
885
+ if self.shutdown_event.is_set():
649
886
  break
887
+
650
888
  if destination.get("path") == self.source.get("path"):
651
- err_msg = "Source and destination accounts are on the same \
652
- MetaTrader 5 installation which is not allowed."
653
- logger.error(err_msg)
889
+ self._log_message(
890
+ f"Source and destination accounts are on the same "
891
+ f"MetaTrader 5 installation {self.source.get('path')} which is not allowed."
892
+ )
654
893
  continue
655
- self.copy_orders(destination)
656
- self.copy_positions(destination)
657
- Mt5.shutdown()
658
- time.sleep(0.1)
659
-
660
- if self.shutdown_event and self.shutdown_event.is_set():
661
- logger.info(
662
- "Shutdown event received during destination processing, exiting."
663
- )
664
- break
894
+ try:
895
+ self.copy_positions(destination)
896
+ self.copy_orders(destination)
897
+ except Exception as e:
898
+ self.log_error(e)
899
+ time.sleep(self.sleeptime)
900
+
901
+ except KeyboardInterrupt:
902
+ self._log_message(
903
+ "KeyboardInterrupt received, stopping the copier instance..."
904
+ )
905
+ self.shutdown_event.set()
665
906
 
666
- except KeyboardInterrupt:
667
- logger.info("KeyboardInterrupt received, stopping the Trade Copier ...")
668
- if self.shutdown_event:
669
- self.shutdown_event.set()
670
- break
671
- except Exception as e:
672
- self.log_error(e)
673
- if self.shutdown_event and self.shutdown_event.is_set():
674
- logger.error(
675
- "Error occurred after shutdown signaled, exiting loop."
676
- )
677
- break
907
+ self._log_message(
908
+ f"Copier instance for source @{self.source.get('login')} is shutting down."
909
+ )
910
+
911
+ def stop(self):
912
+ """
913
+ Stop the Trade Copier gracefully by setting the shutdown event.
914
+ """
915
+ if self._running:
916
+ self._log_message(
917
+ f"Signaling stop for Trade Copier on source account @{self.source.get('login')}..."
918
+ )
919
+ self._running = False
920
+ self.shutdown_event.set()
921
+ self._log_message("Trade Copier stopped successfully.")
922
+
923
+
924
+ def copier_worker_process(
925
+ source_config: dict,
926
+ destination_config: dict,
927
+ sleeptime: float,
928
+ start_time: str,
929
+ end_time: str,
930
+ /,
931
+ custom_logger=None,
932
+ shutdown_event=None,
933
+ log_queue=None,
934
+ ):
935
+ """A top-level worker function for handling a single source-to-destination copy task.
678
936
 
679
- # Check shutdown event before sleeping
680
- if self.shutdown_event and self.shutdown_event.is_set():
681
- logger.info("Shutdown event checked before sleep, exiting.")
682
- break
683
- time.sleep(self.sleeptime)
684
- logger.info("Trade Copier has shut down.")
937
+ This function is the cornerstone of the robust, multi-process architecture. It is
938
+ designed to be the `target` of a `multiprocessing.Process`. By being a top-level
939
+ function, it avoids pickling issues on Windows and ensures that each copy task
940
+ runs in a completely isolated process.
941
+
942
+ A controller (like a GUI or a master script) should spawn one process with this
943
+ target for each destination account it needs to manage.
944
+
945
+ Args:
946
+ source_config (dict): Configuration dictionary for the source account.
947
+ Must contain 'login', 'password', 'server', and 'path'.
948
+ destination_config (dict): Configuration dictionary for a *single*
949
+ destination account.
950
+ sleeptime (float): The time in seconds to wait between copy cycles.
951
+ start_time (str): The time of day to start copying (e.g., "08:00").
952
+ end_time (str): The time of day to stop copying (e.g., "22:00").
953
+ custom_logger: An optional custom logger instance.
954
+ shutdown_event (multiprocessing.Event): An event object that, when set,
955
+ will signal this process to terminate gracefully.
956
+ log_queue (multiprocessing.Queue): A queue for sending log messages back
957
+ to the parent process in a thread-safe manner.
958
+ """
959
+ copier = TradeCopier(
960
+ source_config,
961
+ [destination_config],
962
+ sleeptime=sleeptime,
963
+ start_time=start_time,
964
+ end_time=end_time,
965
+ custom_logger=custom_logger,
966
+ shutdown_event=shutdown_event,
967
+ log_queue=log_queue,
968
+ )
969
+ copier.start_copy_process(destination_config)
685
970
 
686
971
 
687
972
  def RunCopier(
@@ -690,30 +975,89 @@ def RunCopier(
690
975
  sleeptime: float,
691
976
  start_time: str,
692
977
  end_time: str,
693
- shutdown_event=None,
978
+ /,
694
979
  custom_logger=None,
980
+ shutdown_event=None,
981
+ log_queue=None,
695
982
  ):
983
+ """Initializes and runs a TradeCopier instance in a single process.
984
+
985
+ This function serves as a straightforward wrapper to start a copying session
986
+ that handles one source account and one or more destination accounts
987
+ *sequentially* within the same thread. It does not create any new processes itself.
988
+
989
+ This is useful for:
990
+ - Simpler, command-line based use cases.
991
+ - Scenarios where parallelism is not required.
992
+ - As the target for `RunMultipleCopier`, where each process handles a
993
+ full source-to-destinations session.
994
+
995
+ Args:
996
+ source (dict): Configuration dictionary for the source account.
997
+ destinations (list): A list of configuration dictionaries, one for each
998
+ destination account to be processed sequentially.
999
+ sleeptime (float): The time in seconds to wait after completing a full
1000
+ cycle through all destinations.
1001
+ start_time (str): The time of day to start copying (e.g., "08:00").
1002
+ end_time (str): The time of day to stop copying (e.g., "22:00").
1003
+ custom_logger: An optional custom logger instance.
1004
+ shutdown_event (multiprocessing.Event): An event to signal shutdown.
1005
+ log_queue (multiprocessing.Queue): A queue for log messages.
1006
+ """
696
1007
  copier = TradeCopier(
697
1008
  source,
698
1009
  destinations,
699
- sleeptime,
700
- start_time,
701
- end_time,
702
- shutdown_event,
703
- custom_logger,
1010
+ sleeptime=sleeptime,
1011
+ start_time=start_time,
1012
+ end_time=end_time,
1013
+ custom_logger=custom_logger,
1014
+ shutdown_event=shutdown_event,
1015
+ log_queue=log_queue,
704
1016
  )
705
1017
  copier.run()
706
1018
 
707
1019
 
708
1020
  def RunMultipleCopier(
709
1021
  accounts: List[dict],
710
- sleeptime: float = 0.1,
1022
+ sleeptime: float = 0.01,
711
1023
  start_delay: float = 1.0,
712
1024
  start_time: str = None,
713
1025
  end_time: str = None,
714
1026
  shutdown_event=None,
715
1027
  custom_logger=None,
1028
+ log_queue=None,
716
1029
  ):
1030
+ """Manages multiple, independent trade copying sessions in parallel.
1031
+
1032
+ This function acts as a high-level manager that takes a list of account
1033
+ setups and creates a separate, dedicated process for each one. Each process
1034
+ is responsible for copying from one source account to its associated list of
1035
+ destination accounts.
1036
+
1037
+ The parallelism occurs at the **source account level**. Within each spawned
1038
+ process, the destinations for that source are handled sequentially by `RunCopier`.
1039
+
1040
+ Example `accounts` structure:
1041
+ [
1042
+ { "source": {...}, "destinations": [{...}, {...}] }, # -> Process 1
1043
+ { "source": {...}, "destinations": [{...}] } # -> Process 2
1044
+ ]
1045
+
1046
+ Args:
1047
+ accounts (List[dict]): A list of account configurations. Each item in the
1048
+ list must be a dictionary with a 'source' key and a 'destinations' key.
1049
+ sleeptime (float): The sleep time passed down to each `RunCopier` process.
1050
+ start_delay (float): A delay in seconds between starting each new process.
1051
+ This helps prevent resource contention by staggering the initialization
1052
+ of multiple MetaTrader 5 terminals.
1053
+ start_time (str): The start time passed down to each `RunCopier` process.
1054
+ end_time (str): The end time passed down to each `RunCopier` process.
1055
+ shutdown_event (multiprocessing.Event): An event to signal shutdown to all
1056
+ child processes.
1057
+ custom_logger: An optional custom logger instance.
1058
+ log_queue (multiprocessing.Queue): A queue for aggregating log messages
1059
+ from all child processes.
1060
+ """
717
1061
  processes = []
718
1062
 
719
1063
  for account in accounts:
@@ -724,13 +1068,13 @@ def RunMultipleCopier(
724
1068
  logger.warning("Skipping account due to missing source or destinations.")
725
1069
  continue
726
1070
  paths = set([source.get("path")] + [dest.get("path") for dest in destinations])
727
- if len(paths) == 1:
1071
+ if len(paths) == 1 and len(destinations) >= 1:
728
1072
  logger.warning(
729
- "Skipping account due to same MetaTrader 5 installation path."
1073
+ "Skipping account: source and destination cannot share the same MetaTrader 5 terminal path."
730
1074
  )
731
1075
  continue
732
1076
  logger.info(f"Starting process for source account @{source.get('login')}")
733
- process = multiprocessing.Process(
1077
+ process = mp.Process(
734
1078
  target=RunCopier,
735
1079
  args=(
736
1080
  source,
@@ -739,7 +1083,11 @@ def RunMultipleCopier(
739
1083
  start_time,
740
1084
  end_time,
741
1085
  ),
742
- kwargs=dict(shutdown_event=shutdown_event, custom_logger=custom_logger),
1086
+ kwargs=dict(
1087
+ custom_logger=custom_logger,
1088
+ shutdown_event=shutdown_event,
1089
+ log_queue=log_queue,
1090
+ ),
743
1091
  )
744
1092
  processes.append(process)
745
1093
  process.start()
@@ -751,39 +1099,14 @@ def RunMultipleCopier(
751
1099
  process.join()
752
1100
 
753
1101
 
754
- def _strtodict(string: str) -> dict:
755
- string = string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
756
- if string.endswith(","):
757
- string = string[:-1]
758
- return dict(item.split(":") for item in string.split(","))
759
-
760
-
761
1102
  def _parse_symbols(section):
762
1103
  symbols: str = section.get("symbols")
763
1104
  symbols = symbols.strip().replace("\n", " ").replace('"""', "")
764
1105
  if symbols in ["all", "*"]:
765
1106
  section["symbols"] = symbols
766
- elif ":" in symbols:
767
- symbols = _strtodict(symbols)
768
- section["symbols"] = symbols
769
- elif " " in symbols and "," not in symbols:
770
- symbols = symbols.split()
771
- section["symbols"] = symbols
772
- elif "," in symbols:
773
- symbols = symbols.replace(" ", "").split(",")
774
- section["symbols"] = symbols
775
1107
  else:
776
- raise ValueError("""
777
- Invalid symbols format.
778
- You can use space or comma separated symbols in one line or multiple lines using triple quotes.
779
- You can also use a dictionary to map source symbols to destination symbols as shown below.
780
- Or if you want to copy all symbols, use "all" or "*".
781
-
782
- symbols = EURUSD, GBPUSD, USDJPY (space separated)
783
- symbols = EURUSD,GBPUSD,USDJPY (comma separated)
784
- symbols = EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i (dictionary)
785
- symbols = all (copy all symbols)
786
- symbols = * (copy all symbols) """)
1108
+ symbols = get_symbols_from_string(symbols)
1109
+ section["symbols"] = symbols
787
1110
 
788
1111
 
789
1112
  def config_copier(