bbstrader 0.3.2__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,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,12 @@ 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
  )
574
575
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
575
- self._log_message(
576
+ self.log_message(
576
577
  f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
577
578
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
578
579
  type="error",
@@ -589,12 +590,12 @@ class TradeCopier(object):
589
590
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
590
591
  result = self._update_filling_type(request, result)
591
592
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
592
- self._log_message(
593
+ self.log_message(
593
594
  f"Close {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
594
595
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
595
596
  )
596
597
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
597
- self._log_message(
598
+ self.log_message(
598
599
  f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
599
600
  f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
600
601
  type="error",
@@ -619,12 +620,12 @@ class TradeCopier(object):
619
620
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
620
621
  result = self._update_filling_type(request, result)
621
622
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
622
- self._log_message(
623
+ self.log_message(
623
624
  f"Modify {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
624
625
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
625
626
  )
626
627
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
627
- self._log_message(
628
+ self.log_message(
628
629
  f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
629
630
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
630
631
  type="error",
@@ -652,13 +653,13 @@ class TradeCopier(object):
652
653
  result = self._update_filling_type(request, result)
653
654
 
654
655
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
655
- self._log_message(
656
+ self.log_message(
656
657
  f"Close {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
657
658
  f"on @{destination.get('login')}::{position.symbol}, "
658
659
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
659
660
  )
660
661
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
661
- self._log_message(
662
+ self.log_message(
662
663
  f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
663
664
  f"on @{destination.get('login')}::{position.symbol}, "
664
665
  f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
@@ -720,30 +721,40 @@ class TradeCopier(object):
720
721
  and int(ticket[: len(id)]) == self.source_id
721
722
  )
722
723
 
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]
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}
727
729
  for source_order in source_orders:
728
730
  if self._get_magic(source_order.ticket) not in dest_ids:
729
731
  if not self.slippage(source_order, destination):
730
- 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}
731
740
 
732
- def _copy_modified_orders(self, destination):
733
- # Check for modified orders
734
- source_orders, destination_orders = self.get_orders(destination)
735
741
  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]
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}
747
758
  for destination_order in destination_orders:
748
759
  if destination_order.magic not in source_ids:
749
760
  if self.source_isunique or self._isvalide_magic(
@@ -752,173 +763,312 @@ class TradeCopier(object):
752
763
  src_symbol = self.get_copy_symbol(
753
764
  destination_order.symbol, destination, type="source"
754
765
  )
755
- self.remove_order(src_symbol, destination_order, destination)
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
+ }
756
778
 
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
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,
766
788
  )
767
- if not self.slippage(source_order, destination):
768
- self.copy_new_order(source_order, destination)
789
+ )
790
+ if not self.slippage(source_order, destination):
791
+ actions.append((OrderAction.SYNC_ADD, source_order, destination))
792
+ return actions
769
793
 
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)
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
+ )
780
813
 
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
- )
814
+ def process_all_orders(self, destination, max_workers=10):
815
+ source_orders, destination_orders = self.get_orders(destination)
816
+ _, destination_positions = self.get_positions(destination)
805
817
 
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
- ):
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
+ )
831
+
832
+ if not orders_actions:
833
+ return
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,
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):
815
878
  src_symbol = self.get_copy_symbol(
816
- destination_position.symbol, destination, type="source"
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,
817
903
  )
818
- self.remove_position(src_symbol, destination_position, destination)
904
+ )
905
+
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
819
911
 
820
- def _sync_positions(self, what, destination):
821
- # Update postions
822
- source_positions, _ = self.get_positions(destination)
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):
934
+ source_positions, destination_positions = self.get_positions(destination)
823
935
  _, 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)
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)
833
969
 
834
970
  def copy_positions(self, destination: dict):
835
- what = self._copy_what(destination)
836
- if what not in ["all", "positions"]:
971
+ if self._copy_what(destination) not in ["all", "positions"]:
837
972
  return
838
973
  check_mt5_connection(**destination)
839
- self._copy_new_positions(destination)
840
- self._copy_modified_positions(destination)
841
- self._copy_closed_position(destination)
974
+ self.process_all_positions(destination)
842
975
 
843
976
  def start_copy_process(self, destination: dict):
844
977
  """
845
- Worker process: copy orders and positions for a single destination account.
978
+ Worker process: copies orders and positions concurrently for a single destination account.
846
979
  """
847
980
  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."
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."
851
984
  )
852
985
  return
853
986
 
854
- self._log_message(
855
- f"Copy started for source @{self.source.get('login')} "
856
- f" and destination @{destination.get('login')}"
987
+ self.log_message(
988
+ f"Copy process started for source @{self.source.get('login')} "
989
+ f"and destination @{destination.get('login')}"
857
990
  )
858
991
  while not self.shutdown_event.is_set():
859
992
  try:
860
993
  self.copy_positions(destination)
861
994
  self.copy_orders(destination)
862
995
  except KeyboardInterrupt:
863
- self._log_message(
864
- "KeyboardInterrupt received, stopping the Trade Copier ..."
996
+ self.log_message(
997
+ "KeyboardInterrupt received, stopping the Trade Copier..."
865
998
  )
866
999
  self.stop()
867
1000
  except Exception as e:
868
- self.log_error(e)
1001
+ self.log_error(f"An error occurred during the sync cycle: {e}")
1002
+ time.sleep(self.sleeptime)
869
1003
 
870
- self._log_message(
1004
+ self.log_message(
871
1005
  f"Process exiting for destination @{destination.get('login')} due to shutdown event."
872
1006
  )
873
1007
 
874
1008
  def run(self):
875
1009
  """
876
- Entry point to start the copier.
877
- This will loop through the destinations it was given and process them.
1010
+ Entry point: Starts a dedicated worker thread for EACH destination account to run concurrently.
878
1011
  """
879
- self._log_message(
880
- f"Copier instance started for source @{self.source.get('login')}"
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."
881
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.")
882
1046
  try:
883
1047
  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
-
1048
+ time.sleep(1)
901
1049
  except KeyboardInterrupt:
902
- self._log_message(
903
- "KeyboardInterrupt received, stopping the copier instance..."
1050
+ self.log_message(
1051
+ "\nKeyboardInterrupt detected by main thread. Initiating shutdown..."
904
1052
  )
905
- self.shutdown_event.set()
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()
906
1058
 
907
- self._log_message(
908
- f"Copier instance for source @{self.source.get('login')} is shutting down."
909
- )
1059
+ self.log_message("All worker threads have shut down. Copier exiting.")
910
1060
 
911
1061
  def stop(self):
912
1062
  """
913
1063
  Stop the Trade Copier gracefully by setting the shutdown event.
914
1064
  """
915
1065
  if self._running:
916
- self._log_message(
1066
+ self.log_message(
917
1067
  f"Signaling stop for Trade Copier on source account @{self.source.get('login')}..."
918
1068
  )
919
1069
  self._running = False
920
1070
  self.shutdown_event.set()
921
- self._log_message("Trade Copier stopped successfully.")
1071
+ self.log_message("Trade Copier stopped successfully.")
922
1072
 
923
1073
 
924
1074
  def copier_worker_process(