bbstrader 0.3.2__py3-none-any.whl → 0.3.4__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,6 +1,9 @@
1
+ import concurrent.futures as cf
1
2
  import multiprocessing as mp
3
+ import threading
2
4
  import time
3
5
  from datetime import datetime
6
+ from enum import Enum
4
7
  from multiprocessing.synchronize import Event
5
8
  from pathlib import Path
6
9
  from typing import Dict, List, Literal, Tuple
@@ -20,10 +23,11 @@ except ImportError:
20
23
 
21
24
  __all__ = [
22
25
  "TradeCopier",
23
- "copier_worker_process",
24
26
  "RunCopier",
25
27
  "RunMultipleCopier",
26
28
  "config_copier",
29
+ "copier_worker_process",
30
+ "get_symbols_from_string",
27
31
  ]
28
32
 
29
33
  log.add(
@@ -48,6 +52,17 @@ ORDER_TYPE = {
48
52
  }
49
53
 
50
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
+
51
66
  def fix_lot(fixed):
52
67
  if fixed == 0 or fixed is None:
53
68
  raise ValueError("Fixed lot must be a number")
@@ -103,15 +118,11 @@ def fixed_lot(lot, symbol, destination) -> float:
103
118
  else:
104
119
  return _check_lot(round(lot), s_info)
105
120
 
106
-
107
- Mode = Literal["fix", "multiply", "percentage", "dynamic", "replicate"]
108
-
109
-
110
121
  def calculate_copy_lot(
111
122
  source_lot,
112
123
  symbol: str,
113
124
  destination: dict,
114
- mode: Mode = "dynamic",
125
+ mode: CopyMode = "dynamic",
115
126
  value=None,
116
127
  source_eqty: float = None,
117
128
  dest_eqty: float = None,
@@ -162,10 +173,10 @@ def get_symbols_from_string(symbols_string: str):
162
173
 
163
174
  def get_copy_symbols(destination: dict, source: dict) -> List[str] | Dict[str, str]:
164
175
  symbols = destination.get("symbols", "all")
165
- src_account = Account(**source)
166
- dest_account = Account(**destination)
167
- if symbols == "all" or symbols == "*":
176
+ if symbols == "all" or symbols == "*" or isinstance(symbols, list) :
177
+ src_account = Account(**source)
168
178
  src_symbols = src_account.get_symbols()
179
+ dest_account = Account(**destination)
169
180
  dest_symbols = dest_account.get_symbols()
170
181
  for s in src_symbols:
171
182
  if s not in dest_symbols:
@@ -308,7 +319,7 @@ class TradeCopier(object):
308
319
  end_time (str, optional): The time (HH:MM) from which the copier stop copying from the source.
309
320
  sleeptime (float, optional): The delay between each check from the source account.
310
321
  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.
322
+ shutdown_event (Any, Otional): Use to terminate the copy process when runs in a custum environment like web App or GUI.
312
323
  log_queue (multiprocessing.Queue, Optional): Use to send log to an external program, usefule in GUI apps
313
324
 
314
325
  Note:
@@ -349,7 +360,7 @@ class TradeCopier(object):
349
360
  for destination in self.destinations:
350
361
  destination["copy"] = destination.get("copy", True)
351
362
 
352
- def _log_message(self, message, type="info"):
363
+ def log_message(self, message, type="info"):
353
364
  if self.log_queue:
354
365
  try:
355
366
  now = datetime.now()
@@ -357,15 +368,14 @@ class TradeCopier(object):
357
368
  now.strftime("%Y-%m-%d %H:%M:%S.")
358
369
  + f"{int(now.microsecond / 1000):03d}"
359
370
  )
360
- space = len("warning") # longest log name
371
+ space = len("exception") # longest log name
361
372
  self.log_queue.put(
362
- f"{formatted} |{type.upper()} {' '*(space - len(type))}| - {message}"
373
+ f"{formatted} |{type.upper()} {' '*(space - len(type))} | - {message}"
363
374
  )
364
375
  except Exception:
365
376
  pass
366
377
  else:
367
- logmethod = logger.info if type == "info" else logger.error
368
- logmethod(message)
378
+ getattr(logger, type)(message)
369
379
 
370
380
  def log_error(self, e, symbol=None):
371
381
  if datetime.now().date() > self._last_session:
@@ -376,7 +386,7 @@ class TradeCopier(object):
376
386
  self.errors.add(error_msg)
377
387
  add_msg = f"SYMBOL={symbol}" if symbol else ""
378
388
  message = f"Error encountered: {error_msg}, {add_msg}"
379
- self._log_message(message, type="error")
389
+ self.log_message(message, type="error")
380
390
 
381
391
  def _validate_source(self):
382
392
  if not self.source_isunique:
@@ -387,23 +397,14 @@ class TradeCopier(object):
387
397
  "Non Unique source account must have a valide ID , (e.g., source['id'] = 1234)"
388
398
  )
