bbstrader 0.3.4__py3-none-any.whl → 0.3.6__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.

Files changed (38) hide show
  1. bbstrader/__init__.py +10 -1
  2. bbstrader/__main__.py +5 -0
  3. bbstrader/apps/_copier.py +3 -3
  4. bbstrader/btengine/strategy.py +113 -38
  5. bbstrader/compat.py +18 -10
  6. bbstrader/config.py +0 -16
  7. bbstrader/core/scripts.py +4 -3
  8. bbstrader/metatrader/account.py +51 -26
  9. bbstrader/metatrader/analysis.py +30 -16
  10. bbstrader/metatrader/copier.py +136 -58
  11. bbstrader/metatrader/trade.py +39 -45
  12. bbstrader/metatrader/utils.py +5 -4
  13. bbstrader/models/factors.py +17 -13
  14. bbstrader/models/ml.py +96 -49
  15. bbstrader/models/nlp.py +83 -66
  16. bbstrader/trading/execution.py +39 -22
  17. bbstrader/tseries.py +103 -127
  18. {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/METADATA +29 -46
  19. bbstrader-0.3.6.dist-info/RECORD +62 -0
  20. bbstrader-0.3.6.dist-info/top_level.txt +3 -0
  21. docs/conf.py +56 -0
  22. tests/__init__.py +0 -0
  23. tests/engine/__init__.py +1 -0
  24. tests/engine/test_backtest.py +58 -0
  25. tests/engine/test_data.py +536 -0
  26. tests/engine/test_events.py +300 -0
  27. tests/engine/test_execution.py +219 -0
  28. tests/engine/test_portfolio.py +307 -0
  29. tests/metatrader/__init__.py +0 -0
  30. tests/metatrader/test_account.py +1769 -0
  31. tests/metatrader/test_rates.py +292 -0
  32. tests/metatrader/test_risk_management.py +700 -0
  33. tests/metatrader/test_trade.py +439 -0
  34. bbstrader-0.3.4.dist-info/RECORD +0 -49
  35. bbstrader-0.3.4.dist-info/top_level.txt +0 -1
  36. {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/WHEEL +0 -0
  37. {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/entry_points.txt +0 -0
  38. {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/licenses/LICENSE +0 -0
@@ -51,6 +51,31 @@ ORDER_TYPE = {
51
51
  7: (Mt5.ORDER_TYPE_SELL_STOP_LIMIT, "SELL STOP LIMIT"),
52
52
  }
53
53
 
54
+ STOP_RETCODES = [
55
+ Mt5.TRADE_RETCODE_TRADE_DISABLED,
56
+ Mt5.TRADE_RETCODE_NO_MONEY,
57
+ Mt5.TRADE_RETCODE_SERVER_DISABLES_AT,
58
+ Mt5.TRADE_RETCODE_CLIENT_DISABLES_AT,
59
+ Mt5.TRADE_RETCODE_ONLY_REAL,
60
+ ]
61
+
62
+ RETURN_RETCODE = [
63
+ Mt5.TRADE_RETCODE_MARKET_CLOSED,
64
+ Mt5.TRADE_RETCODE_CONNECTION,
65
+ Mt5.TRADE_RETCODE_LIMIT_ORDERS,
66
+ Mt5.TRADE_RETCODE_LIMIT_VOLUME,
67
+ Mt5.TRADE_RETCODE_LIMIT_POSITIONS,
68
+ Mt5.TRADE_RETCODE_LONG_ONLY,
69
+ Mt5.TRADE_RETCODE_SHORT_ONLY,
70
+ Mt5.TRADE_RETCODE_CLOSE_ONLY,
71
+ Mt5.TRADE_RETCODE_FIFO_CLOSE,
72
+ Mt5.TRADE_RETCODE_INVALID_VOLUME,
73
+ Mt5.TRADE_RETCODE_INVALID_PRICE,
74
+ Mt5.TRADE_RETCODE_INVALID_STOPS,
75
+ Mt5.TRADE_RETCODE_NO_CHANGES
76
+ ]
77
+
78
+
54
79
 
55
80
  class OrderAction(Enum):
56
81
  COPY_NEW = "COPY_NEW"
@@ -65,25 +90,23 @@ CopyMode = Literal["fix", "multiply", "percentage", "dynamic", "replicate"]
65
90
 
66
91
  def fix_lot(fixed):
67
92
  if fixed == 0 or fixed is None:
68
- raise ValueError("Fixed lot must be a number")
93
+ raise ValueError("Fixed lot must be a number > 0")
69
94
  return fixed
70
95
 
71
96
 
72
97
  def multiply_lot(lot, multiplier):
73
98
  if multiplier == 0 or multiplier is None:
74
- raise ValueError("Multiplier lot must be a number")
99
+ raise ValueError("Multiplier lot must be a number > 0")
75
100
  return lot * multiplier
76
101
 
77
102
 
78
103
  def percentage_lot(lot, percentage):
79
104
  if percentage == 0 or percentage is None:
80
- raise ValueError("Percentage lot must be a number")
105
+ raise ValueError("Percentage lot must be a number > 0")
81
106
  return round(lot * percentage / 100, 2)
82
107
 
83
108
 
84
109
  def dynamic_lot(source_lot, source_eqty: float, dest_eqty: float):
85
- if source_eqty == 0 or dest_eqty == 0:
86
- raise ValueError("Source or destination account equity is zero")
87
110
  try:
88
111
  ratio = dest_eqty / source_eqty
89
112
  return round(source_lot * ratio, 2)
@@ -118,6 +141,7 @@ def fixed_lot(lot, symbol, destination) -> float:
118
141
  else:
119
142
  return _check_lot(round(lot), s_info)
120
143
 
144
+
121
145
  def calculate_copy_lot(
122
146
  source_lot,
123
147
  symbol: str,
@@ -173,7 +197,7 @@ def get_symbols_from_string(symbols_string: str):
173
197
 
174
198
  def get_copy_symbols(destination: dict, source: dict) -> List[str] | Dict[str, str]:
175
199
  symbols = destination.get("symbols", "all")
176
- if symbols == "all" or symbols == "*" or isinstance(symbols, list) :
200
+ if symbols == "all" or symbols == "*" or isinstance(symbols, list):
177
201
  src_account = Account(**source)
178
202
  src_symbols = src_account.get_symbols()
179
203
  dest_account = Account(**destination)
@@ -360,7 +384,10 @@ class TradeCopier(object):
360
384
  for destination in self.destinations:
361
385
  destination["copy"] = destination.get("copy", True)
362
386
 
363
- def log_message(self, message, type="info"):
387
+ def log_message(
388
+ self, message, type: Literal["info", "error", "debug", "warning"] = "info"
389
+ ):
390
+ logger.trace
364
391
  if self.log_queue:
365
392
  try:
366
393
  now = datetime.now()
@@ -370,7 +397,7 @@ class TradeCopier(object):
370
397
  )
371
398
  space = len("exception") # longest log name
372
399
  self.log_queue.put(
373
- f"{formatted} |{type.upper()} {' '*(space - len(type))} | - {message}"
400
+ f"{formatted} |{type.upper()} {' ' * (space - len(type))} | - {message}"
374
401
  )
375
402
  except Exception:
376
403
  pass
@@ -384,8 +411,8 @@ class TradeCopier(object):
384
411
  error_msg = repr(e)
385
412
  if error_msg not in self.errors:
386
413
  self.errors.add(error_msg)
387
- add_msg = f"SYMBOL={symbol}" if symbol else ""
388
- message = f"Error encountered: {error_msg}, {add_msg}"
414
+ add_msg = f", SYMBOL={symbol}" if symbol else ""
415
+ message = f"Error encountered: {error_msg}{add_msg}"
389
416
  self.log_message(message, type="error")
390
417
 
391
418
  def _validate_source(self):
@@ -487,6 +514,14 @@ class TradeCopier(object):
487
514
  if new_result.retcode == Mt5.TRADE_RETCODE_DONE:
488
515
  break
489
516
  return new_result
517
+
518
+ def handle_retcode(self, retcode) -> int:
519
+ if retcode in STOP_RETCODES:
520
+ msg = trade_retcode_message(retcode)
521
+ self.log_error(f"Critical Error on @{self.source['login']}: {msg} ")
522
+ self.stop()
523
+ if retcode in RETURN_RETCODE:
524
+ return 1
490
525
 
491
526
  def copy_new_trade(self, trade: TradeOrder | TradePosition, destination: dict):
492
527
  if not self.iscopy_time():
@@ -508,7 +543,6 @@ class TradeCopier(object):
508
543
  trade_action = (
509
544
  Mt5.TRADE_ACTION_DEAL if trade.type in [0, 1] else Mt5.TRADE_ACTION_PENDING
510
545
  )
511
- action = ORDER_TYPE[trade.type][1]
512
546
  tick = Mt5.symbol_info_tick(symbol)
513
547
  price = tick.bid if trade.type == 0 else tick.ask
514
548
  try:
@@ -535,14 +569,18 @@ class TradeCopier(object):
535
569
  result = Mt5.order_send(request)
536
570
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
537
571
  result = self._update_filling_type(request, result)
572
+ action = ORDER_TYPE[trade.type][1]
573
+ copy_action = "Position" if trade.type in [0, 1] else "Order"
538
574
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
539
575
  self.log_message(
540
- f"Copy {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
576
+ f"Copy {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
541
577
  f"to @{destination.get('login')}::{symbol}",
542
578
  )
543
579
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
580
+ if self.handle_retcode(result.retcode) == 1:
581
+ return
544
582
  self.log_message(
545
- f"Error copying {action} Order #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
583
+ f"Error copying {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
546
584
  f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
547
585
  type="error",
548
586
  )
@@ -572,9 +610,9 @@ class TradeCopier(object):
572
610
  f"Modify {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol}, "
573
611
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
574
612
  )
575
- if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
576
- return
577
613
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
614
+ if self.handle_retcode(result.retcode) == 1:
615
+ return
578
616
  self.log_message(
579
617
  f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
580
618
  f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
@@ -597,6 +635,8 @@ class TradeCopier(object):
597
635
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
598
636
  )
599
637
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
638
+ if self.handle_retcode(result.retcode) == 1:
639
+ return
600
640
  self.log_message(
601
641
  f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
602
642
  f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
@@ -626,9 +666,9 @@ class TradeCopier(object):
626
666
  f"Modify {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
627
667
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
628
668
  )
629
- if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
630
- return
631
669
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
670
+ if self.handle_retcode(result.retcode) == 1:
671
+ return
632
672
  self.log_message(
633
673
  f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
634
674
  f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
@@ -663,6 +703,8 @@ class TradeCopier(object):
663
703
  f"SOURCE=@{self.source.get('login')}::{src_symbol}"
664
704
  )
665
705
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
706
+ if self.handle_retcode(result.retcode) == 1:
707
+ return
666
708
  self.log_message(
667
709
  f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
668
710
  f"on @{destination.get('login')}::{position.symbol}, "
@@ -1023,7 +1065,8 @@ class TradeCopier(object):
1023
1065
  self.destinations
1024
1066
  ):
1025
1067
  self.log_message(
1026
- "Two or more destination accounts have the same Terminal path, which is not allowed."
1068
+ "Two or more destination accounts have the same Terminal path, which is not allowed.",
1069
+ type="error",
1027
1070
  )
1028
1071
  return
1029
1072
 
@@ -1134,29 +1177,45 @@ def RunCopier(
1134
1177
  shutdown_event=None,
1135
1178
  log_queue=None,
1136
1179
  ):
1137
- """Initializes and runs a TradeCopier instance in a single process.
1180
+ """
1181
+ Initialize and run a TradeCopier instance in a single process.
1138
1182
 
1139
1183
  This function serves as a straightforward wrapper to start a copying session
1140
1184
  that handles one source account and one or more destination accounts
1141
- *sequentially* within the same thread. It does not create any new processes itself.
1185
+ sequentially within the same thread. It does not create any new processes itself.
1142
1186
 
1143
- This is useful for:
1144
- - Simpler, command-line based use cases.
1145
- - Scenarios where parallelism is not required.
1146
- - As the target for `RunMultipleCopier`, where each process handles a
1187
+ Use Cases
1188
+ ---------
1189
+ * Simpler, command-line based use cases.
1190
+ * Scenarios where parallelism is not required.
1191
+ * As the target for ``RunMultipleCopier``, where each process handles a
1147
1192
  full source-to-destinations session.
1148
1193
 
1149
- Args:
1150
- source (dict): Configuration dictionary for the source account.
1151
- destinations (list): A list of configuration dictionaries, one for each
1152
- destination account to be processed sequentially.
1153
- sleeptime (float): The time in seconds to wait after completing a full
1154
- cycle through all destinations.
1155
- start_time (str): The time of day to start copying (e.g., "08:00").
1156
- end_time (str): The time of day to stop copying (e.g., "22:00").
1157
- custom_logger: An optional custom logger instance.
1158
- shutdown_event (multiprocessing.Event): An event to signal shutdown.
1159
- log_queue (multiprocessing.Queue): A queue for log messages.
1194
+ Parameters
1195
+ ----------
1196
+ source : dict
1197
+ Configuration dictionary for the source account.
1198
+ destinations : list
1199
+ A list of configuration dictionaries, one for each
1200
+ destination account to be processed sequentially.
1201
+ sleeptime : float
1202
+ The time in seconds to wait after completing a full
1203
+ cycle through all destinations.
1204
+ start_time : str
1205
+ The time of day to start copying (e.g., ``"08:00"``).
1206
+ end_time : str
1207
+ The time of day to stop copying (e.g., ``"22:00"``).
1208
+ custom_logger : logging.Logger, optional
1209
+ An optional custom logger instance.
1210
+ shutdown_event : multiprocessing.Event, optional
1211
+ An event to signal shutdown.
1212
+ log_queue : multiprocessing.Queue, optional
1213
+ A queue for log messages.
1214
+
1215
+ Returns
1216
+ -------
1217
+ None
1218
+ Runs until stopped via ``shutdown_event`` or external interruption.
1160
1219
  """
1161
1220
  copier = TradeCopier(
1162
1221
  source,
@@ -1181,7 +1240,8 @@ def RunMultipleCopier(
1181
1240
  custom_logger=None,
1182
1241
  log_queue=None,
1183
1242
  ):
1184
- """Manages multiple, independent trade copying sessions in parallel.
1243
+ """
1244
+ Manage multiple, independent trade copying sessions in parallel.
1185
1245
 
1186
1246
  This function acts as a high-level manager that takes a list of account
1187
1247
  setups and creates a separate, dedicated process for each one. Each process
@@ -1189,28 +1249,46 @@ def RunMultipleCopier(
1189
1249
  destination accounts.
1190
1250
 
1191
1251
  The parallelism occurs at the **source account level**. Within each spawned
1192
- process, the destinations for that source are handled sequentially by `RunCopier`.
1193
-
1194
- Example `accounts` structure:
1195
- [
1196
- { "source": {...}, "destinations": [{...}, {...}] }, # -> Process 1
1197
- { "source": {...}, "destinations": [{...}] } # -> Process 2
1198
- ]
1199
-
1200
- Args:
1201
- accounts (List[dict]): A list of account configurations. Each item in the
1202
- list must be a dictionary with a 'source' key and a 'destinations' key.
1203
- sleeptime (float): The sleep time passed down to each `RunCopier` process.
1204
- start_delay (float): A delay in seconds between starting each new process.
1205
- This helps prevent resource contention by staggering the initialization
1206
- of multiple MetaTrader 5 terminals.
1207
- start_time (str): The start time passed down to each `RunCopier` process.
1208
- end_time (str): The end time passed down to each `RunCopier` process.
1209
- shutdown_event (multiprocessing.Event): An event to signal shutdown to all
1210
- child processes.
1211
- custom_logger: An optional custom logger instance.
1212
- log_queue (multiprocessing.Queue): A queue for aggregating log messages
1213
- from all child processes.
1252
+ process, the destinations for that source are handled sequentially by
1253
+ ``RunCopier``.
1254
+
1255
+ Example
1256
+ -------
1257
+ An example ``accounts`` structure:
1258
+
1259
+ .. code-block:: python
1260
+
1261
+ accounts = [
1262
+ {"source": {...}, "destinations": [{...}, {...}]}, # -> Process 1
1263
+ {"source": {...}, "destinations": [{...}]} # -> Process 2
1264
+ ]
1265
+
1266
+ Parameters
1267
+ ----------
1268
+ accounts : list of dict
1269
+ A list of account configurations. Each item must be a dictionary with
1270
+ a ``source`` key and a ``destinations`` key.
1271
+ sleeptime : float, optional
1272
+ The sleep time passed down to each ``RunCopier`` process.
1273
+ start_delay : float, optional
1274
+ A delay in seconds between starting each new process.
1275
+ Helps prevent resource contention by staggering the initialization of
1276
+ multiple MetaTrader 5 terminals.
1277
+ start_time : str, optional
1278
+ The start time passed down to each ``RunCopier`` process.
1279
+ end_time : str, optional
1280
+ The end time passed down to each ``RunCopier`` process.
1281
+ shutdown_event : multiprocessing.Event, optional
1282
+ An event to signal shutdown to all child processes.
1283
+ custom_logger : logging.Logger, optional
1284
+ An optional custom logger instance.
1285
+ log_queue : multiprocessing.Queue, optional
1286
+ A queue for aggregating log messages from all child processes.
1287
+
1288
+ Returns
1289
+ -------
1290
+ None
1291
+ Runs until stopped via ``shutdown_event`` or external interruption.
1214
1292
  """
1215
1293
  processes = []
1216
1294
 
@@ -95,27 +95,16 @@ class TradeSignal:
95
95
  """
96
96
  Represents a trading signal generated by a trading system or strategy.
97
97
 
98
+ Notes
99
+ -----
98
100
  Attributes:
99
- id (int):
100
- A unique identifier for the trade signal or the strategy.
101
101
 
102
- symbol (str):
103
- The trading symbol (e.g., stock ticker, forex pair, crypto asset)
104
- related to the signal.
105
-
106
- action (TradeAction):
107
- The trading action to perform.
108
- Must be an instance of the `TradeAction` enum (e.g., BUY, SELL).
109
-
110
- price (float, optional):
111
- The price at which the trade should be executed.
112
-
113
- stoplimit (float, optional):
114
- A stop-limit price for the trade.
115
- Must not be set without specifying a price.
116
-
117
- comment (str, optional):
118
- An optional comment or description related to the trade signal.
102
+ - id (int): A unique identifier for the trade signal or the strategy.
103
+ - symbol (str): The trading symbol (e.g., stock ticker, forex pair, crypto asset).
104
+ - action (TradeAction): The trading action to perform. Must be an instance of the ``TradeAction`` enum (e.g., BUY, SELL).
105
+ - price (float, optional): The price at which the trade should be executed.
106
+ - stoplimit (float, optional): A stop-limit price for the trade. Must not be set without specifying a price.
107
+ - comment (str, optional): An optional comment or description related to the trade signal.
119
108
  """
120
109
 
121
110
  id: int
@@ -776,18 +765,19 @@ class Trade(RiskManagement):
776
765
  # Check the execution result
777
766
  pos = self._order_type()[type][1]
778
767
  addtionnal = f", SYMBOL={self.symbol}"
768
+ result = None
779
769
  try:
780
770
  self.check_order(request)
781
771
  result = self.send_order(request)
782
772
  except Exception as e:
783
- msg = trade_retcode_message(result.retcode)
773
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
784
774
  LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
785
- if result.retcode != Mt5.TRADE_RETCODE_DONE:
775
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
786
776
  if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
787
777
  for fill in FILLING_TYPE:
788
778
  request["type_filling"] = fill
789
779
  result = self.send_order(request)
790
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
780
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
791
781
  break
792
782
  elif result.retcode == Mt5.TRADE_RETCODE_INVALID_VOLUME: # 10014
793
783
  new_volume = int(request["volume"])
@@ -811,13 +801,13 @@ class Trade(RiskManagement):
811
801
  self.check_order(request)
812
802
  result = self.send_order(request)
813
803
  except Exception as e:
814
- msg = trade_retcode_message(result.retcode)
804
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
815
805
  LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
816
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
806
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
817
807
  break
818
808
  tries += 1
819
809
  # Print the result
820
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
810
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
821
811
  msg = trade_retcode_message(result.retcode)
822
812
  LOGGER.info(f"Trade Order {msg}{addtionnal}")
823
813
  if type != "BMKT" or type != "SMKT":
@@ -854,7 +844,7 @@ class Trade(RiskManagement):
854
844
  LOGGER.info(pos_info)
855
845
  return True
856
846
  else:
857
- msg = trade_retcode_message(result.retcode)
847
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
858
848
  LOGGER.error(
859
849
  f"Unable to Open Position, RETCODE={result.retcode}: {msg}{addtionnal}"
860
850
  )
@@ -1325,14 +1315,15 @@ class Trade(RiskManagement):
1325
1315
  request (dict): The request to set the stop loss to break even.
1326
1316
  """
1327
1317
  addtionnal = f", SYMBOL={self.symbol}"
1318
+ result = None
1328
1319
  time.sleep(0.1)
1329
1320
  try:
1330
1321
  self.check_order(request)
1331
1322
  result = self.send_order(request)
1332
1323
  except Exception as e:
1333
- msg = trade_retcode_message(result.retcode)
1324
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1334
1325
  LOGGER.error(f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}")
1335
- if result.retcode != Mt5.TRADE_RETCODE_DONE:
1326
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
1336
1327
  msg = trade_retcode_message(result.retcode)
1337
1328
  if result.retcode != Mt5.TRADE_RETCODE_NO_CHANGES:
1338
1329
  LOGGER.error(
@@ -1348,14 +1339,14 @@ class Trade(RiskManagement):
1348
1339
  self.check_order(request)
1349
1340
  result = self.send_order(request)
1350
1341
  except Exception as e:
1351
- msg = trade_retcode_message(result.retcode)
1342
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1352
1343
  LOGGER.error(
1353
1344
  f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}"
1354
1345
  )
1355
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
1346
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1356
1347
  break
1357
1348
  tries += 1
1358
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
1349
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1359
1350
  msg = trade_retcode_message(result.retcode)
1360
1351
  LOGGER.info(f"Break-Even Order {msg}{addtionnal}")
1361
1352
  info = f"Stop loss set to Break-even, Position: #{tiket}, Symbol: {self.symbol}, Price: @{round(price, 5)}"
@@ -1432,15 +1423,17 @@ class Trade(RiskManagement):
1432
1423
  """
1433
1424
  ticket = request[type]
1434
1425
  addtionnal = f", SYMBOL={self.symbol}"
1426
+ result = None
1435
1427
  try:
1436
1428
  self.check_order(request)
1437
1429
  result = self.send_order(request)
1438
1430
  except Exception as e:
1439
- msg = trade_retcode_message(result.retcode)
1431
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1440
1432
  LOGGER.error(
1441
- f"Closing {type.capitalize()} Request, {msg}{addtionnal}, Error: {e}"
1433
+ f"Closing {type.capitalize()} Request, RETCODE={msg}{addtionnal}, Error: {e}"
1442
1434
  )
1443
- if result.retcode != Mt5.TRADE_RETCODE_DONE:
1435
+
1436
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
1444
1437
  if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
1445
1438
  for fill in FILLING_TYPE:
1446
1439
  request["type_filling"] = fill
@@ -1462,14 +1455,14 @@ class Trade(RiskManagement):
1462
1455
  self.check_order(request)
1463
1456
  result = self.send_order(request)
1464
1457
  except Exception as e:
1465
- msg = trade_retcode_message(result.retcode)
1458
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1466
1459
  LOGGER.error(
1467
1460
  f"Closing {type.capitalize()} Request, {msg}{addtionnal}, Error: {e}"
1468
1461
  )
1469
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
1462
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1470
1463
  break
1471
1464
  tries += 1
1472
- if result.retcode == Mt5.TRADE_RETCODE_DONE:
1465
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1473
1466
  msg = trade_retcode_message(result.retcode)
1474
1467
  LOGGER.info(f"Closing Order {msg}{addtionnal}")
1475
1468
  info = (
@@ -1504,7 +1497,7 @@ class Trade(RiskManagement):
1504
1497
  orders = self.get_orders(ticket=ticket) or []
1505
1498
  if len(orders) == 0:
1506
1499
  LOGGER.error(
1507
- f"Order #{ticket} not found, SYMBOL={self.symbol}, PRICE={round(price, 5)}"
1500
+ f"Order #{ticket} not found, SYMBOL={self.symbol}, PRICE={round(price, 5) if price else 'N/A'}"
1508
1501
  )
1509
1502
  return
1510
1503
  order = orders[0]
@@ -1520,8 +1513,8 @@ class Trade(RiskManagement):
1520
1513
  result = self.send_order(request)
1521
1514
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1522
1515
  LOGGER.info(
1523
- f"Order #{ticket} modified, SYMBOL={self.symbol}, PRICE={round(price, 5)},"
1524
- f"SL={round(sl, 5)}, TP={round(tp, 5)}, STOP_LIMIT={round(stoplimit, 5)}"
1516
+ f"Order #{ticket} modified, SYMBOL={self.symbol}, PRICE={round(request['price'], 5)},"
1517
+ f"SL={round(request['sl'], 5)}, TP={round(request['tp'], 5)}, STOP_LIMIT={round(request['stoplimit'], 5)}"
1525
1518
  )
1526
1519
  else:
1527
1520
  msg = trade_retcode_message(result.retcode)
@@ -1835,15 +1828,16 @@ class Trade(RiskManagement):
1835
1828
 
1836
1829
  def sleep_time(self, weekend=False):
1837
1830
  if weekend:
1838
- # claculate number of minute from the friday and to monday start
1839
- friday_time = datetime.strptime(self.current_time(), "%H:%M")
1831
+ # calculate number of minute from now and monday start
1832
+ multiplyer = {"friday": 3, "saturday": 2, "sunday": 1}
1833
+ current_time = datetime.strptime(self.current_time(), "%H:%M")
1840
1834
  monday_time = datetime.strptime(self.start, "%H:%M")
1841
- intra_day_diff = (monday_time - friday_time).total_seconds() // 60
1842
- inter_day_diff = 3 * 24 * 60
1835
+ intra_day_diff = (monday_time - current_time).total_seconds() // 60
1836
+ inter_day_diff = multiplyer[datetime.now().strftime("%A").lower()] * 24 * 60
1843
1837
  total_minutes = inter_day_diff + intra_day_diff
1844
1838
  return total_minutes
1845
1839
  else:
1846
- # claculate number of minute from the end to the start
1840
+ # calculate number of minute from the end to the start
1847
1841
  start = datetime.strptime(self.start, "%H:%M")
1848
1842
  end = datetime.strptime(self.current_time(), "%H:%M")
1849
1843
  minutes = (end - start).total_seconds() // 60
@@ -607,7 +607,8 @@ class AutoTradingDisabled(MT5TerminalError):
607
607
  class InternalFailError(MT5TerminalError):
608
608
  """Base exception class for internal IPC errors."""
609
609
 
610
- pass
610
+ def __init__(self, code, message):
611
+ super().__init__(code, message)
611
612
 
612
613
 
613
614
  class InternalFailSend(InternalFailError):
@@ -700,9 +701,9 @@ def raise_mt5_error(message: Optional[str] = None):
700
701
  """
701
702
  if message and isinstance(message, Exception):
702
703
  message = str(message)
703
- error = _ERROR_CODE_TO_EXCEPTION_.get(MT5.last_error()[0])
704
- if error is not None:
705
- raise Exception(f"{error(None)} {message or MT5.last_error()[1]}")
704
+ exception = _ERROR_CODE_TO_EXCEPTION_.get(MT5.last_error()[0])
705
+ if exception is not None:
706
+ raise exception(f"{message or MT5.last_error()[1]}")
706
707
  else:
707
708
  raise Exception(f"{message or MT5.last_error()[1]}")
708
709
 
@@ -1,8 +1,9 @@
1
1
  from datetime import datetime
2
- from typing import Dict, List
2
+ from typing import Dict, List, Literal
3
3
 
4
4
  import pandas as pd
5
5
  import yfinance as yf
6
+ from loguru import logger
6
7
 
7
8
  from bbstrader.btengine.data import EODHDataHandler, FMPDataHandler
8
9
  from bbstrader.metatrader.rates import download_historical_data
@@ -16,6 +17,7 @@ __all__ = [
16
17
  "search_coint_candidate_pairs",
17
18
  ]
18
19
 
20
+
19
21
  def _download_and_process_data(source, tickers, start, end, tf, path, **kwargs):
20
22
  """Download and process data for a list of tickers from the specified source."""
21
23
  data_list = []
@@ -43,9 +45,7 @@ def _download_and_process_data(source, tickers, start, end, tf, path, **kwargs):
43
45
  )
44
46
  data = data.drop(columns=["adj_close"], axis=1)
45
47
  elif source in ["fmp", "eodhd"]:
46
- handler_class = (
47
- FMPDataHandler if source == "fmp" else EODHDataHandler
48
- )
48
+ handler_class = FMPDataHandler if source == "fmp" else EODHDataHandler
49
49
  handler = handler_class(events=None, symbol_list=[ticker], **kwargs)
50
50
  data = handler.data[ticker]
51
51
  else:
@@ -62,6 +62,7 @@ def _download_and_process_data(source, tickers, start, end, tf, path, **kwargs):
62
62
 
63
63
  return pd.concat(data_list)
64
64
 
65
+
65
66
  def _handle_date_range(start, end, window):
66
67
  """Handle start and end date generation."""
67
68
  if start is None or end is None:
@@ -73,6 +74,7 @@ def _handle_date_range(start, end, window):
73
74
  ).strftime("%Y-%m-%d")
74
75
  return start, end
75
76
 
77
+
76
78
  def _period_search(start, end, securities, candidates, window, npairs):
77
79
  if window < 3 or (pd.Timestamp(end) - pd.Timestamp(start)).days / 365 < 3:
78
80
  raise ValueError(
@@ -103,14 +105,11 @@ def _period_search(start, end, securities, candidates, window, npairs):
103
105
  )
104
106
  return top_pairs.head(npairs * 2)
105
107
 
108
+
106
109
  def _process_asset_data(securities, candidates, universe, rolling_window):
107
110
  """Process and select assets from the data."""
108
- securities = select_assets(
109
- securities, n=universe, rolling_window=rolling_window
110
- )
111
- candidates = select_assets(
112
- candidates, n=universe, rolling_window=rolling_window
113
- )
111
+ securities = select_assets(securities, n=universe, rolling_window=rolling_window)
112
+ candidates = select_assets(candidates, n=universe, rolling_window=rolling_window)
114
113
  return securities, candidates
115
114
 
116
115
 
@@ -121,7 +120,7 @@ def search_coint_candidate_pairs(
121
120
  end: str = None,
122
121
  period_search: bool = False,
123
122
  select: bool = True,
124
- source: str = None,
123
+ source: Literal["yf", "mt5", "fmp", "eodhd"] = None,
125
124
  universe: int = 100,
126
125
  window: int = 2,
127
126
  rolling_window: int = None,
@@ -257,7 +256,9 @@ def search_coint_candidate_pairs(
257
256
  if period_search:
258
257
  start = securities.index.get_level_values("date").min()
259
258
  end = securities.index.get_level_values("date").max()
260
- top_pairs = _period_search(start, end, securities, candidates, window, npairs)
259
+ top_pairs = _period_search(
260
+ start, end, securities, candidates, window, npairs
261
+ )
261
262
  else:
262
263
  top_pairs = find_cointegrated_pairs(
263
264
  securities, candidates, n=npairs, coint=True
@@ -286,6 +287,10 @@ def search_coint_candidate_pairs(
286
287
  candidates_data = _download_and_process_data(
287
288
  source, candidates, start, end, tf, path, **kwargs
288
289
  )
290
+ if securities_data.empty or candidates_data.empty:
291
+ logger.error("No data found for candidates and securities")
292
+ return [] if select else pd.DataFrame()
293
+
289
294
  securities_data = securities_data.set_index(["ticker", "date"])
290
295
  candidates_data = candidates_data.set_index(["ticker", "date"])
291
296
  securities_data, candidates_data = _process_asset_data(
@@ -305,7 +310,6 @@ def search_coint_candidate_pairs(
305
310
  )
306
311
  else:
307
312
  return top_pairs
308
-
309
313
  else:
310
314
  msg = (
311
315
  "Invalid input. Either provide securities"