bbstrader 0.3.0__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 +19 -13
- bbstrader/btengine/backtest.py +7 -8
- bbstrader/btengine/execution.py +2 -2
- bbstrader/btengine/strategy.py +68 -17
- bbstrader/config.py +2 -2
- bbstrader/core/data.py +92 -29
- bbstrader/metatrader/account.py +81 -16
- bbstrader/metatrader/copier.py +594 -195
- bbstrader/metatrader/risk.py +1 -0
- bbstrader/metatrader/scripts.py +53 -13
- bbstrader/metatrader/trade.py +79 -67
- bbstrader/metatrader/utils.py +3 -0
- bbstrader/models/__init__.py +0 -1
- bbstrader/models/ml.py +55 -26
- bbstrader/models/nlp.py +182 -74
- bbstrader/models/optimization.py +1 -1
- bbstrader/models/risk.py +16 -386
- bbstrader/trading/execution.py +70 -41
- bbstrader/trading/strategies.py +9 -592
- bbstrader/tseries.py +39 -709
- {bbstrader-0.3.0.dist-info → bbstrader-0.3.2.dist-info}/METADATA +36 -44
- bbstrader-0.3.2.dist-info/RECORD +47 -0
- bbstrader-0.3.0.dist-info/RECORD +0 -47
- {bbstrader-0.3.0.dist-info → bbstrader-0.3.2.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.0.dist-info → bbstrader-0.3.2.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.0.dist-info → bbstrader-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {bbstrader-0.3.0.dist-info → bbstrader-0.3.2.dist-info}/top_level.txt +0 -0
bbstrader/metatrader/copier.py
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import multiprocessing
|
|
1
|
+
import multiprocessing as mp
|
|
2
2
|
import time
|
|
3
3
|
from datetime import datetime
|
|
4
|
+
from multiprocessing.synchronize import Event
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Dict, List, Literal, Tuple
|
|
6
7
|
|
|
7
|
-
from loguru import logger
|
|
8
|
+
from loguru import logger as log
|
|
8
9
|
|
|
9
10
|
from bbstrader.config import BBSTRADER_DIR
|
|
10
11
|
from bbstrader.metatrader.account import Account, check_mt5_connection
|
|
11
|
-
from bbstrader.metatrader.trade import
|
|
12
|
+
from bbstrader.metatrader.trade import FILLING_TYPE
|
|
12
13
|
from bbstrader.metatrader.utils import TradeOrder, TradePosition, trade_retcode_message
|
|
13
14
|
|
|
14
15
|
try:
|
|
@@ -17,15 +18,34 @@ except ImportError:
|
|
|
17
18
|
import bbstrader.compat # noqa: F401
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
__all__ = [
|
|
21
|
+
__all__ = [
|
|
22
|
+
"TradeCopier",
|
|
23
|
+
"copier_worker_process",
|
|
24
|
+
"RunCopier",
|
|
25
|
+
"RunMultipleCopier",
|
|
26
|
+
"config_copier",
|
|
27
|
+
]
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
logger.add(
|
|
29
|
+
log.add(
|
|
24
30
|
f"{BBSTRADER_DIR}/logs/copier.log",
|
|
25
31
|
enqueue=True,
|
|
26
32
|
level="INFO",
|
|
27
33
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
28
34
|
)
|
|
35
|
+
global logger
|
|
36
|
+
logger = log
|
|
37
|
+
|
|
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
|
+
}
|
|
29
49
|
|
|
30
50
|
|
|
31
51
|
def fix_lot(fixed):
|
|
@@ -113,34 +133,58 @@ def calculate_copy_lot(
|
|
|
113
133
|
raise ValueError("Invalid mode selected")
|
|
114
134
|
|
|
115
135
|
|
|
116
|
-
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]:
|
|
117
164
|
symbols = destination.get("symbols", "all")
|
|
118
|
-
|
|
165
|
+
src_account = Account(**source)
|
|
166
|
+
dest_account = Account(**destination)
|
|
119
167
|
if symbols == "all" or symbols == "*":
|
|
120
|
-
|
|
168
|
+
src_symbols = src_account.get_symbols()
|
|
169
|
+
dest_symbols = dest_account.get_symbols()
|
|
170
|
+
for s in src_symbols:
|
|
171
|
+
if s not in dest_symbols:
|
|
172
|
+
err_msg = (
|
|
173
|
+
f"To use 'all' or '*', Source account@{src_account.number} "
|
|
174
|
+
f"and destination account@{dest_account.number} "
|
|
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"
|
|
179
|
+
)
|
|
180
|
+
raise ValueError(err_msg)
|
|
181
|
+
return dest_symbols
|
|
121
182
|
elif isinstance(symbols, (list, dict)):
|
|
122
183
|
return symbols
|
|
123
184
|
elif isinstance(symbols, str):
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return symbols.split()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def get_copy_symbol(symbol, destination: dict = None, type="destination"):
|
|
131
|
-
symbols = get_copy_symbols(destination)
|
|
132
|
-
if isinstance(symbols, list):
|
|
133
|
-
if symbol in symbols:
|
|
134
|
-
return symbol
|
|
135
|
-
if isinstance(symbols, dict):
|
|
136
|
-
if type == "destination":
|
|
137
|
-
if symbol in symbols.keys():
|
|
138
|
-
return symbols[symbol]
|
|
139
|
-
if type == "source":
|
|
140
|
-
for k, v in symbols.items():
|
|
141
|
-
if v == symbol:
|
|
142
|
-
return k
|
|
143
|
-
raise ValueError(f"Symbol {symbol} not found in {type} account")
|
|
185
|
+
return get_symbols_from_string(symbols)
|
|
186
|
+
else:
|
|
187
|
+
raise ValueError("Invalide symbols provided")
|
|
144
188
|
|
|
145
189
|
|
|
146
190
|
class TradeCopier(object):
|
|
@@ -154,20 +198,39 @@ class TradeCopier(object):
|
|
|
154
198
|
|
|
155
199
|
__slots__ = (
|
|
156
200
|
"source",
|
|
201
|
+
"source_id",
|
|
202
|
+
"source_isunique",
|
|
157
203
|
"destinations",
|
|
158
204
|
"errors",
|
|
159
205
|
"sleeptime",
|
|
160
206
|
"start_time",
|
|
161
207
|
"end_time",
|
|
208
|
+
"shutdown_event",
|
|
209
|
+
"custom_logger",
|
|
210
|
+
"log_queue",
|
|
211
|
+
"_last_session",
|
|
212
|
+
"_running",
|
|
162
213
|
)
|
|
163
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
|
|
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,
|
|
230
|
+
*,
|
|
231
|
+
custom_logger=None,
|
|
232
|
+
shutdown_event=None,
|
|
233
|
+
log_queue=None,
|
|
171
234
|
):
|
|
172
235
|
"""
|
|
173
236
|
Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
|
|
@@ -183,7 +246,12 @@ class TradeCopier(object):
|
|
|
183
246
|
- `password`: The account password (string).
|
|
184
247
|
- `server`: The server address (string), e.g., "Broker-Demo".
|
|
185
248
|
- `path`: The path to the MetaTrader 5 installation directory (string).
|
|
186
|
-
-
|
|
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.
|
|
187
255
|
|
|
188
256
|
destinations (List[dict]):
|
|
189
257
|
A list of dictionaries, where each dictionary represents a destination trading account to which
|
|
@@ -235,23 +303,109 @@ class TradeCopier(object):
|
|
|
235
303
|
sleeptime (float, optional):
|
|
236
304
|
The time interval in seconds between each iteration of the trade copying process.
|
|
237
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
|
+
|
|
238
314
|
Note:
|
|
239
315
|
The source account and the destination accounts must be connected to different MetaTrader 5 platforms.
|
|
240
316
|
you can copy the initial installation of MetaTrader 5 to a different directory and rename it to create a new instance
|
|
241
317
|
Then you can connect destination accounts to the new instance while the source account is connected to the original instance.
|
|
242
318
|
"""
|
|
243
319
|
self.source = source
|
|
320
|
+
self.source_id = source.get("id", 0)
|
|
321
|
+
self.source_isunique = source.get("unique", True)
|
|
244
322
|
self.destinations = destinations
|
|
245
323
|
self.sleeptime = sleeptime
|
|
246
324
|
self.start_time = start_time
|
|
247
325
|
self.end_time = end_time
|
|
248
326
|
self.errors = set()
|
|
327
|
+
self.log_queue = log_queue
|
|
328
|
+
self._add_logger(custom_logger)
|
|
329
|
+
self._validate_source()
|
|
249
330
|
self._add_copy()
|
|
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
|
|
341
|
+
|
|
342
|
+
def _add_logger(self, custom_logger):
|
|
343
|
+
if custom_logger:
|
|
344
|
+
global logger
|
|
345
|
+
logger = custom_logger
|
|
250
346
|
|
|
251
347
|
def _add_copy(self):
|
|
252
|
-
self.source["copy"] = True
|
|
348
|
+
self.source["copy"] = self.source.get("copy", True)
|
|
253
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:
|
|
254
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
|
+
)
|
|
255
409
|
|
|
256
410
|
def source_orders(self, symbol=None):
|
|
257
411
|
check_mt5_connection(**self.source)
|
|
@@ -269,8 +423,23 @@ class TradeCopier(object):
|
|
|
269
423
|
check_mt5_connection(**destination)
|
|
270
424
|
return Account(**destination).get_positions(symbol=symbol)
|
|
271
425
|
|
|
426
|
+
def get_copy_symbol(self, symbol, destination: dict = None, type="destination"):
|
|
427
|
+
symbols = get_copy_symbols(destination, self.source)
|
|
428
|
+
if isinstance(symbols, list):
|
|
429
|
+
if symbol in symbols:
|
|
430
|
+
return symbol
|
|
431
|
+
if isinstance(symbols, dict):
|
|
432
|
+
if type == "destination":
|
|
433
|
+
if symbol in symbols.keys():
|
|
434
|
+
return symbols[symbol]
|
|
435
|
+
if type == "source":
|
|
436
|
+
for k, v in symbols.items():
|
|
437
|
+
if v == symbol:
|
|
438
|
+
return k
|
|
439
|
+
raise ValueError(f"Symbol {symbol} not found in {type} account")
|
|
440
|
+
|
|
272
441
|
def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
|
|
273
|
-
if source.type == dest.type and source.ticket == dest.magic:
|
|
442
|
+
if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
|
|
274
443
|
return (
|
|
275
444
|
source.sl != dest.sl
|
|
276
445
|
or source.tp != dest.tp
|
|
@@ -280,7 +449,7 @@ class TradeCopier(object):
|
|
|
280
449
|
return False
|
|
281
450
|
|
|
282
451
|
def isposition_modified(self, source: TradePosition, dest: TradePosition):
|
|
283
|
-
if source.type == dest.type and source.ticket == dest.magic:
|
|
452
|
+
if source.type == dest.type and self._get_magic(source.ticket) == dest.magic:
|
|
284
453
|
return source.sl != dest.sl or source.tp != dest.tp
|
|
285
454
|
return False
|
|
286
455
|
|
|
@@ -308,14 +477,24 @@ class TradeCopier(object):
|
|
|
308
477
|
return True
|
|
309
478
|
return False
|
|
310
479
|
|
|
311
|
-
def
|
|
312
|
-
|
|
313
|
-
|
|
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):
|
|
314
491
|
if not self.iscopy_time():
|
|
315
492
|
return
|
|
316
493
|
check_mt5_connection(**destination)
|
|
494
|
+
symbol = self.get_copy_symbol(trade.symbol, destination)
|
|
495
|
+
self._select_symbol(symbol, destination)
|
|
496
|
+
|
|
317
497
|
volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
|
|
318
|
-
symbol = get_copy_symbol(trade.symbol, destination)
|
|
319
498
|
lot = calculate_copy_lot(
|
|
320
499
|
volume,
|
|
321
500
|
symbol,
|
|
@@ -325,51 +504,56 @@ class TradeCopier(object):
|
|
|
325
504
|
source_eqty=Account(**self.source).get_account_info().margin_free,
|
|
326
505
|
dest_eqty=Account(**destination).get_account_info().margin_free,
|
|
327
506
|
)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
507
|
+
trade_action = (
|
|
508
|
+
Mt5.TRADE_ACTION_DEAL if trade.type in [0, 1] else Mt5.TRADE_ACTION_PENDING
|
|
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
|
|
334
513
|
try:
|
|
335
|
-
|
|
336
|
-
|
|
514
|
+
request = dict(
|
|
515
|
+
symbol=symbol,
|
|
516
|
+
action=trade_action,
|
|
337
517
|
volume=lot,
|
|
518
|
+
price=price,
|
|
338
519
|
sl=trade.sl,
|
|
339
520
|
tp=trade.tp,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
price=trade.price_open if trade.type not in [0, 1] else None,
|
|
344
|
-
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,
|
|
345
524
|
comment=destination.get("comment", trade.comment + "#Copied"),
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
348
539
|
f"Copy {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
349
|
-
f"to @{destination.get('login')}::{symbol}"
|
|
540
|
+
f"to @{destination.get('login')}::{symbol}",
|
|
350
541
|
)
|
|
351
|
-
|
|
352
|
-
|
|
542
|
+
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
543
|
+
self._log_message(
|
|
353
544
|
f"Error copying {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
354
|
-
f"to @{destination.get('login')}::{symbol}"
|
|
545
|
+
f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
|
|
546
|
+
type="error",
|
|
355
547
|
)
|
|
356
548
|
except Exception as e:
|
|
357
549
|
self.log_error(e, symbol=symbol)
|
|
358
550
|
|
|
359
551
|
def copy_new_order(self, order: TradeOrder, destination: dict):
|
|
360
|
-
|
|
361
|
-
2: "BLMT",
|
|
362
|
-
3: "SLMT",
|
|
363
|
-
4: "BSTP",
|
|
364
|
-
5: "SSTP",
|
|
365
|
-
6: "BSTPLMT",
|
|
366
|
-
7: "SSTPLMT",
|
|
367
|
-
}
|
|
368
|
-
self.copy_new_trade(order, action_type, destination)
|
|
552
|
+
self.copy_new_trade(order, destination)
|
|
369
553
|
|
|
370
554
|
def modify_order(self, ticket, symbol, source_order: TradeOrder, destination: dict):
|
|
371
555
|
check_mt5_connection(**destination)
|
|
372
|
-
|
|
556
|
+
self._select_symbol(symbol, destination)
|
|
373
557
|
request = {
|
|
374
558
|
"action": Mt5.TRADE_ACTION_MODIFY,
|
|
375
559
|
"order": ticket,
|
|
@@ -379,42 +563,51 @@ class TradeCopier(object):
|
|
|
379
563
|
"tp": source_order.tp,
|
|
380
564
|
"stoplimit": source_order.price_stoplimit,
|
|
381
565
|
}
|
|
382
|
-
result =
|
|
566
|
+
result = Mt5.order_send(request)
|
|
383
567
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
384
|
-
|
|
385
|
-
logger.error(
|
|
386
|
-
f"Error modifying Order #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
|
|
387
|
-
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
388
|
-
)
|
|
568
|
+
result = self._update_filling_type(request, result)
|
|
389
569
|
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
390
|
-
|
|
391
|
-
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}, "
|
|
392
572
|
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
393
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
|
+
)
|
|
394
580
|
|
|
395
581
|
def remove_order(self, src_symbol, order: TradeOrder, destination: dict):
|
|
396
582
|
check_mt5_connection(**destination)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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}, "
|
|
401
594
|
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
402
595
|
)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
f"Error closing Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
406
|
-
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",
|
|
407
601
|
)
|
|
408
602
|
|
|
409
603
|
def copy_new_position(self, position: TradePosition, destination: dict):
|
|
410
|
-
|
|
411
|
-
self.copy_new_trade(position, action_type, destination)
|
|
604
|
+
self.copy_new_trade(position, destination)
|
|
412
605
|
|
|
413
606
|
def modify_position(
|
|
414
607
|
self, ticket, symbol, source_pos: TradePosition, destination: dict
|
|
415
608
|
):
|
|
416
609
|
check_mt5_connection(**destination)
|
|
417
|
-
|
|
610
|
+
self._select_symbol(symbol, destination)
|
|
418
611
|
request = {
|
|
419
612
|
"action": Mt5.TRADE_ACTION_SLTP,
|
|
420
613
|
"position": ticket,
|
|
@@ -422,31 +615,54 @@ class TradeCopier(object):
|
|
|
422
615
|
"sl": source_pos.sl,
|
|
423
616
|
"tp": source_pos.tp,
|
|
424
617
|
}
|
|
425
|
-
result =
|
|
618
|
+
result = Mt5.order_send(request)
|
|
426
619
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
427
|
-
|
|
428
|
-
logger.error(
|
|
429
|
-
f"Error modifying Position #{ticket} on @{destination.get('login')}::{symbol}, {msg}, "
|
|
430
|
-
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
431
|
-
)
|
|
620
|
+
result = self._update_filling_type(request, result)
|
|
432
621
|
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
433
|
-
|
|
434
|
-
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}, "
|
|
435
624
|
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
436
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
|
+
)
|
|
437
632
|
|
|
438
633
|
def remove_position(self, src_symbol, position: TradePosition, destination: dict):
|
|
439
634
|
check_mt5_connection(**destination)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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}, "
|
|
444
658
|
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
445
659
|
)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
f"Error closing
|
|
449
|
-
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",
|
|
450
666
|
)
|
|
451
667
|
|
|
452
668
|
def filter_positions_and_orders(self, pos_or_orders, symbols=None):
|
|
@@ -461,9 +677,11 @@ class TradeCopier(object):
|
|
|
461
677
|
if pos.symbol in symbols.keys() or pos.symbol in symbols.values()
|
|
462
678
|
]
|
|
463
679
|
|
|
464
|
-
def get_positions(
|
|
680
|
+
def get_positions(
|
|
681
|
+
self, destination: dict
|
|
682
|
+
) -> Tuple[List[TradePosition], List[TradePosition]]:
|
|
465
683
|
source_positions = self.source_positions() or []
|
|
466
|
-
dest_symbols = get_copy_symbols(destination)
|
|
684
|
+
dest_symbols = get_copy_symbols(destination, self.source)
|
|
467
685
|
dest_positions = self.destination_positions(destination) or []
|
|
468
686
|
source_positions = self.filter_positions_and_orders(
|
|
469
687
|
source_positions, symbols=dest_symbols
|
|
@@ -473,9 +691,11 @@ class TradeCopier(object):
|
|
|
473
691
|
)
|
|
474
692
|
return source_positions, dest_positions
|
|
475
693
|
|
|
476
|
-
def get_orders(
|
|
694
|
+
def get_orders(
|
|
695
|
+
self, destination: dict
|
|
696
|
+
) -> Tuple[List[TradeOrder], List[TradeOrder]]:
|
|
477
697
|
source_orders = self.source_orders() or []
|
|
478
|
-
dest_symbols = get_copy_symbols(destination)
|
|
698
|
+
dest_symbols = get_copy_symbols(destination, self.source)
|
|
479
699
|
dest_orders = self.destination_orders(destination) or []
|
|
480
700
|
source_orders = self.filter_positions_and_orders(
|
|
481
701
|
source_orders, symbols=dest_symbols
|
|
@@ -485,12 +705,27 @@ class TradeCopier(object):
|
|
|
485
705
|
)
|
|
486
706
|
return source_orders, dest_orders
|
|
487
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
|
+
|
|
488
723
|
def _copy_new_orders(self, destination):
|
|
489
724
|
source_orders, destination_orders = self.get_orders(destination)
|
|
490
725
|
# Check for new orders
|
|
491
726
|
dest_ids = [order.magic for order in destination_orders]
|
|
492
727
|
for source_order in source_orders:
|
|
493
|
-
if source_order.ticket not in dest_ids:
|
|
728
|
+
if self._get_magic(source_order.ticket) not in dest_ids:
|
|
494
729
|
if not self.slippage(source_order, destination):
|
|
495
730
|
self.copy_new_order(source_order, destination)
|
|
496
731
|
|
|
@@ -499,7 +734,7 @@ class TradeCopier(object):
|
|
|
499
734
|
source_orders, destination_orders = self.get_orders(destination)
|
|
500
735
|
for source_order in source_orders:
|
|
501
736
|
for destination_order in destination_orders:
|
|
502
|
-
if source_order.ticket == destination_order.magic:
|
|
737
|
+
if self._get_magic(source_order.ticket) == destination_order.magic:
|
|
503
738
|
if self.isorder_modified(source_order, destination_order):
|
|
504
739
|
ticket = destination_order.ticket
|
|
505
740
|
symbol = destination_order.symbol
|
|
@@ -508,27 +743,16 @@ class TradeCopier(object):
|
|
|
508
743
|
def _copy_closed_orders(self, destination):
|
|
509
744
|
# Check for closed orders
|
|
510
745
|
source_orders, destination_orders = self.get_orders(destination)
|
|
511
|
-
source_ids = [order.ticket for order in source_orders]
|
|
746
|
+
source_ids = [self._get_magic(order.ticket) for order in source_orders]
|
|
512
747
|
for destination_order in destination_orders:
|
|
513
748
|
if destination_order.magic not in source_ids:
|
|
514
|
-
|
|
515
|
-
destination_order.
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
def _sync_positions(self, what, destination):
|
|
520
|
-
# Update postions
|
|
521
|
-
source_positions, _ = self.get_positions(destination)
|
|
522
|
-
_, destination_orders = self.get_orders(destination)
|
|
523
|
-
for source_position in source_positions:
|
|
524
|
-
for destination_order in destination_orders:
|
|
525
|
-
if source_position.ticket == destination_order.magic:
|
|
526
|
-
self.remove_order(
|
|
527
|
-
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"
|
|
528
754
|
)
|
|
529
|
-
|
|
530
|
-
if not self.slippage(source_position, destination):
|
|
531
|
-
self.copy_new_position(source_position, destination)
|
|
755
|
+
self.remove_order(src_symbol, destination_order, destination)
|
|
532
756
|
|
|
533
757
|
def _sync_orders(self, destination):
|
|
534
758
|
# Update orders
|
|
@@ -536,18 +760,13 @@ class TradeCopier(object):
|
|
|
536
760
|
source_orders, _ = self.get_orders(destination)
|
|
537
761
|
for destination_position in destination_positions:
|
|
538
762
|
for source_order in source_orders:
|
|
539
|
-
if destination_position.magic == source_order.ticket:
|
|
763
|
+
if destination_position.magic == self._get_magic(source_order.ticket):
|
|
540
764
|
self.remove_position(
|
|
541
765
|
source_order.symbol, destination_position, destination
|
|
542
766
|
)
|
|
543
767
|
if not self.slippage(source_order, destination):
|
|
544
768
|
self.copy_new_order(source_order, destination)
|
|
545
769
|
|
|
546
|
-
def _copy_what(self, destination):
|
|
547
|
-
if not destination.get("copy", False):
|
|
548
|
-
raise ValueError("Destination account not set to copy mode")
|
|
549
|
-
return destination.get("copy_what", "all")
|
|
550
|
-
|
|
551
770
|
def copy_orders(self, destination: dict):
|
|
552
771
|
what = self._copy_what(destination)
|
|
553
772
|
if what not in ["all", "orders"]:
|
|
@@ -564,7 +783,7 @@ class TradeCopier(object):
|
|
|
564
783
|
# Check for new positions
|
|
565
784
|
dest_ids = [pos.magic for pos in destination_positions]
|
|
566
785
|
for source_position in source_positions:
|
|
567
|
-
if source_position.ticket not in dest_ids:
|
|
786
|
+
if self._get_magic(source_position.ticket) not in dest_ids:
|
|
568
787
|
if not self.slippage(source_position, destination):
|
|
569
788
|
self.copy_new_position(source_position, destination)
|
|
570
789
|
|
|
@@ -573,7 +792,10 @@ class TradeCopier(object):
|
|
|
573
792
|
source_positions, destination_positions = self.get_positions(destination)
|
|
574
793
|
for source_position in source_positions:
|
|
575
794
|
for destination_position in destination_positions:
|
|
576
|
-
if
|
|
795
|
+
if (
|
|
796
|
+
self._get_magic(source_position.ticket)
|
|
797
|
+
== destination_position.magic
|
|
798
|
+
):
|
|
577
799
|
if self.isposition_modified(source_position, destination_position):
|
|
578
800
|
ticket = destination_position.ticket
|
|
579
801
|
symbol = destination_position.symbol
|
|
@@ -584,13 +806,30 @@ class TradeCopier(object):
|
|
|
584
806
|
def _copy_closed_position(self, destination):
|
|
585
807
|
# Check for closed positions
|
|
586
808
|
source_positions, destination_positions = self.get_positions(destination)
|
|
587
|
-
source_ids = [pos.ticket for pos in source_positions]
|
|
809
|
+
source_ids = [self._get_magic(pos.ticket) for pos in source_positions]
|
|
588
810
|
for destination_position in destination_positions:
|
|
589
811
|
if destination_position.magic not in source_ids:
|
|
590
|
-
|
|
591
|
-
destination_position.
|
|
592
|
-
)
|
|
593
|
-
|
|
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)
|
|
594
833
|
|
|
595
834
|
def copy_positions(self, destination: dict):
|
|
596
835
|
what = self._copy_what(destination)
|
|
@@ -601,50 +840,224 @@ class TradeCopier(object):
|
|
|
601
840
|
self._copy_modified_positions(destination)
|
|
602
841
|
self._copy_closed_position(destination)
|
|
603
842
|
|
|
604
|
-
def
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
610
853
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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():
|
|
615
859
|
try:
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
err_msg = "Source and destination accounts are on the same \
|
|
619
|
-
MetaTrader 5 installation which is not allowed."
|
|
620
|
-
logger.error(err_msg)
|
|
621
|
-
continue
|
|
622
|
-
self.copy_orders(destination)
|
|
623
|
-
self.copy_positions(destination)
|
|
624
|
-
Mt5.shutdown()
|
|
625
|
-
time.sleep(0.1)
|
|
860
|
+
self.copy_positions(destination)
|
|
861
|
+
self.copy_orders(destination)
|
|
626
862
|
except KeyboardInterrupt:
|
|
627
|
-
|
|
628
|
-
|
|
863
|
+
self._log_message(
|
|
864
|
+
"KeyboardInterrupt received, stopping the Trade Copier ..."
|
|
865
|
+
)
|
|
866
|
+
self.stop()
|
|
629
867
|
except Exception as e:
|
|
630
868
|
self.log_error(e)
|
|
631
|
-
|
|
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():
|
|
884
|
+
for destination in self.destinations:
|
|
885
|
+
if self.shutdown_event.is_set():
|
|
886
|
+
break
|
|
887
|
+
|
|
888
|
+
if destination.get("path") == self.source.get("path"):
|
|
889
|
+
self._log_message(
|
|
890
|
+
f"Source and destination accounts are on the same "
|
|
891
|
+
f"MetaTrader 5 installation {self.source.get('path')} which is not allowed."
|
|
892
|
+
)
|
|
893
|
+
continue
|
|
894
|
+
try:
|
|
895
|
+
self.copy_positions(destination)
|
|
896
|
+
self.copy_orders(destination)
|
|
897
|
+
except Exception as e:
|
|
898
|
+
self.log_error(e)
|
|
899
|
+
time.sleep(self.sleeptime)
|
|
900
|
+
|
|
901
|
+
except KeyboardInterrupt:
|
|
902
|
+
self._log_message(
|
|
903
|
+
"KeyboardInterrupt received, stopping the copier instance..."
|
|
904
|
+
)
|
|
905
|
+
self.shutdown_event.set()
|
|
906
|
+
|
|
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.
|
|
936
|
+
|
|
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)
|
|
632
970
|
|
|
633
971
|
|
|
634
972
|
def RunCopier(
|
|
635
|
-
source: dict,
|
|
973
|
+
source: dict,
|
|
974
|
+
destinations: list,
|
|
975
|
+
sleeptime: float,
|
|
976
|
+
start_time: str,
|
|
977
|
+
end_time: str,
|
|
978
|
+
/,
|
|
979
|
+
custom_logger=None,
|
|
980
|
+
shutdown_event=None,
|
|
981
|
+
log_queue=None,
|
|
636
982
|
):
|
|
637
|
-
|
|
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
|
+
"""
|
|
1007
|
+
copier = TradeCopier(
|
|
1008
|
+
source,
|
|
1009
|
+
destinations,
|
|
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,
|
|
1016
|
+
)
|
|
638
1017
|
copier.run()
|
|
639
1018
|
|
|
640
1019
|
|
|
641
1020
|
def RunMultipleCopier(
|
|
642
1021
|
accounts: List[dict],
|
|
643
|
-
sleeptime: float = 0.
|
|
1022
|
+
sleeptime: float = 0.01,
|
|
644
1023
|
start_delay: float = 1.0,
|
|
645
1024
|
start_time: str = None,
|
|
646
1025
|
end_time: str = None,
|
|
1026
|
+
shutdown_event=None,
|
|
1027
|
+
custom_logger=None,
|
|
1028
|
+
log_queue=None,
|
|
647
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
|
+
"""
|
|
648
1061
|
processes = []
|
|
649
1062
|
|
|
650
1063
|
for account in accounts:
|
|
@@ -655,16 +1068,26 @@ def RunMultipleCopier(
|
|
|
655
1068
|
logger.warning("Skipping account due to missing source or destinations.")
|
|
656
1069
|
continue
|
|
657
1070
|
paths = set([source.get("path")] + [dest.get("path") for dest in destinations])
|
|
658
|
-
if len(paths) == 1:
|
|
1071
|
+
if len(paths) == 1 and len(destinations) >= 1:
|
|
659
1072
|
logger.warning(
|
|
660
|
-
"Skipping account
|
|
1073
|
+
"Skipping account: source and destination cannot share the same MetaTrader 5 terminal path."
|
|
661
1074
|
)
|
|
662
1075
|
continue
|
|
663
1076
|
logger.info(f"Starting process for source account @{source.get('login')}")
|
|
664
|
-
|
|
665
|
-
process = multiprocessing.Process(
|
|
1077
|
+
process = mp.Process(
|
|
666
1078
|
target=RunCopier,
|
|
667
|
-
args=(
|
|
1079
|
+
args=(
|
|
1080
|
+
source,
|
|
1081
|
+
destinations,
|
|
1082
|
+
sleeptime,
|
|
1083
|
+
start_time,
|
|
1084
|
+
end_time,
|
|
1085
|
+
),
|
|
1086
|
+
kwargs=dict(
|
|
1087
|
+
custom_logger=custom_logger,
|
|
1088
|
+
shutdown_event=shutdown_event,
|
|
1089
|
+
log_queue=log_queue,
|
|
1090
|
+
),
|
|
668
1091
|
)
|
|
669
1092
|
processes.append(process)
|
|
670
1093
|
process.start()
|
|
@@ -672,42 +1095,18 @@ def RunMultipleCopier(
|
|
|
672
1095
|
if start_delay:
|
|
673
1096
|
time.sleep(start_delay)
|
|
674
1097
|
|
|
675
|
-
# Wait for all processes to complete
|
|
676
1098
|
for process in processes:
|
|
677
1099
|
process.join()
|
|
678
1100
|
|
|
679
1101
|
|
|
680
|
-
def _strtodict(string: str) -> dict:
|
|
681
|
-
string = string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
|
|
682
|
-
return dict(item.split(":") for item in string.split(","))
|
|
683
|
-
|
|
684
|
-
|
|
685
1102
|
def _parse_symbols(section):
|
|
686
1103
|
symbols: str = section.get("symbols")
|
|
687
1104
|
symbols = symbols.strip().replace("\n", " ").replace('"""', "")
|
|
688
1105
|
if symbols in ["all", "*"]:
|
|
689
1106
|
section["symbols"] = symbols
|
|
690
|
-
elif ":" in symbols:
|
|
691
|
-
symbols = _strtodict(symbols)
|
|
692
|
-
section["symbols"] = symbols
|
|
693
|
-
elif " " in symbols and "," not in symbols:
|
|
694
|
-
symbols = symbols.split()
|
|
695
|
-
section["symbols"] = symbols
|
|
696
|
-
elif "," in symbols:
|
|
697
|
-
symbols = symbols.replace(" ", "").split(",")
|
|
698
|
-
section["symbols"] = symbols
|
|
699
1107
|
else:
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
You can use space or comma separated symbols in one line or multiple lines using triple quotes.
|
|
703
|
-
You can also use a dictionary to map source symbols to destination symbols as shown below.
|
|
704
|
-
Or if you want to copy all symbols, use "all" or "*".
|
|
705
|
-
|
|
706
|
-
symbols = EURUSD, GBPUSD, USDJPY (space separated)
|
|
707
|
-
symbols = EURUSD,GBPUSD,USDJPY (comma separated)
|
|
708
|
-
symbols = EURUSD.s:EURUSD_i, GBPUSD.s:GBPUSD_i, USDJPY.s:USDJPY_i (dictionary)
|
|
709
|
-
symbols = all (copy all symbols)
|
|
710
|
-
symbols = * (copy all symbols) """)
|
|
1108
|
+
symbols = get_symbols_from_string(symbols)
|
|
1109
|
+
section["symbols"] = symbols
|
|
711
1110
|
|
|
712
1111
|
|
|
713
1112
|
def config_copier(
|