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.
- bbstrader/__main__.py +5 -5
- bbstrader/btengine/data.py +3 -3
- bbstrader/btengine/strategy.py +9 -7
- bbstrader/core/data.py +3 -1
- bbstrader/core/scripts.py +62 -19
- bbstrader/metatrader/account.py +32 -18
- bbstrader/metatrader/copier.py +321 -171
- bbstrader/metatrader/rates.py +2 -2
- bbstrader/metatrader/trade.py +2 -2
- bbstrader/metatrader/utils.py +1 -5
- bbstrader/models/nlp.py +16 -11
- bbstrader/trading/execution.py +92 -41
- bbstrader/tseries.py +0 -2
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.dist-info}/METADATA +2 -2
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.dist-info}/RECORD +19 -19
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {bbstrader-0.3.2.dist-info → bbstrader-0.3.3.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,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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
724
|
-
source_orders, destination_orders
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
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
|
-
)
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
849
|
-
f"Source and destination accounts are on the same
|
|
850
|
-
f"
|
|
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.
|
|
855
|
-
f"Copy started for source @{self.source.get('login')} "
|
|
856
|
-
f"
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
880
|
-
f"Copier instance
|
|
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
|
-
|
|
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.
|
|
903
|
-
"
|
|
1050
|
+
self.log_message(
|
|
1051
|
+
"\nKeyboardInterrupt detected by main thread. Initiating shutdown..."
|
|
904
1052
|
)
|
|
905
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1071
|
+
self.log_message("Trade Copier stopped successfully.")
|
|
922
1072
|
|
|
923
1073
|
|
|
924
1074
|
def copier_worker_process(
|