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.
- bbstrader/__ini__.py +18 -0
- bbstrader/btengine/__init__.py +54 -0
- bbstrader/btengine/backtest.py +360 -0
- bbstrader/btengine/data.py +712 -0
- bbstrader/btengine/event.py +221 -0
- bbstrader/btengine/execution.py +251 -0
- bbstrader/btengine/performance.py +347 -0
- bbstrader/btengine/portfolio.py +406 -0
- bbstrader/btengine/strategy.py +779 -0
- bbstrader/config.py +133 -0
- bbstrader/core/__init__.py +0 -0
- bbstrader/core/data.py +22 -0
- bbstrader/core/utils.py +57 -0
- bbstrader/ibkr/__init__.py +0 -0
- bbstrader/ibkr/utils.py +0 -0
- bbstrader/metatrader/__init__.py +6 -0
- bbstrader/metatrader/account.py +1488 -0
- bbstrader/metatrader/rates.py +579 -0
- bbstrader/metatrader/risk.py +702 -0
- bbstrader/metatrader/trade.py +1690 -0
- bbstrader/metatrader/utils.py +641 -0
- bbstrader/models/__init__.py +10 -0
- bbstrader/models/factors.py +312 -0
- bbstrader/models/ml.py +1264 -0
- bbstrader/models/optimization.py +182 -0
- bbstrader/models/portfolio.py +223 -0
- bbstrader/models/risk.py +398 -0
- bbstrader/trading/__init__.py +11 -0
- bbstrader/trading/execution.py +726 -0
- bbstrader/trading/scripts.py +67 -0
- bbstrader/trading/strategies.py +860 -0
- bbstrader/tseries.py +1816 -0
- bbstrader-0.2.4.dist-info/LICENSE +21 -0
- bbstrader-0.2.4.dist-info/METADATA +174 -0
- bbstrader-0.2.4.dist-info/RECORD +37 -0
- bbstrader-0.2.4.dist-info/WHEEL +5 -0
- bbstrader-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -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): ...
|