bbstrader 0.2.991__py3-none-any.whl → 0.3.1__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,10 +1,11 @@
1
1
  import multiprocessing
2
+ import threading
2
3
  import time
3
4
  from datetime import datetime
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
@@ -20,12 +21,14 @@ except ImportError:
20
21
  __all__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
21
22
 
22
23
 
23
- logger.add(
24
+ log.add(
24
25
  f"{BBSTRADER_DIR}/logs/copier.log",
25
26
  enqueue=True,
26
27
  level="INFO",
27
28
  format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
28
29
  )
30
+ global logger
31
+ logger = log
29
32
 
30
33
 
31
34
  def fix_lot(fixed):
@@ -113,11 +116,21 @@ def calculate_copy_lot(
113
116
  raise ValueError("Invalid mode selected")
114
117
 
115
118
 
116
- def get_copy_symbols(destination: dict = None):
119
+ def get_copy_symbols(destination: dict, source: dict):
117
120
  symbols = destination.get("symbols", "all")
118
- account = Account(**destination)
121
+ src_account = Account(**source)
122
+ dest_account = Account(**destination)
119
123
  if symbols == "all" or symbols == "*":
120
- return account.get_symbols()
124
+ src_symbols = src_account.get_symbols()
125
+ dest_symbols = dest_account.get_symbols()
126
+ for s in src_symbols:
127
+ if s not in dest_symbols:
128
+ err_msg = (
129
+ f"To use 'all' or '*', Source account@{src_account.number} "
130
+ f"and destination account@{dest_account.number} "
131
+ f"must be the same type and have the same symbols"
132
+ )
133
+ raise ValueError(err_msg)
121
134
  elif isinstance(symbols, (list, dict)):
122
135
  return symbols
123
136
  elif isinstance(symbols, str):
@@ -127,22 +140,6 @@ def get_copy_symbols(destination: dict = None):
127
140
  return symbols.split()
128
141
 
129
142
 
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")
144
-
145
-
146
143
  class TradeCopier(object):
147
144
  """
148
145
  ``TradeCopier`` responsible for copying trading orders and positions from a source account to multiple destination accounts.
@@ -159,7 +156,10 @@ class TradeCopier(object):
159
156
  "sleeptime",
160
157
  "start_time",
161
158
  "end_time",
159
+ "shutdown_event",
160
+ "custom_logger",
162
161
  )
162
+ shutdown_event: threading.Event
163
163
 
164
164
  def __init__(
165
165
  self,
@@ -168,6 +168,8 @@ class TradeCopier(object):
168
168
  sleeptime: float = 0.1,
169
169
  start_time: str = None,
170
170
  end_time: str = None,
171
+ shutdown_event=None,
172
+ custom_logger=None,
171
173
  ):
172
174
  """
173
175
  Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
@@ -245,8 +247,15 @@ class TradeCopier(object):
245
247
  self.sleeptime = sleeptime
246
248
  self.start_time = start_time
247
249
  self.end_time = end_time
248
- self.errors = set()
250
+ self.shutdown_event = shutdown_event
251
+ self._add_logger(custom_logger)
249
252
  self._add_copy()
253
+ self.errors = set()
254
+
255
+ def _add_logger(self, custom_logger):
256
+ if custom_logger:
257
+ global logger
258
+ logger = custom_logger
250
259
 
251
260
  def _add_copy(self):
252
261
  self.source["copy"] = True
@@ -269,6 +278,21 @@ class TradeCopier(object):
269
278
  check_mt5_connection(**destination)
270
279
  return Account(**destination).get_positions(symbol=symbol)
271
280
 
281
+ def get_copy_symbol(self, symbol, destination: dict = None, type="destination"):
282
+ symbols = get_copy_symbols(destination, self.source)
283
+ if isinstance(symbols, list):
284
+ if symbol in symbols:
285
+ return symbol
286
+ if isinstance(symbols, dict):
287
+ if type == "destination":
288
+ if symbol in symbols.keys():
289
+ return symbols[symbol]
290
+ if type == "source":
291
+ for k, v in symbols.items():
292
+ if v == symbol:
293
+ return k
294
+ raise ValueError(f"Symbol {symbol} not found in {type} account")
295
+
272
296
  def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
273
297
  if source.type == dest.type and source.ticket == dest.magic:
274
298
  return (
@@ -302,10 +326,9 @@ class TradeCopier(object):
302
326
  if self.start_time is None or self.end_time is None:
303
327
  return True
304
328
  else:
305
- now = datetime.now()
306
329
  start_time = datetime.strptime(self.start_time, "%H:%M").time()
307
330
  end_time = datetime.strptime(self.end_time, "%H:%M").time()
308
- if start_time <= now.time() <= end_time:
331
+ if start_time <= datetime.now().time() <= end_time:
309
332
  return True
310
333
  return False
311
334
 
@@ -316,7 +339,7 @@ class TradeCopier(object):
316
339
  return
317
340
  check_mt5_connection(**destination)
318
341
  volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
319
- symbol = get_copy_symbol(trade.symbol, destination)
342
+ symbol = self.get_copy_symbol(trade.symbol, destination)
320
343
  lot = calculate_copy_lot(
321
344
  volume,
322
345
  symbol,
@@ -327,7 +350,9 @@ class TradeCopier(object):
327
350
  dest_eqty=Account(**destination).get_account_info().margin_free,
328
351
  )
329
352
 
330
- trade_instance = Trade(symbol=symbol, **destination, max_risk=100.0)
353
+ trade_instance = Trade(
354
+ symbol=symbol, **destination, max_risk=100.0, logger=None
355
+ )
331
356
  try:
332
357
  action = action_type[trade.type]
333
358
  except KeyError:
@@ -395,7 +420,7 @@ class TradeCopier(object):
395
420
 
396
421
  def remove_order(self, src_symbol, order: TradeOrder, destination: dict):
397
422
  check_mt5_connection(**destination)
398
- trade = Trade(symbol=order.symbol, **destination)
423
+ trade = Trade(symbol=order.symbol, **destination, logger=None)
399
424
  if trade.close_order(order.ticket, id=order.magic):
400
425
  logger.info(
401
426
  f"Close Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
@@ -438,7 +463,7 @@ class TradeCopier(object):
438
463
 
439
464
  def remove_position(self, src_symbol, position: TradePosition, destination: dict):
440
465
  check_mt5_connection(**destination)
441
- trade = Trade(symbol=position.symbol, **destination)
466
+ trade = Trade(symbol=position.symbol, **destination, logger=None)
442
467
  if trade.close_position(position.ticket, id=position.magic):
443
468
  logger.info(
444
469
  f"Close Position #{position.ticket} on @{destination.get('login')}::{position.symbol}, "
@@ -464,7 +489,7 @@ class TradeCopier(object):
464
489
 
465
490
  def get_positions(self, destination: dict):
466
491
  source_positions = self.source_positions() or []
467
- dest_symbols = get_copy_symbols(destination)
492
+ dest_symbols = get_copy_symbols(destination, self.source)
468
493
  dest_positions = self.destination_positions(destination) or []
469
494
  source_positions = self.filter_positions_and_orders(
470
495
  source_positions, symbols=dest_symbols
@@ -476,7 +501,7 @@ class TradeCopier(object):
476
501
 
477
502
  def get_orders(self, destination: dict):
478
503
  source_orders = self.source_orders() or []
479
- dest_symbols = get_copy_symbols(destination)
504
+ dest_symbols = get_copy_symbols(destination, self.source)
480
505
  dest_orders = self.destination_orders(destination) or []
481
506
  source_orders = self.filter_positions_and_orders(
482
507
  source_orders, symbols=dest_symbols
@@ -512,7 +537,7 @@ class TradeCopier(object):
512
537
  source_ids = [order.ticket for order in source_orders]
513
538
  for destination_order in destination_orders:
514
539
  if destination_order.magic not in source_ids:
515
- src_symbol = get_copy_symbol(
540
+ src_symbol = self.get_copy_symbol(
516
541
  destination_order.symbol, destination, type="source"
517
542
  )
518
543
  self.remove_order(src_symbol, destination_order, destination)
@@ -548,7 +573,7 @@ class TradeCopier(object):
548
573
  if not destination.get("copy", False):
549
574
  raise ValueError("Destination account not set to copy mode")
550
575
  return destination.get("copy_what", "all")
551
-
576
+
552
577
  def copy_orders(self, destination: dict):
553
578
  what = self._copy_what(destination)
554
579
  if what not in ["all", "orders"]:
@@ -588,7 +613,7 @@ class TradeCopier(object):
588
613
  source_ids = [pos.ticket for pos in source_positions]
589
614
  for destination_position in destination_positions:
590
615
  if destination_position.magic not in source_ids:
591
- src_symbol = get_copy_symbol(
616
+ src_symbol = self.get_copy_symbol(
592
617
  destination_position.symbol, destination, type="source"
593
618
  )
594
619
  self.remove_position(src_symbol, destination_position, destination)
@@ -613,8 +638,15 @@ class TradeCopier(object):
613
638
  logger.info("Trade Copier Running ...")
614
639
  logger.info(f"Source Account: {self.source.get('login')}")
615
640
  while True:
641
+ if self.shutdown_event and self.shutdown_event.is_set():
642
+ logger.info(
643
+ "Shutdown event received, stopping Trade Copier gracefully."
644
+ )
645
+ break
616
646
  try:
617
647
  for destination in self.destinations:
648
+ if self.shutdown_event and self.shutdown_event.is_set():
649
+ break
618
650
  if destination.get("path") == self.source.get("path"):
619
651
  err_msg = "Source and destination accounts are on the same \
620
652
  MetaTrader 5 installation which is not allowed."
@@ -624,18 +656,52 @@ class TradeCopier(object):
624
656
  self.copy_positions(destination)
625
657
  Mt5.shutdown()
626
658
  time.sleep(0.1)
659
+
660
+ if self.shutdown_event and self.shutdown_event.is_set():
661
+ logger.info(
662
+ "Shutdown event received during destination processing, exiting."
663
+ )
664
+ break
665
+
627
666
  except KeyboardInterrupt:
628
- logger.info("Stopping the Trade Copier ...")
667
+ logger.info("KeyboardInterrupt received, stopping the Trade Copier ...")
668
+ if self.shutdown_event:
669
+ self.shutdown_event.set()
629
670
  break
630
671
  except Exception as e:
631
672
  self.log_error(e)
673
+ if self.shutdown_event and self.shutdown_event.is_set():
674
+ logger.error(
675
+ "Error occurred after shutdown signaled, exiting loop."
676
+ )
677
+ break
678
+
679
+ # Check shutdown event before sleeping
680
+ if self.shutdown_event and self.shutdown_event.is_set():
681
+ logger.info("Shutdown event checked before sleep, exiting.")
682
+ break
632
683
  time.sleep(self.sleeptime)
684
+ logger.info("Trade Copier has shut down.")
633
685
 
634
686
 
635
687
  def RunCopier(
636
- source: dict, destinations: list, sleeptime: float, start_time: str, end_time: str
688
+ source: dict,
689
+ destinations: list,
690
+ sleeptime: float,
691
+ start_time: str,
692
+ end_time: str,
693
+ shutdown_event=None,
694
+ custom_logger=None,
637
695
  ):
638
- copier = TradeCopier(source, destinations, sleeptime, start_time, end_time)
696
+ copier = TradeCopier(
697
+ source,
698
+ destinations,
699
+ sleeptime,
700
+ start_time,
701
+ end_time,
702
+ shutdown_event,
703
+ custom_logger,
704
+ )
639
705
  copier.run()
640
706
 
641
707
 
@@ -645,6 +711,8 @@ def RunMultipleCopier(
645
711
  start_delay: float = 1.0,
646
712
  start_time: str = None,
647
713
  end_time: str = None,
714
+ shutdown_event=None,
715
+ custom_logger=None,
648
716
  ):
649
717
  processes = []
650
718
 
@@ -662,10 +730,16 @@ def RunMultipleCopier(
662
730
  )
663
731
  continue
664
732
  logger.info(f"Starting process for source account @{source.get('login')}")
665
-
666
733
  process = multiprocessing.Process(
667
734
  target=RunCopier,
668
- args=(source, destinations, sleeptime, start_time, end_time),
735
+ args=(
736
+ source,
737
+ destinations,
738
+ sleeptime,
739
+ start_time,
740
+ end_time,
741
+ ),
742
+ kwargs=dict(shutdown_event=shutdown_event, custom_logger=custom_logger),
669
743
  )
670
744
  processes.append(process)
671
745
  process.start()
@@ -673,13 +747,14 @@ def RunMultipleCopier(
673
747
  if start_delay:
674
748
  time.sleep(start_delay)
675
749
 
676
- # Wait for all processes to complete
677
750
  for process in processes:
678
751
  process.join()
679
752
 
680
753
 
681
754
  def _strtodict(string: str) -> dict:
682
755
  string = string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
756
+ if string.endswith(","):
757
+ string = string[:-1]
683
758
  return dict(item.split(":") for item in string.split(","))
684
759
 
685
760
 
@@ -7,7 +7,7 @@ from pandas.tseries.holiday import USFederalHolidayCalendar
7
7
  from pandas.tseries.offsets import CustomBusinessDay
8
8
 
9
9
  from bbstrader.metatrader.account import AMG_EXCHANGES, Account, check_mt5_connection
10
- from bbstrader.metatrader.utils import TIMEFRAMES, TimeFrame, raise_mt5_error
10
+ from bbstrader.metatrader.utils import TIMEFRAMES, TimeFrame, raise_mt5_error, SymbolType
11
11
 
12
12
  try:
13
13
  import MetaTrader5 as Mt5
@@ -46,13 +46,13 @@ COMD_CALENDARS = {
46
46
  }
47
47
 
48
48
  CALENDARS = {
49
- "FX": "us_futures",
50
- "STK": AMG_EXCHANGES,
51
- "ETF": AMG_EXCHANGES,
52
- "IDX": IDX_CALENDARS,
53
- "COMD": COMD_CALENDARS,
54
- "CRYPTO": "24/7",
55
- "FUT": None,
49
+ SymbolType.FOREX: "us_futures",
50
+ SymbolType.STOCKS: AMG_EXCHANGES,
51
+ SymbolType.ETFs: AMG_EXCHANGES,
52
+ SymbolType.INDICES: IDX_CALENDARS,
53
+ SymbolType.COMMODITIES: COMD_CALENDARS,
54
+ SymbolType.CRYPTO: "24/7",
55
+ SymbolType.FUTURES: None,
56
56
  }
57
57
 
58
58
  SESSION_TIMEFRAMES = [
@@ -206,6 +206,7 @@ class Rates(object):
206
206
  ) -> Union[pd.DataFrame, None]:
207
207
  """Fetches data from MT5 and returns a DataFrame or None."""
208
208
  try:
209
+ rates = None
209
210
  if isinstance(start, int) and isinstance(count, int):
210
211
  rates = Mt5.copy_rates_from_pos(
211
212
  self.symbol, self.time_frame, start, count
@@ -248,7 +249,7 @@ class Rates(object):
248
249
  currencies = self.__account.get_currency_rates(self.symbol)
249
250
  s_info = self.__account.get_symbol_info(self.symbol)
250
251
  if symbol_type in CALENDARS:
251
- if symbol_type == "STK" or symbol_type == "ETF":
252
+ if symbol_type == SymbolType.STOCKS or symbol_type == SymbolType.ETFs:
252
253
  for exchange in CALENDARS[symbol_type]:
253
254
  if exchange in get_calendar_names():
254
255
  symbols = self.__account.get_stocks_from_exchange(
@@ -257,20 +258,20 @@ class Rates(object):
257
258
  if self.symbol in symbols:
258
259
  calendar = get_calendar(exchange, side="right")
259
260
  break
260
- elif symbol_type == "IDX":
261
+ elif symbol_type == SymbolType.INDICES:
261
262
  calendar = get_calendar(
262
263
  CALENDARS[symbol_type][currencies["mc"]], side="right"
263
264
  )
264
- elif symbol_type == "COMD":
265
+ elif symbol_type == SymbolType.COMMODITIES:
265
266
  for commodity in CALENDARS[symbol_type]:
266
267
  if commodity in s_info.path:
267
268
  calendar = get_calendar(
268
269
  CALENDARS[symbol_type][commodity], side="right"
269
270
  )
270
- elif symbol_type == "FUT":
271
+ elif symbol_type == SymbolType.FUTURES:
271
272
  if "Index" in s_info.path:
272
273
  calendar = get_calendar(
273
- CALENDARS["IDX"][currencies["mc"]], side="right"
274
+ CALENDARS[SymbolType.INDICES][currencies["mc"]], side="right"
274
275
  )
275
276
  else:
276
277
  for commodity, cal in COMD_CALENDARS.items():
@@ -6,7 +6,7 @@ from scipy.stats import norm
6
6
 
7
7
  from bbstrader.metatrader.account import Account
8
8
  from bbstrader.metatrader.rates import Rates
9
- from bbstrader.metatrader.utils import TIMEFRAMES, TimeFrame
9
+ from bbstrader.metatrader.utils import TIMEFRAMES, TimeFrame, SymbolType
10
10
 
11
11
  try:
12
12
  import MetaTrader5 as Mt5
@@ -275,10 +275,12 @@ class RiskManagement(Account):
275
275
  swap = df["swap"].sum()
276
276
  total_profit = commisions + fees + swap + profit
277
277
  initial_balance = balance - total_profit
278
- if balance != 0:
278
+ if equity != 0:
279
279
  risk_alowed = (((equity - initial_balance) / equity) * 100) * -1
280
280
  return round(risk_alowed, 2)
281
- return 0.0
281
+ else: # Handle equity is zero
282
+ return 0.0
283
+ return 0.0 # This is for the case where df is None
282
284
 
283
285
  def get_lot(self) -> float:
284
286
  """ "Get the approprite lot size for a trade"""
@@ -498,10 +500,10 @@ class RiskManagement(Account):
498
500
  av_price = (s_info.bid + s_info.ask) / 2
499
501
  trade_risk = self.get_trade_risk()
500
502
  symbol_type = self.get_symbol_type(self.symbol)
501
- FX = symbol_type == "FX"
502
- COMD = symbol_type == "COMD"
503
- FUT = symbol_type == "FUT"
504
- CRYPTO = symbol_type == "CRYPTO"
503
+ FX = symbol_type == SymbolType.FOREX
504
+ COMD = symbol_type == SymbolType.COMMODITIES
505
+ FUT = symbol_type == SymbolType.FUTURES
506
+ CRYPTO = symbol_type == SymbolType.CRYPTO
505
507
  if COMD:
506
508
  supported = _COMMD_SUPPORTED_
507
509
  if "." in self.symbol:
@@ -652,14 +654,14 @@ class RiskManagement(Account):
652
654
  lot = self._check_lot(_lot)
653
655
 
654
656
  volume = round(lot * size * av_price)
655
- if self.get_symbol_type(self.symbol) == "FX":
657
+ if self.get_symbol_type(self.symbol) == SymbolType.FOREX:
656
658
  volume = round((trade_loss * size) / loss)
657
659
  __lot = round((volume / size), 2)
658
660
  lot = self._check_lot(__lot)
659
661
 
660
662
  if (
661
- self.get_symbol_type(self.symbol) == "COMD"
662
- or self.get_symbol_type(self.symbol) == "CRYPTO"
663
+ self.get_symbol_type(self.symbol) == SymbolType.COMMODITIES
664
+ or self.get_symbol_type(self.symbol) == SymbolType.CRYPTO
663
665
  and size > 1
664
666
  ):
665
667
  lot = currency_risk / (sl * loss * size)
@@ -705,7 +707,7 @@ class RiskManagement(Account):
705
707
  if account:
706
708
  return AL
707
709
 
708
- if self.get_symbol_type(self.symbol) == "FX":
710
+ if self.get_symbol_type(self.symbol) == SymbolType.FOREX:
709
711
  return AL
710
712
  else:
711
713
  s_info = self.symbol_info
@@ -2,9 +2,18 @@ import argparse
2
2
  import sys
3
3
 
4
4
  from bbstrader.metatrader.copier import RunCopier, config_copier
5
+ from bbstrader.apps._copier import main as RunCopyAPP
5
6
 
6
7
 
7
8
  def copier_args(parser: argparse.ArgumentParser):
9
+ parser.add_argument(
10
+ "-m",
11
+ "--mode",
12
+ type=str,
13
+ default="CLI",
14
+ choices=("CLI", "GUI"),
15
+ help="Run the copier in the terminal or using the GUI",
16
+ )
8
17
  parser.add_argument(
9
18
  "-s", "--source", type=str, nargs="?", default=None, help="Source section name"
10
19
  )
@@ -52,6 +61,7 @@ def copy_trades(unknown):
52
61
  python -m bbstrader --run copier [options]
53
62
 
54
63
  Options:
64
+ -m, --mode: CLI for terminal app and GUI for Desktop app
55
65
  -s, --source: Source Account section name
56
66
  -d, --destinations: Destination Account section names (multiple allowed)
57
67
  -i, --interval: Update interval in seconds
@@ -67,15 +77,19 @@ def copy_trades(unknown):
67
77
  copy_parser = copier_args(copy_parser)
68
78
  copy_args = copy_parser.parse_args(unknown)
69
79
 
70
- source, destinations = config_copier(
71
- source_section=copy_args.source,
72
- dest_sections=copy_args.destinations,
73
- inifile=copy_args.config,
74
- )
75
- RunCopier(
76
- source,
77
- destinations,
78
- copy_args.interval,
79
- copy_args.start,
80
- copy_args.end,
81
- )
80
+ if copy_args.mode == "GUI":
81
+ RunCopyAPP()
82
+
83
+ elif copy_args.mode == "CLI":
84
+ source, destinations = config_copier(
85
+ source_section=copy_args.source,
86
+ dest_sections=copy_args.destinations,
87
+ inifile=copy_args.config,
88
+ )
89
+ RunCopier(
90
+ source,
91
+ destinations,
92
+ copy_args.interval,
93
+ copy_args.start,
94
+ copy_args.end,
95
+ )