389
399
 
390
- def add_destinations(self, destination_accounts: List[dict]):
391
- self.stop()
392
- destinations = destination_accounts.copy()
393
- for destination in destinations:
394
- destination["copy"] = True
395
- self.destinations.append(destination)
396
- self.run()
397
-
398
400
  def _get_magic(self, ticket: int) -> int:
399
401
  return int(str(self.source_id) + str(ticket)) if self.source_id >= 1 else ticket
400
402
 
401
403
  def _select_symbol(self, symbol: str, destination: dict):
402
404
  selected = Mt5.symbol_select(symbol, True)
403
405
  if not selected:
404
- self._log_message(
405
- f"Failed to select {destination.get('login')}::{symbol}, error code =",
406
- Mt5.last_error(),
406
+ self.log_message(
407
+ f"Failed to select {destination.get('login')}::{symbol}, error code = {Mt5.last_error()}",
407
408
  type="error",
408
409
  )
409
410
 
@@ -509,7 +510,7 @@ class TradeCopier(object):
509
510
  )
510
511
  action = ORDER_TYPE[trade.type][1]
511
512
  tick = Mt5.symbol_info_tick(symbol)
512
- price = tick.ask if trade.type == 0 else tick.bid
513
+ price = tick.bid if trade.type == 0 else tick.ask
513
514
  try:
