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