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