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.

@@ -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 Trade
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__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
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 get_copy_symbols(destination: dict = None):
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
- account = Account(**destination)
165
+ src_account = Account(**source)
166
+ dest_account = Account(**destination)
119
167
  if symbols == "all" or symbols == "*":
120
- return account.get_symbols()
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
- if "," in symbols:
125
- return symbols.split(",")
126
- if " " in symbols:
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
- - ``portable``: A boolean indicating whether to open MetaTrader 5 installation in portable mode.
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 copy_new_trade(
312
- self, trade: TradeOrder | TradePosition, action_type: dict, destination: dict
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
- trade_instance = Trade(symbol=symbol, **destination, max_risk=100.0, logger=None)
330
- try:
331
- action = action_type[trade.type]
332
- except KeyError:
333
- return
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
- if trade_instance.open_position(
336
- action,
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
- id=trade.ticket,
341
- symbol=symbol,
342
- mm=trade.sl != 0 and trade.tp != 0,
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
- logger.info(
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
- else:
352
- logger.error(
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
- action_type = {
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
- account = Account(**destination)
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 = account.send_order(request)
566
+ result = Mt5.order_send(request)
383
567
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
384
- msg = trade_retcode_message(result.retcode)
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
- logger.info(
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
- trade = Trade(symbol=order.symbol, **destination, logger=None)
398
- if trade.close_order(order.ticket, id=order.magic):
399
- logger.info(
400
- f"Close Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
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
- else:
404
- logger.error(
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
- action_type = {0: "BMKT", 1: "SMKT"}
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
- account = Account(**destination)
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 = account.send_order(request)
618
+ result = Mt5.order_send(request)
426
619
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
427
- msg = trade_retcode_message(result.retcode)
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
- logger.info(
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
- trade = Trade(symbol=position.symbol, **destination, logger=None)
441
- if trade.close_position(position.ticket, id=position.magic):
442
- logger.info(
443
- f"Close Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
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
- else:
447
- logger.error(
448
- f"Error closing Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
449
- f"SOURCE=@{self.source.get('login')}::{src_symbol}"
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(self, destination: dict):
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(self, destination: dict):
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
- src_symbol = get_copy_symbol(
515
- destination_order.symbol, destination, type="source"
516
- )
517
- self.remove_order(src_symbol, destination_order, destination)
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
- if what in ["all", "positions"]:
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 source_position.ticket == destination_position.magic:
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
- src_symbol = get_copy_symbol(
591
- destination_position.symbol, destination, type="source"
592
- )
593
- self.remove_position(src_symbol, destination_position, destination)
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 log_error(self, e, symbol=None):
605
- error_msg = repr(e)
606
- if error_msg not in self.errors:
607
- self.errors.add(error_msg)
608
- add_msg = f"SYMBOL={symbol}" if symbol else ""
609
- logger.error(f"Error encountered: {error_msg}, {add_msg}")
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
- def run(self):
612
- logger.info("Trade Copier Running ...")
613
- logger.info(f"Source Account: {self.source.get('login')}")
614
- while True:
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
- for destination in self.destinations:
617
- if destination.get("path") == self.source.get("path"):
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
- logger.info("Stopping the Trade Copier ...")
628
- exit(0)
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
- time.sleep(self.sleeptime)
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, destinations: list, sleeptime: float, start_time: str, end_time: str
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
- copier = TradeCopier(source, destinations, sleeptime, start_time, end_time)
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.1,
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 due to same MetaTrader 5 installation path."
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=(source, destinations, sleeptime, start_time, end_time),
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
- raise ValueError("""
701
- Invalid symbols format.
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(