514
515
  request = dict(
515
516
  symbol=symbol,
@@ -535,12 +536,12 @@ class TradeCopier(object):
535
536
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
536
537
  result = self._update_filling_type(request, result)
537
538
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
538
- self._log_message(
539
+ self.log_message(
539
540
  f"Copy {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
540
541
  f"to @{destination.get('login')}::{symbol}",
541
542
  )
542
543
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
543
- self._log_message(
544
+ self.log_message(
544
545
  f"Error copying {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
545
546
  f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
546
547
  type="error",
@@ -567,12 +568,14 @@ class TradeCopier(object):
567
568
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
568
569
  result = self._update_filling_type(request, result)
569
570
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
570
- self._log_message(
571
+ self.log_message(
571
572
  f"Modify {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol}, "
572
573
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
573
574
  )
575
+ if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
576
+ return
574
577
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
575
- self._log_message(
578
+ self.log_message(
576
579
  f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
577
580
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
578
581
  type="error",
@@ -589,12 +592,12 @@ class TradeCopier(object):
589
592
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
590
593
  result = self._update_filling_type(request, result)
591
594
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
592
- self._log_message(
595
+ self.log_message(
593
596
  f"Close {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
594
597
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
595
598
  )
596
599
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
597
- self._log_message(
600
+ self.log_message(
598
601
  f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
599
602
  f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
600
603
  type="error",
@@ -619,12 +622,14 @@ class TradeCopier(object):
619
622
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
620
623
  result = self._update_filling_type(request, result)
621
624
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
622
- self._log_message(
625
+ self.log_message(
623
626
  f"Modify {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
624
627
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
625
628
  )
629
+ if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
630
+ return
626
631
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
627
- self._log_message(
632
+ self.log_message(
628
633
  f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
629
634
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
630
635
  type="error",
@@ -652,13 +657,13 @@ class TradeCopier(object):
652
657
  result = self._update_filling_type(request, result)
653
658
 
654
659
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
655
- self._log_message(
660
+ self.log_message(
656
661
  f"Close {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
657
662
  f"on @{destination.get('login')}::{position.symbol}, "
658
663
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
659
664
  )
660
665
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
661
- self._log_message(
666
+ self.log_message(
662
667
  f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
663
668
  f"on @{destination.get('login')}::{position.symbol}, "
664
669
  f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
@@ -720,30 +725,40 @@ class TradeCopier(object):
720
725
  and int(ticket[: len(id)]) == self.source_id
721
726
  )
722
727
 
723
- def _copy_new_orders(self, destination):
724
- source_orders, destination_orders = self.get_orders(destination)
725
- # Check for new orders
726
- dest_ids = [order.magic for order in destination_orders]
728
+ def _get_new_orders(
729
+ self, source_orders, destination_orders, destination
730
+ ) -> List[Tuple]:
731
+ actions = []
732
+ dest_ids = {order.magic for order in destination_orders}
727
733
  for source_order in source_orders:
728
734
  if self._get_magic(source_order.ticket) not in dest_ids:
729
735
  if not self.slippage(source_order, destination):
730
- self.copy_new_order(source_order, destination)
736
+ actions.append((OrderAction.COPY_NEW, source_order, destination))
737
+ return actions
738
+
739
+ def _get_modified_orders(
740
+ self, source_orders, destination_orders, destination
741
+ ) -> List[Tuple]:
742
+ actions = []
743
+ dest_order_map = {order.magic: order for order in destination_orders}
731
744
 
732
- def _copy_modified_orders(self, destination):
733
- # Check for modified orders
734
- source_orders, destination_orders = self.get_orders(destination)
735
745
  for source_order in source_orders:
736
- for destination_order in destination_orders:
737
- if self._get_magic(source_order.ticket) == destination_order.magic:
738
- if self.isorder_modified(source_order, destination_order):
739
- ticket = destination_order.ticket
740
- symbol = destination_order.symbol
741
- self.modify_order(ticket, symbol, source_order, destination)
742
-
743
- def _copy_closed_orders(self, destination):
744
- # Check for closed orders
745
- source_orders, destination_orders = self.get_orders(destination)
746
- source_ids = [self._get_magic(order.ticket) for order in source_orders]
746
+ magic_id = self._get_magic(source_order.ticket)
747
+ if magic_id in dest_order_map:
748
+ destination_order = dest_order_map[magic_id]
749
+ if self.isorder_modified(source_order, destination_order):
750
+ ticket = destination_order.ticket
751
+ symbol = destination_order.symbol
752
+ actions.append(
753
+ (OrderAction.MODIFY, ticket, symbol, source_order, destination)
754
+ )
755
+ return actions
756
+
757
+ def _get_closed_orders(
758
+ self, source_orders, destination_orders, destination
759
+ ) -> List[Tuple]:
760
+ actions = []
761
+ source_ids = {self._get_magic(order.ticket) for order in source_orders}
747
762
  for destination_order in destination_orders:
748
763
  if destination_order.magic not in source_ids:
749
764
  if self.source_isunique or self._isvalide_magic(
@@ -752,173 +767,312 @@ class TradeCopier(object):
752
767
  src_symbol = self.get_copy_symbol(
753
768
  destination_order.symbol, destination, type="source"
754
769
  )
755
- self.remove_order(src_symbol, destination_order, destination)
770
+ actions.append(
771
+ (OrderAction.CLOSE, src_symbol, destination_order, destination)
772
+ )
773
+ return actions
774
+
775
+ def _get_orders_to_sync(
776
+ self, source_orders, destination_positions, destination
777
+ ) -> List[Tuple]:
778
+ actions = []
779
+ source_order_map = {
780
+ self._get_magic(order.ticket): order for order in source_orders
781
+ }
756
782
 
757
- def _sync_orders(self, destination):
758
- # Update orders
759
- _, destination_positions = self.get_positions(destination)
760
- source_orders, _ = self.get_orders(destination)
761
- for destination_position in destination_positions:
762
- for source_order in source_orders:
763
- if destination_position.magic == self._get_magic(source_order.ticket):
764
- self.remove_position(
765
- source_order.symbol, destination_position, destination
783
+ for dest_pos in destination_positions:
784
+ if dest_pos.magic in source_order_map:
785
+ source_order = source_order_map[dest_pos.magic]
786
+ actions.append(
787
+ (
788
+ OrderAction.SYNC_REMOVE,
789
+ source_order.symbol,
790
+ dest_pos,
791
+ destination,
766
792
  )
767
- if not self.slippage(source_order, destination):
768
- self.copy_new_order(source_order, destination)
793
+ )
794
+ if not self.slippage(source_order, destination):
795
+ actions.append((OrderAction.SYNC_ADD, source_order, destination))
796
+ return actions
769
797
 
770
- def copy_orders(self, destination: dict):
771
- what = self._copy_what(destination)
772
- if what not in ["all", "orders"]:
773
- return
774
- check_mt5_connection(**destination)
775
- self._copy_new_orders(destination)
776
- self._copy_modified_orders(destination)
777
- self._copy_closed_orders(destination)
778
- self._sync_positions(what, destination)
779
- self._sync_orders(destination)
798
+ def _execute_order_action(self, action_item: Tuple):
799
+ action_type, *args = action_item
800
+ try:
801
+ if action_type == OrderAction.COPY_NEW:
802
+ self.copy_new_order(*args)
803
+ elif action_type == OrderAction.MODIFY:
804
+ self.modify_order(*args)
805
+ elif action_type == OrderAction.CLOSE:
806
+ self.remove_order(*args)
807
+ elif action_type == OrderAction.SYNC_REMOVE:
808
+ self.remove_position(*args)
809
+ elif action_type == OrderAction.SYNC_ADD:
810
+ self.copy_new_order(*args)
811
+ else:
812
+ self.log_message(f"Warning: Unknown action type '{action_type.value}'")
813
+ except Exception as e:
814
+ self.log_error(
815
+ f"Error executing action {action_type.value} with args {args}: {e}"
816
+ )
780
817
 
781
- def _copy_new_positions(self, destination):
782
- source_positions, destination_positions = self.get_positions(destination)
783
- # Check for new positions
784
- dest_ids = [pos.magic for pos in destination_positions]
785
- for source_position in source_positions:
786
- if self._get_magic(source_position.ticket) not in dest_ids:
787
- if not self.slippage(source_position, destination):
788
- self.copy_new_position(source_position, destination)
789
-
790
- def _copy_modified_positions(self, destination):
791
- # Check for modified positions
792
- source_positions, destination_positions = self.get_positions(destination)
793
- for source_position in source_positions:
794
- for destination_position in destination_positions:
795
- if (
796
- self._get_magic(source_position.ticket)
797
- == destination_position.magic
798
- ):
799
- if self.isposition_modified(source_position, destination_position):
800
- ticket = destination_position.ticket
801
- symbol = destination_position.symbol
802
- self.modify_position(
803
- ticket, symbol, source_position, destination
804
- )
818
+ def process_all_orders(self, destination, max_workers=10):
819
+ source_orders, destination_orders = self.get_orders(destination)
820
+ _, destination_positions = self.get_positions(destination)
805
821
 
806
- def _copy_closed_position(self, destination):
807
- # Check for closed positions
808
- source_positions, destination_positions = self.get_positions(destination)
809
- source_ids = [self._get_magic(pos.ticket) for pos in source_positions]
810
- for destination_position in destination_positions:
811
- if destination_position.magic not in source_ids:
812
- if self.source_isunique or self._isvalide_magic(
813
- destination_position.magic
814
- ):
822
+ orders_actions = []
823
+ orders_actions.extend(
824
+ self._get_new_orders(source_orders, destination_orders, destination)
825
+ )
826
+ orders_actions.extend(
827
+ self._get_modified_orders(source_orders, destination_orders, destination)
828
+ )
829
+ orders_actions.extend(
830
+ self._get_closed_orders(source_orders, destination_orders, destination)
831
+ )
832
+ orders_actions.extend(
833
+ self._get_orders_to_sync(source_orders, destination_positions, destination)
834
+ )
835
+
836
+ if not orders_actions:
837
+ return
838
+ with cf.ThreadPoolExecutor(max_workers=max_workers) as executor:
839
+ list(executor.map(self._execute_order_action, orders_actions))
840
+
841
+ def _get_new_positions(
842
+ self, source_positions, destination_positions, destination
843
+ ) -> List[Tuple]:
844
+ actions = []
845
+ dest_ids = {pos.magic for pos in destination_positions}
846
+ for source_pos in source_positions:
847
+ if self._get_magic(source_pos.ticket) not in dest_ids:
848
+ if not self.slippage(source_pos, destination):
849
+ actions.append((OrderAction.COPY_NEW, source_pos, destination))
850
+ return actions
851
+
852
+ def _get_modified_positions(
853
+ self, source_positions, destination_positions, destination
854
+ ) -> List[Tuple]:
855
+ actions = []
856
+ dest_pos_map = {pos.magic: pos for pos in destination_positions}
857
+
858
+ for source_pos in source_positions:
859
+ magic_id = self._get_magic(source_pos.ticket)
860
+ if magic_id in dest_pos_map:
861
+ dest_pos = dest_pos_map[magic_id]
862
+ if self.isposition_modified(source_pos, dest_pos):
863
+ actions.append(
864
+ (
865
+ OrderAction.MODIFY,
866
+ dest_pos.ticket,
867
+ dest_pos.symbol,
868
+ source_pos,
869
+ destination,
870
+ )
871
+ )
872
+ return actions
873
+
874
+ def _get_closed_positions(
875
+ self, source_positions, destination_positions, destination
876
+ ) -> List[Tuple]:
877
+ actions = []
878
+ source_ids = {self._get_magic(pos.ticket) for pos in source_positions}
879
+ for dest_pos in destination_positions:
880
+ if dest_pos.magic not in source_ids:
881
+ if self.source_isunique or self._isvalide_magic(dest_pos.magic):
815
882
  src_symbol = self.get_copy_symbol(
816
- destination_position.symbol, destination, type="source"
883
+ dest_pos.symbol, destination, type="source"
884
+ )
885
+ actions.append(
886
+ (OrderAction.CLOSE, src_symbol, dest_pos, destination)
817
887
  )
818
- self.remove_position(src_symbol, destination_position, destination)
888
+ return actions
889
+
890
+ def _get_positions_to_sync(
891
+ self, source_positions, destination_orders, destination
892
+ ) -> List[Tuple]:
893
+ actions = []
894
+ dest_order_map = {order.magic: order for order in destination_orders}
895
+
896
+ for source_pos in source_positions:
897
+ magic_id = self._get_magic(source_pos.ticket)
898
+ if magic_id in dest_order_map:
899
+ dest_order = dest_order_map[magic_id]
900
+ # Action 1: Always remove the corresponding order
901
+ actions.append(
902
+ (
903
+ OrderAction.SYNC_REMOVE,
904
+ source_pos.symbol,
905
+ dest_order,
906
+ destination,
907
+ )
908
+ )
909
+
910
+ # Action 2: Potentially copy a new position
911
+ if self._copy_what(destination) in ["all", "positions"]:
912
+ if not self.slippage(source_pos, destination):
913
+ actions.append((OrderAction.SYNC_ADD, source_pos, destination))
914
+ return actions
819
915
 
820
- def _sync_positions(self, what, destination):
821
- # Update postions
822
- source_positions, _ = self.get_positions(destination)
916
+ def _execute_position_action(self, action_item: Tuple):
917
+ """A single worker task that executes one action for either Orders or Positions."""
918
+ action_type, *args = action_item
919
+ try:
920
+ if action_type == OrderAction.COPY_NEW:
921
+ self.copy_new_position(*args)
922
+ elif action_type == OrderAction.MODIFY:
923
+ self.modify_position(*args)
924
+ elif action_type == OrderAction.CLOSE:
925
+ self.remove_position(*args)
926
+ elif action_type == OrderAction.SYNC_REMOVE:
927
+ self.remove_order(*args)
928
+ elif action_type == OrderAction.SYNC_ADD:
929
+ self.copy_new_position(*args)
930
+ else:
931
+ self.log_message(f"Warning: Unknown action type '{action_type.value}'")
932
+ except Exception as e:
933
+ self.log_error(
934
+ f"Error executing action {action_type.value} with args {args}: {e}"
935
+ )
936
+
937
+ def process_all_positions(self, destination, max_workers=20):
938
+ source_positions, destination_positions = self.get_positions(destination)
823
939
  _, 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)
940
+
941
+ positions_actions = []
942
+ positions_actions.extend(
943
+ self._get_new_positions(
944
+ source_positions, destination_positions, destination
945
+ )
946
+ )
947
+ positions_actions.extend(
948
+ self._get_modified_positions(
949
+ source_positions, destination_positions, destination
950
+ )
951
+ )
952
+ positions_actions.extend(
953
+ self._get_closed_positions(
954
+ source_positions, destination_positions, destination
955
+ )
956
+ )
957
+ positions_actions.extend(
958
+ self._get_positions_to_sync(
959
+ source_positions, destination_orders, destination
960
+ )
961
+ )
962
+
963
+ if not positions_actions:
964
+ return
965
+ with cf.ThreadPoolExecutor(max_workers=max_workers) as executor:
966
+ list(executor.map(self._execute_position_action, positions_actions))
967
+
968
+ def copy_orders(self, destination: dict):
969
+ if self._copy_what(destination) not in ["all", "orders"]:
970
+ return
971
+ check_mt5_connection(**destination)
972
+ self.process_all_orders(destination)
833
973
 
834
974
  def copy_positions(self, destination: dict):
835
- what = self._copy_what(destination)
836
- if what not in ["all", "positions"]:
975
+ if self._copy_what(destination) not in ["all", "positions"]:
837
976
  return
838
977
  check_mt5_connection(**destination)
839
- self._copy_new_positions(destination)
840
- self._copy_modified_positions(destination)
841
- self._copy_closed_position(destination)
978
+ self.process_all_positions(destination)
842
979
 
843
980
  def start_copy_process(self, destination: dict):
844
981
  """
845
- Worker process: copy orders and positions for a single destination account.
982
+ Worker process: copies orders and positions concurrently for a single destination account.
846
983
  """
847
984
  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."
985
+ self.log_message(
986
+ f"Source and destination accounts are on the same MetaTrader 5 "
987
+ f"installation ({self.source.get('path')}), which is not allowed."
851
988
  )
852
989
  return
853
990
 
854
- self._log_message(
855
- f"Copy started for source @{self.source.get('login')} "
856
- f" and destination @{destination.get('login')}"
991
+ self.log_message(
992
+ f"Copy process started for source @{self.source.get('login')} "
993
+ f"and destination @{destination.get('login')}"
857
994
  )
858
995
  while not self.shutdown_event.is_set():
859
996
  try:
860
997
  self.copy_positions(destination)
861
998
  self.copy_orders(destination)
862
999
  except KeyboardInterrupt:
863
- self._log_message(
864
- "KeyboardInterrupt received, stopping the Trade Copier ..."
1000
+ self.log_message(
1001
+ "KeyboardInterrupt received, stopping the Trade Copier..."
865
1002
  )
866
1003
  self.stop()
867
1004
  except Exception as e:
868
- self.log_error(e)
1005
+ self.log_error(f"An error occurred during the sync cycle: {e}")
1006
+ time.sleep(self.sleeptime)
869
1007
 
870
- self._log_message(
1008
+ self.log_message(
871
1009
  f"Process exiting for destination @{destination.get('login')} due to shutdown event."
872
1010
  )
873
1011
 
874
1012
  def run(self):
875
1013
  """
876
- Entry point to start the copier.
877
- This will loop through the destinations it was given and process them.
1014
+ Entry point: Starts a dedicated worker thread for EACH destination account to run concurrently.
878
1015
  """
879
- self._log_message(
880
- f"Copier instance started for source @{self.source.get('login')}"
1016
+ self.log_message(
1017
+ f"Main Copier instance starting for source @{self.source.get('login')}."
1018
+ )
1019
+ self.log_message(
1020
+ f"Found {len(self.destinations)} destination accounts to process in parallel."
881
1021
  )
1022
+ if len(set([d.get("path") for d in self.destinations])) < len(
1023
+ self.destinations
1024
+ ):
1025
+ self.log_message(
1026
+ "Two or more destination accounts have the same Terminal path, which is not allowed."
1027
+ )
1028
+ return
1029
+
1030
+ worker_threads = []
1031
+
1032
+ for destination in self.destinations:
1033
+ self.log_message(
1034
+ f"Creating worker thread for destination @{destination.get('login')}"
1035
+ )
1036
+ try:
1037
+ thread = threading.Thread(
1038
+ target=self.start_copy_process,
1039
+ args=(destination,),
1040
+ name=f"Worker-{destination.get('login')}",
1041
+ )
1042
+ worker_threads.append(thread)
1043
+ thread.start()
1044
+ except Exception as e:
1045
+ self.log_error(
1046
+ f"Error executing thread Worker-{destination.get('login')} : {e}"
1047
+ )
1048
+
1049
+ self.log_message(f"All {len(worker_threads)} worker threads have been started.")
882
1050
  try:
883
1051
  while not self.shutdown_event.is_set():
884
- for destination in self.destinations:
885
- if self.shutdown_event.is_set():
886
- break
887
-
888
- if destination.get("path") == self.source.get("path"):
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
- )
893
- continue
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
-
1052
+ time.sleep(1)
901
1053
  except KeyboardInterrupt:
902
- self._log_message(
903
- "KeyboardInterrupt received, stopping the copier instance..."
1054
+ self.log_message(
1055
+ "\nKeyboardInterrupt detected by main thread. Initiating shutdown..."
904
1056
  )
905
- self.shutdown_event.set()
1057
+ finally:
1058
+ self.stop()
1059
+ self.log_message("Waiting for all worker threads to complete...")
1060
+ for thread in worker_threads:
1061
+ thread.join()
906
1062
 
907
- self._log_message(
908
- f"Copier instance for source @{self.source.get('login')} is shutting down."
909
- )
1063
+ self.log_message("All worker threads have shut down. Copier exiting.")
910
1064
 
911
1065
  def stop(self):
912
1066
  """
913
1067
  Stop the Trade Copier gracefully by setting the shutdown event.
914
1068
  """
915
1069
  if self._running:
916
- self._log_message(
1070
+ self.log_message(
917
1071
  f"Signaling stop for Trade Copier on source account @{self.source.get('login')}..."
918
1072
  )
919
1073
  self._running = False
920
1074
  self.shutdown_event.set()
921
- self._log_message("Trade Copier stopped successfully.")
1075
+ self.log_message("Trade Copier stopped successfully.")
922
1076
 
923
1077
 
924
1078
  def copier_worker_process(