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.
- bbstrader/__init__.py +10 -1
- bbstrader/__main__.py +5 -0
- bbstrader/apps/_copier.py +3 -3
- bbstrader/btengine/strategy.py +113 -38
- bbstrader/compat.py +18 -10
- bbstrader/config.py +0 -16
- bbstrader/core/scripts.py +4 -3
- bbstrader/metatrader/account.py +51 -26
- bbstrader/metatrader/analysis.py +30 -16
- bbstrader/metatrader/copier.py +136 -58
- bbstrader/metatrader/trade.py +39 -45
- bbstrader/metatrader/utils.py +5 -4
- bbstrader/models/factors.py +17 -13
- bbstrader/models/ml.py +96 -49
- bbstrader/models/nlp.py +83 -66
- bbstrader/trading/execution.py +39 -22
- bbstrader/tseries.py +103 -127
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/METADATA +29 -46
- bbstrader-0.3.6.dist-info/RECORD +62 -0
- bbstrader-0.3.6.dist-info/top_level.txt +3 -0
- docs/conf.py +56 -0
- tests/__init__.py +0 -0
- tests/engine/__init__.py +1 -0
- tests/engine/test_backtest.py +58 -0
- tests/engine/test_data.py +536 -0
- tests/engine/test_events.py +300 -0
- tests/engine/test_execution.py +219 -0
- tests/engine/test_portfolio.py +307 -0
- tests/metatrader/__init__.py +0 -0
- tests/metatrader/test_account.py +1769 -0
- tests/metatrader/test_rates.py +292 -0
- tests/metatrader/test_risk_management.py +700 -0
- tests/metatrader/test_trade.py +439 -0
- bbstrader-0.3.4.dist-info/RECORD +0 -49
- bbstrader-0.3.4.dist-info/top_level.txt +0 -1
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.4.dist-info → bbstrader-0.3.6.dist-info}/licenses/LICENSE +0 -0
bbstrader/metatrader/copier.py
CHANGED
|
@@ -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
|
|
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(
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
"""
|
|
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
|
-
|
|
1185
|
+
sequentially within the same thread. It does not create any new processes itself.
|
|
1142
1186
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
"""
|
|
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
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
accounts
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
|
bbstrader/metatrader/trade.py
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1839
|
-
|
|
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 -
|
|
1842
|
-
inter_day_diff =
|
|
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
|
-
#
|
|
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
|
bbstrader/metatrader/utils.py
CHANGED
|
@@ -607,7 +607,8 @@ class AutoTradingDisabled(MT5TerminalError):
|
|
|
607
607
|
class InternalFailError(MT5TerminalError):
|
|
608
608
|
"""Base exception class for internal IPC errors."""
|
|
609
609
|
|
|
610
|
-
|
|
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
|
-
|
|
704
|
-
if
|
|
705
|
-
raise
|
|
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
|
|
bbstrader/models/factors.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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"
|