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.
- bbstrader/__main__.py +15 -7
- bbstrader/apps/__init__.py +0 -0
- bbstrader/apps/_copier.py +664 -0
- bbstrader/btengine/data.py +3 -3
- bbstrader/btengine/strategy.py +165 -90
- bbstrader/core/data.py +3 -1
- bbstrader/core/scripts.py +62 -19
- bbstrader/core/utils.py +5 -3
- bbstrader/metatrader/account.py +196 -42
- bbstrader/metatrader/analysis.py +7 -5
- bbstrader/metatrader/copier.py +325 -171
- bbstrader/metatrader/rates.py +2 -2
- bbstrader/metatrader/scripts.py +15 -2
- bbstrader/metatrader/trade.py +2 -2
- bbstrader/metatrader/utils.py +65 -5
- bbstrader/models/ml.py +8 -5
- bbstrader/models/nlp.py +16 -11
- bbstrader/trading/execution.py +100 -48
- bbstrader/tseries.py +0 -2
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/METADATA +5 -5
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/RECORD +25 -23
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.4.dist-info}/top_level.txt +0 -0
bbstrader/metatrader/copier.py
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
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("
|
|
371
|
+
space = len("exception") # longest log name
|
|
361
372
|
self.log_queue.put(
|
|
362
|
-
f"{formatted} |{type.upper()} {' '*(space - len(type))}|
|
|
373
|
+
f"{formatted} |{type.upper()} {' '*(space - len(type))} | - {message}"
|
|
363
374
|
)
|
|
364
375
|
except Exception:
|
|
365
376
|
pass
|
|
366
377
|
else:
|
|
367
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
724
|
-
source_orders, destination_orders
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
883
|
+
dest_pos.symbol, destination, type="source"
|
|
884
|
+
)
|
|
885
|
+
actions.append(
|
|
886
|
+
(OrderAction.CLOSE, src_symbol, dest_pos, destination)
|
|
817
887
|
)
|
|
818
|
-
|
|
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
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
849
|
-
f"Source and destination accounts are on the same
|
|
850
|
-
f"
|
|
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.
|
|
855
|
-
f"Copy started for source @{self.source.get('login')} "
|
|
856
|
-
f"
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
880
|
-
f"Copier instance
|
|
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
|
-
|
|
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.
|
|
903
|
-
"
|
|
1054
|
+
self.log_message(
|
|
1055
|
+
"\nKeyboardInterrupt detected by main thread. Initiating shutdown..."
|
|
904
1056
|
)
|
|
905
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1075
|
+
self.log_message("Trade Copier stopped successfully.")
|
|
922
1076
|
|
|
923
1077
|
|
|
924
1078
|
def copier_worker_process(
|