bbstrader 0.2.4__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.

@@ -0,0 +1,779 @@
1
+ import string
2
+ from abc import ABCMeta, abstractmethod
3
+ from datetime import datetime
4
+ from queue import Queue
5
+ from typing import Dict, List, Literal, Union
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import pytz
10
+
11
+ from bbstrader.btengine.data import DataHandler
12
+ from bbstrader.btengine.event import FillEvent, SignalEvent
13
+ from bbstrader.metatrader.account import Account, AdmiralMarktsGroup
14
+ from bbstrader.metatrader.rates import Rates
15
+ from bbstrader.models.optimization import optimized_weights
16
+ from bbstrader.core.utils import TradeSignal
17
+
18
+ __all__ = ["Strategy", "MT5Strategy"]
19
+
20
+
21
+ class Strategy(metaclass=ABCMeta):
22
+ """
23
+ A `Strategy()` object encapsulates all calculation on market data
24
+ that generate advisory signals to a `Portfolio` object. Thus all of
25
+ the "strategy logic" resides within this class. We opted to separate
26
+ out the `Strategy` and `Portfolio` objects for this backtester,
27
+ since we believe this is more amenable to the situation of multiple
28
+ strategies feeding "ideas" to a larger `Portfolio`, which then can handle
29
+ its own risk (such as sector allocation, leverage). In higher frequency trading,
30
+ the strategy and portfolio concepts will be tightly coupled and extremely
31
+ hardware dependent.
32
+
33
+ At this stage in the event-driven backtester development there is no concept of
34
+ an indicator or filter, such as those found in technical trading. These are also
35
+ good candidates for creating a class hierarchy.
36
+
37
+ The strategy hierarchy is relatively simple as it consists of an abstract
38
+ base class with a single pure virtual method for generating `SignalEvent` objects.
39
+ Other methods are provided to check for pending orders, update trades from fills,
40
+ and get updates from the portfolio.
41
+ """
42
+
43
+ @abstractmethod
44
+ def calculate_signals(self, *args, **kwargs) -> List[TradeSignal]:
45
+ raise NotImplementedError("Should implement calculate_signals()")
46
+
47
+ def check_pending_orders(self, *args, **kwargs): ...
48
+ def get_update_from_portfolio(self, *args, **kwargs): ...
49
+ def update_trades_from_fill(self, *args, **kwargs): ...
50
+ def perform_period_end_checks(self, *args, **kwargs): ...
51
+
52
+
53
+ class MT5Strategy(Strategy):
54
+ """
55
+ A `MT5Strategy()` object is a subclass of `Strategy` that is used to
56
+ calculate signals for the MetaTrader 5 trading platform. The signals
57
+ are generated by the `MT5Strategy` object and sent to the the `MT5ExecutionEngine`
58
+ for live trading and `MT5BacktestEngine` objects for backtesting.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ events: Queue = None,
64
+ symbol_list: List[str] = None,
65
+ bars: DataHandler = None,
66
+ mode: str = None,
67
+ **kwargs,
68
+ ):
69
+ """
70
+ Initialize the `MT5Strategy` object.
71
+
72
+ Args:
73
+ events : The event queue.
74
+ symbol_list : The list of symbols for the strategy.
75
+ bars : The data handler object.
76
+ mode : The mode of operation for the strategy (backtest or live).
77
+ **kwargs : Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler).
78
+ - max_trades : The maximum number of trades allowed per symbol.
79
+ - time_frame : The time frame for the strategy.
80
+ - logger : The logger object for the strategy.
81
+ """
82
+ self.events = events
83
+ self.data = bars
84
+ self.symbols = symbol_list
85
+ self.mode = mode
86
+ self._porfolio_value = None
87
+ self.risk_budget = self._check_risk_budget(**kwargs)
88
+ self.max_trades = kwargs.get("max_trades", {s: 1 for s in self.symbols})
89
+ self.tf = kwargs.get("time_frame", "D1")
90
+ self.logger = kwargs.get("logger")
91
+ if self.mode == "backtest":
92
+ self._initialize_portfolio()
93
+ self.kwargs = kwargs
94
+
95
+ @property
96
+ def cash(self) -> float:
97
+ return self._porfolio_value
98
+
99
+ @cash.setter
100
+ def cash(self, value):
101
+ self._porfolio_value = value
102
+
103
+ @property
104
+ def orders(self) -> Dict[str, Dict[str, List[SignalEvent]]]:
105
+ return self._orders
106
+
107
+ @property
108
+ def trades(self) -> Dict[str, Dict[str, int]]:
109
+ return self._trades
110
+
111
+ @property
112
+ def positions(self) -> Dict[str, Dict[str, int | float]]:
113
+ return self._positions
114
+
115
+ @property
116
+ def holdings(self) -> Dict[str, float]:
117
+ return self._holdings
118
+
119
+ def _check_risk_budget(self, **kwargs):
120
+ weights = kwargs.get("risk_weights")
121
+ if weights is not None and isinstance(weights, dict):
122
+ for asset in self.symbols:
123
+ if asset not in weights:
124
+ raise ValueError(f"Risk budget for asset {asset} is missing.")
125
+ total_risk = sum(weights.values())
126
+ if not np.isclose(total_risk, 1.0):
127
+ raise ValueError(f"Risk budget weights must sum to 1. got {total_risk}")
128
+ return weights
129
+ elif isinstance(weights, str):
130
+ return weights
131
+
132
+ def _initialize_portfolio(self):
133
+ positions = ["LONG", "SHORT"]
134
+ orders = ["BLMT", "BSTP", "BSTPLMT", "SLMT", "SSTP", "SSTPLMT"]
135
+ self._positions: Dict[str, Dict[str, int | float]] = {}
136
+ self._trades: Dict[str, Dict[str, int]] = {}
137
+ self._orders: Dict[str, Dict[str, List[SignalEvent]]] = {}
138
+ for symbol in self.symbols:
139
+ self._positions[symbol] = {}
140
+ self._orders[symbol] = {}
141
+ self._trades[symbol] = {}
142
+ for position in positions:
143
+ self._trades[symbol][position] = 0
144
+ self._positions[symbol][position] = 0.0
145
+ for order in orders:
146
+ self._orders[symbol][order] = []
147
+ self._holdings = {s: 0.0 for s in self.symbols}
148
+
149
+ def get_update_from_portfolio(self, positions, holdings):
150
+ """
151
+ Update the positions and holdings for the strategy from the portfolio.
152
+
153
+ Positions are the number of shares of a security that are owned in long or short.
154
+ Holdings are the value (postions * price) of the security that are owned in long or short.
155
+
156
+ Args:
157
+ positions : The positions for the symbols in the strategy.
158
+ holdings : The holdings for the symbols in the strategy.
159
+ """
160
+ for symbol in self.symbols:
161
+ if symbol in positions:
162
+ if positions[symbol] > 0:
163
+ self._positions[symbol]["LONG"] = positions[symbol]
164
+ elif positions[symbol] < 0:
165
+ self._positions[symbol]["SHORT"] = positions[symbol]
166
+ else:
167
+ self._positions[symbol]["LONG"] = 0
168
+ self._positions[symbol]["SHORT"] = 0
169
+ if symbol in holdings:
170
+ self._holdings[symbol] = holdings[symbol]
171
+
172
+ def update_trades_from_fill(self, event: FillEvent):
173
+ """
174
+ This method updates the trades for the strategy based on the fill event.
175
+ It is used to keep track of the number of trades executed for each order.
176
+ """
177
+ if event.type == "FILL":
178
+ if event.order != "EXIT":
179
+ self._trades[event.symbol][event.order] += 1
180
+ elif event.order == "EXIT" and event.direction == "BUY":
181
+ self._trades[event.symbol]["SHORT"] = 0
182
+ elif event.order == "EXIT" and event.direction == "SELL":
183
+ self._trades[event.symbol]["LONG"] = 0
184
+
185
+ def calculate_signals(self, *args, **kwargs) -> List[TradeSignal]:
186
+ """
187
+ Provides the mechanisms to calculate signals for the strategy.
188
+ This methods should return a list of signals for the strategy.
189
+
190
+ Each signal must be a ``TradeSignal`` object with the following attributes:
191
+ - ``action``: The order to execute on the symbol (LONG, SHORT, EXIT, etc.), see `bbstrader.core.utils.TradeAction`.
192
+ - ``price``: The price at which to execute the action, used for pending orders.
193
+ - ``stoplimit``: The stop-limit price for STOP-LIMIT orders, used for pending stop limit orders.
194
+ - ``id``: The unique identifier for the strategy or order.
195
+ """
196
+ pass
197
+
198
+ def perform_period_end_checks(self, *args, **kwargs):
199
+ """
200
+ Some strategies may require additional checks at the end of the period,
201
+ such as closing all positions or orders or tracking the performance of the strategy etc.
202
+
203
+ This method is called at the end of the period to perform such checks.
204
+ """
205
+ pass
206
+
207
+ def apply_risk_management(
208
+ self, optimer, symbols=None, freq=252
209
+ ) -> Dict[str, float] | None:
210
+ """
211
+ Apply risk management rules to the strategy.
212
+ """
213
+ if optimer is None:
214
+ return None
215
+ symbols = symbols or self.symbols
216
+ prices = self.get_asset_values(
217
+ symbol_list=symbols,
218
+ bars=self.data,
219
+ mode=self.mode,
220
+ window=freq,
221
+ value_type="close",
222
+ array=False,
223
+ tf=self.tf,
224
+ )
225
+ prices = pd.DataFrame(prices)
226
+ prices = prices.dropna(axis=0, how="any")
227
+ try:
228
+ weights = optimized_weights(prices=prices, freq=freq, method=optimer)
229
+ return {symbol: weight for symbol, weight in weights.items()}
230
+ except Exception:
231
+ return {symbol: 0.0 for symbol in symbols}
232
+
233
+ def get_quantity(self, symbol, weight, price=None, volume=None, maxqty=None) -> int:
234
+ """
235
+ Calculate the quantity to buy or sell for a given symbol based on the dollar value provided.
236
+ The quantity calculated can be used to evalute a strategy's performance for each symbol
237
+ given the fact that the dollar value is the same for all symbols.
238
+
239
+ Args:
240
+ symbol : The symbol for the trade.
241
+
242
+ Returns:
243
+ qty : The quantity to buy or sell for the symbol.
244
+ """
245
+ if (
246
+ self._porfolio_value is None
247
+ or weight == 0
248
+ or self._porfolio_value == 0
249
+ or np.isnan(self._porfolio_value)
250
+ ):
251
+ return 0
252
+ if volume is None:
253
+ volume = round(self._porfolio_value * weight)
254
+ if price is None:
255
+ price = self.data.get_latest_bar_value(symbol, "close")
256
+ if (
257
+ price is None
258
+ or not isinstance(price, (int, float, np.number))
259
+ or volume is None
260
+ or not isinstance(volume, (int, float, np.number))
261
+ or np.isnan(float(price))
262
+ or np.isnan(float(volume))
263
+ ):
264
+ if weight != 0:
265
+ return 1
266
+ return 0
267
+ qty = round(volume / price, 2)
268
+ qty = max(qty, 0) / self.max_trades[symbol]
269
+ if maxqty is not None:
270
+ qty = min(qty, maxqty)
271
+ return max(round(qty, 2), 0)
272
+
273
+ def get_quantities(self, quantities: Union[None, dict, int]) -> dict:
274
+ """
275
+ Get the quantities to buy or sell for the symbols in the strategy.
276
+ This method is used when whe need to assign different quantities to the symbols.
277
+
278
+ Args:
279
+ quantities : The quantities for the symbols in the strategy.
280
+ """
281
+ if quantities is None:
282
+ return {symbol: None for symbol in self.symbols}
283
+ if isinstance(quantities, dict):
284
+ return quantities
285
+ elif isinstance(quantities, int):
286
+ return {symbol: quantities for symbol in self.symbols}
287
+
288
+ def _send_order(
289
+ self,
290
+ id,
291
+ symbol: str,
292
+ signal: str,
293
+ strength: float,
294
+ price: float,
295
+ quantity: int,
296
+ dtime: datetime | pd.Timestamp,
297
+ ):
298
+ position = SignalEvent(
299
+ id, symbol, dtime, signal, quantity=quantity, strength=strength, price=price
300
+ )
301
+ log = False
302
+ if signal in ["LONG", "SHORT"]:
303
+ if self._trades[symbol][signal] < self.max_trades[symbol] and quantity > 0:
304
+ self.events.put(position)
305
+ log = True
306
+ elif signal == "EXIT":
307
+ if (
308
+ self._positions[symbol]["LONG"] > 0
309
+ or self._positions[symbol]["SHORT"] < 0
310
+ ):
311
+ self.events.put(position)
312
+ log = True
313
+ if log:
314
+ self.logger.info(
315
+ f"{signal} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={quantity}, PRICE @{price}",
316
+ custom_time=dtime,
317
+ )
318
+
319
+ def buy_mkt(
320
+ self,
321
+ id: int,
322
+ symbol: str,
323
+ price: float,
324
+ quantity: int,
325
+ strength: float = 1.0,
326
+ dtime: datetime | pd.Timestamp = None,
327
+ ):
328
+ """
329
+ Open a long position
330
+
331
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
332
+ """
333
+ self._send_order(id, symbol, "LONG", strength, price, quantity, dtime)
334
+
335
+ def sell_mkt(self, id, symbol, price, quantity, strength=1.0, dtime=None):
336
+ """
337
+ Open a short position
338
+
339
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
340
+ """
341
+ self._send_order(id, symbol, "SHORT", strength, price, quantity, dtime)
342
+
343
+ def close_positions(self, id, symbol, price, quantity, strength=1.0, dtime=None):
344
+ """
345
+ Close a position or exit all positions
346
+
347
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
348
+ """
349
+ self._send_order(id, symbol, "EXIT", strength, price, quantity, dtime)
350
+
351
+ def buy_stop(self, id, symbol, price, quantity, strength=1.0, dtime=None):
352
+ """
353
+ Open a pending order to buy at a stop price
354
+
355
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
356
+ """
357
+ current_price = self.data.get_latest_bar_value(symbol, "close")
358
+ if price <= current_price:
359
+ raise ValueError(
360
+ "The buy_stop price must be greater than the current price."
361
+ )
362
+ order = SignalEvent(
363
+ id, symbol, dtime, "LONG", quantity=quantity, strength=strength, price=price
364
+ )
365
+ self._orders[symbol]["BSTP"].append(order)
366
+
367
+ def sell_stop(self, id, symbol, price, quantity, strength=1.0, dtime=None):
368
+ """
369
+ Open a pending order to sell at a stop price
370
+
371
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
372
+ """
373
+ current_price = self.data.get_latest_bar_value(symbol, "close")
374
+ if price >= current_price:
375
+ raise ValueError("The sell_stop price must be less than the current price.")
376
+ order = SignalEvent(
377
+ id,
378
+ symbol,
379
+ dtime,
380
+ "SHORT",
381
+ quantity=quantity,
382
+ strength=strength,
383
+ price=price,
384
+ )
385
+ self._orders[symbol]["SSTP"].append(order)
386
+
387
+ def buy_limit(self, id, symbol, price, quantity, strength=1.0, dtime=None):
388
+ """
389
+ Open a pending order to buy at a limit price
390
+
391
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
392
+ """
393
+ current_price = self.data.get_latest_bar_value(symbol, "close")
394
+ if price >= current_price:
395
+ raise ValueError("The buy_limit price must be less than the current price.")
396
+ order = SignalEvent(
397
+ id, symbol, dtime, "LONG", quantity=quantity, strength=strength, price=price
398
+ )
399
+ self._orders[symbol]["BLMT"].append(order)
400
+
401
+ def sell_limit(self, id, symbol, price, quantity, strength=1.0, dtime=None):
402
+ """
403
+ Open a pending order to sell at a limit price
404
+
405
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
406
+ """
407
+ current_price = self.data.get_latest_bar_value(symbol, "close")
408
+ if price <= current_price:
409
+ raise ValueError(
410
+ "The sell_limit price must be greater than the current price."
411
+ )
412
+ order = SignalEvent(
413
+ id,
414
+ symbol,
415
+ dtime,
416
+ "SHORT",
417
+ quantity=quantity,
418
+ strength=strength,
419
+ price=price,
420
+ )
421
+ self._orders[symbol]["SLMT"].append(order)
422
+
423
+ def buy_stop_limit(
424
+ self,
425
+ id: int,
426
+ symbol: str,
427
+ price: float,
428
+ stoplimit: float,
429
+ quantity: int,
430
+ strength: float = 1.0,
431
+ dtime: datetime | pd.Timestamp = None,
432
+ ):
433
+ """
434
+ Open a pending order to buy at a stop-limit price
435
+
436
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
437
+ """
438
+ current_price = self.data.get_latest_bar_value(symbol, "close")
439
+ if price <= current_price:
440
+ raise ValueError(
441
+ f"The stop price {price} must be greater than the current price {current_price}."
442
+ )
443
+ if price >= stoplimit:
444
+ raise ValueError(
445
+ f"The stop-limit price {stoplimit} must be greater than the price {price}."
446
+ )
447
+ order = SignalEvent(
448
+ id,
449
+ symbol,
450
+ dtime,
451
+ "LONG",
452
+ quantity=quantity,
453
+ strength=strength,
454
+ price=price,
455
+ stoplimit=stoplimit,
456
+ )
457
+ self._orders[symbol]["BSTPLMT"].append(order)
458
+
459
+ def sell_stop_limit(
460
+ self, id, symbol, price, stoplimit, quantity, strength=1.0, dtime=None
461
+ ):
462
+ """
463
+ Open a pending order to sell at a stop-limit price
464
+
465
+ See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
466
+ """
467
+ current_price = self.data.get_latest_bar_value(symbol, "close")
468
+ if price >= current_price:
469
+ raise ValueError(
470
+ f"The stop price {price} must be less than the current price {current_price}."
471
+ )
472
+ if price <= stoplimit:
473
+ raise ValueError(
474
+ f"The stop-limit price {stoplimit} must be less than the price {price}."
475
+ )
476
+ order = SignalEvent(
477
+ id,
478
+ symbol,
479
+ dtime,
480
+ "SHORT",
481
+ quantity=quantity,
482
+ strength=strength,
483
+ price=price,
484
+ stoplimit=stoplimit,
485
+ )
486
+ self._orders[symbol]["SSTPLMT"].append(order)
487
+
488
+ def check_pending_orders(self):
489
+ """
490
+ Check for pending orders and handle them accordingly.
491
+ """
492
+ for symbol in self.symbols:
493
+ dtime = self.data.get_latest_bar_datetime(symbol)
494
+
495
+ def logmsg(order, type):
496
+ return self.logger.info(
497
+ f"{type} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
498
+ f"PRICE @ {order.price}",
499
+ custom_time=dtime,
500
+ )
501
+
502
+ for order in self._orders[symbol]["BLMT"].copy():
503
+ if self.data.get_latest_bar_value(symbol, "close") <= order.price:
504
+ self.buy_mkt(
505
+ order.strategy_id, symbol, order.price, order.quantity, dtime
506
+ )
507
+ try:
508
+ self._orders[symbol]["BLMT"].remove(order)
509
+ assert order not in self._orders[symbol]["BLMT"]
510
+ logmsg(order, "BUY LIMIT")
511
+ except AssertionError:
512
+ self._orders[symbol]["BLMT"] = [
513
+ o for o in self._orders[symbol]["BLMT"] if o != order
514
+ ]
515
+ logmsg(order, "BUY LIMIT")
516
+ for order in self._orders[symbol]["SLMT"].copy():
517
+ if self.data.get_latest_bar_value(symbol, "close") >= order.price:
518
+ self.sell_mkt(
519
+ order.strategy_id, symbol, order.price, order.quantity, dtime
520
+ )
521
+ try:
522
+ self._orders[symbol]["SLMT"].remove(order)
523
+ assert order not in self._orders[symbol]["SLMT"]
524
+ logmsg(order, "SELL LIMIT")
525
+ except AssertionError:
526
+ self._orders[symbol]["SLMT"] = [
527
+ o for o in self._orders[symbol]["SLMT"] if o != order
528
+ ]
529
+ logmsg(order, "SELL LIMIT")
530
+ for order in self._orders[symbol]["BSTP"].copy():
531
+ if self.data.get_latest_bar_value(symbol, "close") >= order.price:
532
+ self.buy_mkt(
533
+ order.strategy_id, symbol, order.price, order.quantity, dtime
534
+ )
535
+ try:
536
+ self._orders[symbol]["BSTP"].remove(order)
537
+ assert order not in self._orders[symbol]["BSTP"]
538
+ logmsg(order, "BUY STOP")
539
+ except AssertionError:
540
+ self._orders[symbol]["BSTP"] = [
541
+ o for o in self._orders[symbol]["BSTP"] if o != order
542
+ ]
543
+ logmsg(order, "BUY STOP")
544
+ for order in self._orders[symbol]["SSTP"].copy():
545
+ if self.data.get_latest_bar_value(symbol, "close") <= order.price:
546
+ self.sell_mkt(
547
+ order.strategy_id, symbol, order.price, order.quantity, dtime
548
+ )
549
+ try:
550
+ self._orders[symbol]["SSTP"].remove(order)
551
+ assert order not in self._orders[symbol]["SSTP"]
552
+ logmsg(order, "SELL STOP")
553
+ except AssertionError:
554
+ self._orders[symbol]["SSTP"] = [
555
+ o for o in self._orders[symbol]["SSTP"] if o != order
556
+ ]
557
+ logmsg(order, "SELL STOP")
558
+ for order in self._orders[symbol]["BSTPLMT"].copy():
559
+ if self.data.get_latest_bar_value(symbol, "close") >= order.price:
560
+ self.buy_limit(
561
+ order.strategy_id,
562
+ symbol,
563
+ order.stoplimit,
564
+ order.quantity,
565
+ dtime,
566
+ )
567
+ try:
568
+ self._orders[symbol]["BSTPLMT"].remove(order)
569
+ assert order not in self._orders[symbol]["BSTPLMT"]
570
+ logmsg(order, "BUY STOP LIMIT")
571
+ except AssertionError:
572
+ self._orders[symbol]["BSTPLMT"] = [
573
+ o for o in self._orders[symbol]["BSTPLMT"] if o != order
574
+ ]
575
+ logmsg(order, "BUY STOP LIMIT")
576
+ for order in self._orders[symbol]["SSTPLMT"].copy():
577
+ if self.data.get_latest_bar_value(symbol, "close") <= order.price:
578
+ self.sell_limit(
579
+ order.strategy_id,
580
+ symbol,
581
+ order.stoplimit,
582
+ order.quantity,
583
+ dtime,
584
+ )
585
+ try:
586
+ self._orders[symbol]["SSTPLMT"].remove(order)
587
+ assert order not in self._orders[symbol]["SSTPLMT"]
588
+ logmsg(order, "SELL STOP LIMIT")
589
+ except AssertionError:
590
+ self._orders[symbol]["SSTPLMT"] = [
591
+ o for o in self._orders[symbol]["SSTPLMT"] if o != order
592
+ ]
593
+ logmsg(order, "SELL STOP LIMIT")
594
+
595
+ @staticmethod
596
+ def calculate_pct_change(current_price, lh_price):
597
+ return ((current_price - lh_price) / lh_price) * 100
598
+
599
+ def get_asset_values(
600
+ self,
601
+ symbol_list: List[str],
602
+ window: int,
603
+ value_type: str = "returns",
604
+ array: bool = True,
605
+ bars: DataHandler = None,
606
+ mode: Literal["backtest", "live"] = "backtest",
607
+ tf: str = "D1",
608
+ ) -> Dict[str, np.ndarray | pd.Series] | None:
609
+ """
610
+ Get the historical OHLCV value or returns or custum value
611
+ based on the DataHandker of the assets in the symbol list.
612
+
613
+ Args:
614
+ bars : DataHandler for market data handling, required for backtest mode.
615
+ symbol_list : List of ticker symbols for the pairs trading strategy.
616
+ value_type : The type of value to get (e.g., returns, open, high, low, close, adjclose, volume).
617
+ array : If True, return the values as numpy arrays, otherwise as pandas Series.
618
+ mode : Mode of operation for the strategy.
619
+ window : The lookback period for resquesting the data.
620
+ tf : The time frame for the strategy.
621
+
622
+ Returns:
623
+ asset_values : Historical values of the assets in the symbol list.
624
+
625
+ Note:
626
+ In Live mode, the `bbstrader.metatrader.rates.Rates` class is used to get the historical data
627
+ so the value_type must be 'returns', 'open', 'high', 'low', 'close', 'adjclose', 'volume'.
628
+ """
629
+ if mode not in ["backtest", "live"]:
630
+ raise ValueError("Mode must be either backtest or live.")
631
+ asset_values = {}
632
+ if mode == "backtest":
633
+ if bars is None:
634
+ raise ValueError("DataHandler is required for backtest mode.")
635
+ for asset in symbol_list:
636
+ if array:
637
+ values = bars.get_latest_bars_values(asset, value_type, N=window)
638
+ asset_values[asset] = values[~np.isnan(values)]
639
+ else:
640
+ values = bars.get_latest_bars(asset, N=window)
641
+ asset_values[asset] = getattr(values, value_type)
642
+ elif mode == "live":
643
+ for asset in symbol_list:
644
+ rates = Rates(asset, timeframe=tf, count=window + 1, **self.kwargs)
645
+ if array:
646
+ values = getattr(rates, value_type).values
647
+ asset_values[asset] = values[~np.isnan(values)]
648
+ else:
649
+ values = getattr(rates, value_type)
650
+ asset_values[asset] = values
651
+ if all(len(values) >= window for values in asset_values.values()):
652
+ return {a: v[-window:] for a, v in asset_values.items()}
653
+ else:
654
+ return None
655
+
656
+ @staticmethod
657
+ def is_signal_time(period_count, signal_inverval) -> bool:
658
+ """
659
+ Check if we can generate a signal based on the current period count.
660
+ We use the signal interval as a form of periodicity or rebalancing period.
661
+
662
+ Args:
663
+ period_count : The current period count (e.g., number of bars).
664
+ signal_inverval : The signal interval for generating signals (e.g., every 5 bars).
665
+
666
+ Returns:
667
+ bool : True if we can generate a signal, False otherwise
668
+ """
669
+ if period_count == 0 or period_count is None:
670
+ return True
671
+ return period_count % signal_inverval == 0
672
+
673
+ def ispositions(
674
+ self, symbol, strategy_id, position, max_trades, one_true=False, account=None
675
+ ) -> bool:
676
+ """
677
+ This function is use for live trading to check if there are open positions
678
+ for a given symbol and strategy. It is used to prevent opening more trades
679
+ than the maximum allowed trades per symbol.
680
+
681
+ Args:
682
+ symbol : The symbol for the trade.
683
+ strategy_id : The unique identifier for the strategy.
684
+ position : The position type (1: short, 0: long).
685
+ max_trades : The maximum number of trades allowed per symbol.
686
+ one_true : If True, return True if there is at least one open position.
687
+ account : The `bbstrader.metatrader.Account` object for the strategy.
688
+
689
+ Returns:
690
+ bool : True if there are open positions, False otherwise
691
+ """
692
+ account = account or Account(**self.kwargs)
693
+ positions = account.get_positions(symbol=symbol)
694
+ if positions is not None:
695
+ open_positions = [
696
+ pos.ticket
697
+ for pos in positions
698
+ if pos.type == position and pos.magic == strategy_id
699
+ ]
700
+ if one_true:
701
+ return len(open_positions) in range(1, max_trades + 1)
702
+ return len(open_positions) >= max_trades
703
+ return False
704
+
705
+ def get_positions_prices(self, symbol, strategy_id, position, account=None):
706
+ """
707
+ Get the buy or sell prices for open positions of a given symbol and strategy.
708
+
709
+ Args:
710
+ symbol : The symbol for the trade.
711
+ strategy_id : The unique identifier for the strategy.
712
+ position : The position type (1: short, 0: long).
713
+ account : The `bbstrader.metatrader.Account` object for the strategy.
714
+
715
+ Returns:
716
+ prices : numpy array of buy or sell prices for open positions if any or an empty array.
717
+ """
718
+ account = account or Account(**self.kwargs)
719
+ positions = account.get_positions(symbol=symbol)
720
+ if positions is not None:
721
+ prices = np.array(
722
+ [
723
+ pos.price_open
724
+ for pos in positions
725
+ if pos.type == position and pos.magic == strategy_id
726
+ ]
727
+ )
728
+ return prices
729
+ return np.array([])
730
+
731
+ @staticmethod
732
+ def get_current_dt(time_zone: str = "US/Eastern") -> datetime:
733
+ return datetime.now(pytz.timezone(time_zone))
734
+
735
+ @staticmethod
736
+ def convert_time_zone(
737
+ dt: datetime | int | pd.Timestamp,
738
+ from_tz: str = "UTC",
739
+ to_tz: str = "US/Eastern",
740
+ ) -> pd.Timestamp:
741
+ """
742
+ Convert datetime from one timezone to another.
743
+
744
+ Args:
745
+ dt : The datetime to convert.
746
+ from_tz : The timezone to convert from.
747
+ to_tz : The timezone to convert to.
748
+
749
+ Returns:
750
+ dt_to : The converted datetime.
751
+ """
752
+ from_tz = pytz.timezone(from_tz)
753
+ if isinstance(dt, datetime):
754
+ dt = pd.to_datetime(dt, unit="s")
755
+ elif isinstance(dt, int):
756
+ dt = pd.to_datetime(dt, unit="s")
757
+ if dt.tzinfo is None:
758
+ dt = dt.tz_localize(from_tz)
759
+ else:
760
+ dt = dt.tz_convert(from_tz)
761
+
762
+ dt_to = dt.tz_convert(pytz.timezone(to_tz))
763
+ return dt_to
764
+
765
+ @staticmethod
766
+ def get_mt5_equivalent(symbols, type="STK", path: str = None) -> List[str]:
767
+ account = Account(path=path)
768
+ mt5_symbols = account.get_symbols(symbol_type=type)
769
+ mt5_equivalent = []
770
+ if account.broker == AdmiralMarktsGroup():
771
+ for s in mt5_symbols:
772
+ _s = s[1:] if s[0] in string.punctuation else s
773
+ for symbol in symbols:
774
+ if _s.split(".")[0] == symbol or _s.split("_")[0] == symbol:
775
+ mt5_equivalent.append(s)
776
+ return mt5_equivalent
777
+
778
+
779
+ class TWSStrategy(Strategy): ...