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