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

@@ -111,8 +111,9 @@ class BacktestEngine(Backtest):
111
111
  their class types.
112
112
  """
113
113
  print(
114
- f"\nStarting Backtest on {self.symbol_list} "
115
- f"with ${self.initial_capital} Initial Capital\n"
114
+ f"\n[======= STARTING BACKTEST =======]\n"
115
+ f"START DATE: {self.start_date} \n"
116
+ f"INITIAL CAPITAL: {self.initial_capital}\n"
116
117
  )
117
118
  self.data_handler: DataHandler = self.dh_cls(
118
119
  self.events, self.symbol_list, **self.kwargs
@@ -141,8 +142,9 @@ class BacktestEngine(Backtest):
141
142
  self.data_handler.update_bars()
142
143
  self.strategy.check_pending_orders()
143
144
  else:
144
- print("\n[======= BACKTEST COMPLETE =======]\n")
145
- print(f"Total bars: {i} ")
145
+ print("\n[======= BACKTEST COMPLETED =======]")
146
+ print(f"END DATE: {self.data_handler.get_latest_bar_datetime()}")
147
+ print(f"TOTAL BARS: {i} ")
146
148
  break
147
149
 
148
150
  # Handle the events
@@ -168,6 +170,11 @@ class BacktestEngine(Backtest):
168
170
  elif event.type == 'FILL':
169
171
  self.fills += 1
170
172
  self.portfolio.update_fill(event)
173
+ self.strategy.update_trades_from_fill(event)
174
+ self.strategy.get_update_from_portfolio(
175
+ self.portfolio.current_positions,
176
+ self.portfolio.current_holdings
177
+ )
171
178
 
172
179
  time.sleep(self.heartbeat)
173
180
 
@@ -272,7 +272,11 @@ class BaseCSVDataHandler(DataHandler):
272
272
  print("Symbol not available in the historical data set.")
273
273
  raise
274
274
  else:
275
- return getattr(bars_list[-1][1], val_type)
275
+ try:
276
+ return getattr(bars_list[-1][1], val_type)
277
+ except AttributeError:
278
+ print(f"Value type {val_type} not available in the historical data set.")
279
+ raise
276
280
 
277
281
  def get_latest_bars_values(self, symbol: str, val_type: str, N=1) -> np.ndarray:
278
282
  """
@@ -285,7 +289,11 @@ class BaseCSVDataHandler(DataHandler):
285
289
  print("That symbol is not available in the historical data set.")
286
290
  raise
287
291
  else:
288
- return np.array([getattr(b[1], val_type) for b in bars_list])
292
+ try:
293
+ return np.array([getattr(b[1], val_type) for b in bars_list])
294
+ except AttributeError:
295
+ print(f"Value type {val_type} not available in the historical data set.")
296
+ raise
289
297
 
290
298
  def update_bars(self):
291
299
  """
@@ -102,14 +102,15 @@ class OrderEvent(Event):
102
102
 
103
103
  def __init__(self,
104
104
  symbol: str,
105
- order_type: Literal['MKT', 'LMT'],
105
+ order_type: Literal['MKT', 'LMT', 'STP', 'STPLMT'],
106
106
  quantity: int | float,
107
107
  direction: Literal['BUY', 'SELL'],
108
- price: int | float = None
108
+ price: int | float = None,
109
+ signal: str = None
109
110
  ):
110
111
  """
111
112
  Initialises the order type, setting whether it is
112
- a Market order ('MKT') or Limit order ('LMT'), has
113
+ a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP').
113
114
  a quantity (integral or float) and its direction ('BUY' or 'SELL').
114
115
 
115
116
  Args:
@@ -118,6 +119,7 @@ class OrderEvent(Event):
118
119
  quantity (int | float): Non-negative number for quantity.
119
120
  direction (str): 'BUY' or 'SELL' for long or short.
120
121
  price (int | float): The price at which to order.
122
+ signal (str): The signal that generated the order.
121
123
  """
122
124
  self.type = 'ORDER'
123
125
  self.symbol = symbol
@@ -125,6 +127,7 @@ class OrderEvent(Event):
125
127
  self.quantity = quantity
126
128
  self.direction = direction
127
129
  self.price = price
130
+ self.signal = signal
128
131
 
129
132
  def print_order(self):
130
133
  """
@@ -162,7 +165,8 @@ class FillEvent(Event):
162
165
  quantity: int | float,
163
166
  direction: Literal['BUY', 'SELL'],
164
167
  fill_cost: int | float | None,
165
- commission: float | None = None
168
+ commission: float | None = None,
169
+ order: str = None
166
170
  ):
167
171
  """
168
172
  Initialises the FillEvent object. Sets the symbol, exchange,
@@ -181,6 +185,7 @@ class FillEvent(Event):
181
185
  direction (str): The direction of fill `('LONG', 'SHORT', 'EXIT')`
182
186
  fill_cost (int | float): Price of the shares when filled.
183
187
  commission (float | None): An optional commission sent from IB.
188
+ order (str): The order that this fill is related
184
189
  """
185
190
  self.type = 'FILL'
186
191
  self.timeindex = timeindex
@@ -194,6 +199,7 @@ class FillEvent(Event):
194
199
  self.commission = self.calculate_ib_commission()
195
200
  else:
196
201
  self.commission = commission
202
+ self.order = order
197
203
 
198
204
  def calculate_ib_commission(self):
199
205
  """
@@ -4,7 +4,6 @@ from abc import ABCMeta, abstractmethod
4
4
  from bbstrader.btengine.event import FillEvent, OrderEvent
5
5
  from bbstrader.btengine.data import DataHandler
6
6
  from bbstrader.metatrader.account import Account
7
- from bbstrader.config import config_logger
8
7
 
9
8
  __all__ = [
10
9
  "ExecutionHandler",
@@ -26,7 +25,8 @@ class ExecutionHandler(metaclass=ABCMeta):
26
25
 
27
26
  The ExecutionHandler described here is exceedingly simple,
28
27
  since it fills all orders at the current market price.
29
- This is highly unrealistic, but serves as a good baseline for improvement.
28
+ This is highly unrealistic, for other markets thant ``CFDs``
29
+ but serves as a good baseline for improvement.
30
30
  """
31
31
 
32
32
  @abstractmethod
@@ -64,7 +64,7 @@ class SimExecutionHandler(ExecutionHandler):
64
64
  """
65
65
  self.events = events
66
66
  self.bardata = data
67
- self.logger = kwargs.get("logger", config_logger("execution.log"))
67
+ self.logger = kwargs.get("logger")
68
68
 
69
69
  def execute_order(self, event: OrderEvent):
70
70
  """
@@ -78,7 +78,7 @@ class SimExecutionHandler(ExecutionHandler):
78
78
  dtime = self.bardata.get_latest_bar_datetime(event.symbol)
79
79
  fill_event = FillEvent(
80
80
  dtime, event.symbol,
81
- 'ARCA', event.quantity, event.direction, None
81
+ 'ARCA', event.quantity, event.direction, order=event.signal
82
82
  )
83
83
  self.events.put(fill_event)
84
84
  self.logger.info(
@@ -121,7 +121,7 @@ class MT5ExecutionHandler(ExecutionHandler):
121
121
  """
122
122
  self.events = events
123
123
  self.bardata = data
124
- self.logger = kwargs.get("logger", config_logger("execution.log"))
124
+ self.logger = kwargs.get("logger")
125
125
  self.__account = Account()
126
126
 
127
127
  def _calculate_lot(self, symbol, quantity, price):
@@ -232,7 +232,7 @@ class MT5ExecutionHandler(ExecutionHandler):
232
232
  fill_event = FillEvent(
233
233
  timeindex=dtime, symbol=symbol,
234
234
  exchange='MT5', quantity=quantity, direction=direction,
235
- fill_cost=None, commission=fees
235
+ fill_cost=None, commission=fees, order=event.signal
236
236
  )
237
237
  self.events.put(fill_event)
238
238
  self.logger.info(
@@ -31,8 +31,6 @@ class Portfolio(object):
31
31
  """
32
32
  This describes a `Portfolio()` object that keeps track of the positions
33
33
  within a portfolio and generates orders of a fixed quantity of stock based on signals.
34
- More sophisticated portfolio objects could include risk management and position
35
- sizing tools (such as the `Kelly Criterion`).
36
34
 
37
35
  The portfolio order management system is possibly the most complex component of
38
36
  an event driven backtester. Its role is to keep track of all current market positions
@@ -57,10 +55,9 @@ class Portfolio(object):
57
55
  value (defaulting to `100,000 USD`) and others parameter based on the `Strategy` requirement.
58
56
 
59
57
  The `Portfolio` is designed to handle position sizing and current holdings,
60
- but will carry out trading orders in a "dumb" manner by simply sending them directly
61
- to the brokerage with a predetermined fixed quantity size, irrespective of cash held.
62
- These are all unrealistic assumptions, but they help to outline how a portfolio order
63
- management system (OMS) functions in an eventdriven fashion.
58
+ but will carry out trading orders by simply them to the brokerage with a predetermined
59
+ fixed quantity size, if the portfolio has enough cash to place the order.
60
+
64
61
 
65
62
  The portfolio contains the `all_positions` and `current_positions` members.
66
63
  The former stores a list of all previous positions recorded at the timestamp of a market data event.
@@ -108,7 +105,7 @@ class Portfolio(object):
108
105
  self.initial_capital = initial_capital
109
106
 
110
107
  self.timeframe = kwargs.get("time_frame", "D1")
111
- self.trading_hours = kwargs.get("session_duration", 6.5)
108
+ self.trading_hours = kwargs.get("session_duration", 23)
112
109
  self.benchmark = kwargs.get('benchmark', 'SPY')
113
110
  self.output_dir = kwargs.get('output_dir', None)
114
111
  self.strategy_name = kwargs.get('strategy_name', '')
@@ -174,6 +171,23 @@ class Portfolio(object):
174
171
  d['Commission'] = 0.0
175
172
  d['Total'] = self.initial_capital
176
173
  return d
174
+
175
+ def _get_price(self, symbol: str) -> float:
176
+ try:
177
+ price = self.bars.get_latest_bar_value(
178
+ symbol, "adj_close"
179
+ )
180
+ return price
181
+ except AttributeError:
182
+ try:
183
+ price = self.bars.get_latest_bar_value(
184
+ symbol, "close"
185
+ )
186
+ return price
187
+ except AttributeError:
188
+ raise AttributeError(
189
+ f"Bars object must have 'adj_close' or 'close' prices"
190
+ )
177
191
 
178
192
  def update_timeindex(self, event: MarketEvent):
179
193
  """
@@ -203,8 +217,8 @@ class Portfolio(object):
203
217
  dh['Total'] = self.current_holdings['Cash']
204
218
  for s in self.symbol_list:
205
219
  # Approximation to the real value
206
- market_value = self.current_positions[s] * \
207
- self.bars.get_latest_bar_value(s, "adj_close")
220
+ price = self._get_price(s)
221
+ market_value = self.current_positions[s] * price
208
222
  dh[s] = market_value
209
223
  dh['Total'] += market_value
210
224
 
@@ -245,9 +259,7 @@ class Portfolio(object):
245
259
  fill_dir = -1
246
260
 
247
261
  # Update holdings list with new quantities
248
- price = self.bars.get_latest_bar_value(
249
- fill.symbol, "adj_close"
250
- )
262
+ price = self._get_price(fill.symbol)
251
263
  cost = fill_dir * price * fill.quantity
252
264
  self.current_holdings[fill.symbol] += cost
253
265
  self.current_holdings['Commission'] += fill.commission
@@ -263,14 +275,16 @@ class Portfolio(object):
263
275
  self.update_positions_from_fill(event)
264
276
  self.update_holdings_from_fill(event)
265
277
 
266
- def generate_naive_order(self, signal: SignalEvent):
278
+ def generate_order(self, signal: SignalEvent):
267
279
  """
268
- Simply files an Order object as a constant quantity
269
- sizing of the signal object, without risk management or
270
- position sizing considerations.
280
+ Check if the portfolio has enough cash to place an order
281
+ and generate an OrderEvent, else return None.
271
282
 
272
283
  Args:
273
284
  signal (SignalEvent): The tuple containing Signal information.
285
+
286
+ Returns:
287
+ OrderEvent: The OrderEvent to be executed.
274
288
  """
275
289
  order = None
276
290
 
@@ -278,21 +292,33 @@ class Portfolio(object):
278
292
  direction = signal.signal_type
279
293
  quantity = signal.quantity
280
294
  strength = signal.strength
281
- price = signal.price
295
+ price = signal.price or self._get_price(symbol)
282
296
  cur_quantity = self.current_positions[symbol]
297
+ cash = self.current_holdings['Cash']
283
298
 
284
- order_type = 'MKT'
299
+ if direction in ['LONG', 'SHORT', 'EXIT']:
300
+ order_type = 'MKT'
301
+ else:
302
+ order_type = direction
285
303
  mkt_quantity = round(quantity * strength)
304
+ cost = mkt_quantity * price
305
+
306
+ if cash >= cost:
307
+ new_quantity = mkt_quantity
308
+ elif cash < cost and cash > 0:
309
+ new_quantity = round(cash // price)
310
+ else:
311
+ new_quantity = 0
286
312
 
287
- if direction == 'LONG' and cur_quantity == 0:
288
- order = OrderEvent(symbol, order_type, mkt_quantity, 'BUY', price)
289
- if direction == 'SHORT' and cur_quantity == 0:
290
- order = OrderEvent(symbol, order_type, mkt_quantity, 'SELL', price)
313
+ if new_quantity > 0 and direction == 'LONG':
314
+ order = OrderEvent(symbol, order_type, new_quantity, 'BUY', price, direction)
315
+ if new_quantity > 0 and direction == 'SHORT':
316
+ order = OrderEvent(symbol, order_type, new_quantity, 'SELL', price, direction)
291
317
 
292
318
  if direction == 'EXIT' and cur_quantity > 0:
293
- order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price)
319
+ order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price, direction)
294
320
  if direction == 'EXIT' and cur_quantity < 0:
295
- order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price)
321
+ order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price, direction)
296
322
 
297
323
  return order
298
324
 
@@ -302,7 +328,7 @@ class Portfolio(object):
302
328
  based on the portfolio logic.
303
329
  """
304
330
  if event.type == 'SIGNAL':
305
- order_event = self.generate_naive_order(event)
331
+ order_event = self.generate_order(event)
306
332
  self.events.put(order_event)
307
333
 
308
334
  def create_equity_curve_dataframe(self):
@@ -7,6 +7,7 @@ from queue import Queue
7
7
  from datetime import datetime
8
8
  from bbstrader.config import config_logger
9
9
  from bbstrader.btengine.event import SignalEvent
10
+ from bbstrader.btengine.event import FillEvent
10
11
  from bbstrader.btengine.data import DataHandler
11
12
  from bbstrader.metatrader.account import Account
12
13
  from bbstrader.metatrader.rates import Rates
@@ -38,6 +39,8 @@ class Strategy(metaclass=ABCMeta):
38
39
 
39
40
  The strategy hierarchy is relatively simple as it consists of an abstract
40
41
  base class with a single pure virtual method for generating `SignalEvent` objects.
42
+ Other methods are provided to check for pending orders, update trades from fills,
43
+ and get updates from the portfolio.
41
44
  """
42
45
 
43
46
  @abstractmethod
@@ -45,8 +48,9 @@ class Strategy(metaclass=ABCMeta):
45
48
  raise NotImplementedError(
46
49
  "Should implement calculate_signals()"
47
50
  )
48
-
49
- def check_pending_orders(self): ...
51
+ def check_pending_orders(self, *args, **kwargs): ...
52
+ def get_update_from_portfolio(self, *args, **kwargs): ...
53
+ def update_trades_from_fill(self, *args, **kwargs): ...
50
54
 
51
55
 
52
56
  class MT5Strategy(Strategy):
@@ -74,17 +78,71 @@ class MT5Strategy(Strategy):
74
78
  self.symbols = symbol_list
75
79
  self.mode = mode
76
80
  self.volume = kwargs.get("volume")
77
- self.logger = kwargs.get("logger", config_logger("mt5_strategy.log"))
78
- self._construct_positions_and_orders()
81
+ self.max_trades = kwargs.get("max_trades",
82
+ {symbol: 1 for symbol in self.symbols})
83
+ self.logger = kwargs.get("logger")
84
+ self._initialize_portfolio()
85
+
86
+ @property
87
+ def orders(self) -> Dict[str, Dict[str, List[SignalEvent]]]:
88
+ return self._orders
89
+
90
+ @property
91
+ def trades(self) -> Dict[str, Dict[str, int]]:
92
+ return self._trades
93
+
94
+ @property
95
+ def positions(self) -> Dict[str, Dict[str, int|float]]:
96
+ return self._positions
97
+
98
+ @property
99
+ def holdings(self) -> Dict[str, float]:
100
+ return self._holdings
79
101
 
80
- def _construct_positions_and_orders(self):
81
- self.positions: Dict[str, Dict[str, int]] = {}
82
- self.orders: Dict[str, Dict[str, List[SignalEvent]]] = {}
102
+ def _initialize_portfolio(self):
83
103
  positions = ['LONG', 'SHORT']
84
104
  orders = ['BLMT', 'BSTP', 'BSTPLMT', 'SLMT', 'SSTP', 'SSTPLMT']
105
+ self._positions: Dict[str, Dict[str, int]] = {}
106
+ self._trades: Dict[str, Dict[str, int]] = {}
107
+ self._orders: Dict[str, Dict[str, List[SignalEvent]]] = {}
85
108
  for symbol in self.symbols:
86
- self.positions[symbol] = {position: 0 for position in positions}
87
- self.orders[symbol] = {order: [] for order in orders}
109
+ self._positions[symbol] = {}
110
+ self._orders[symbol] = {}
111
+ self._trades[symbol] = {}
112
+ for position in positions:
113
+ self._trades[symbol][position] = 0
114
+ self._positions[symbol][position] = 0
115
+ for order in orders:
116
+ self._orders[symbol][order] = []
117
+ self._holdings = {s: 0.0 for s in self.symbols}
118
+
119
+ def get_update_from_portfolio(self, positions, holdings):
120
+ """
121
+ Update the positions and holdings for the strategy from the portfolio.
122
+
123
+ Positions are the number of shares of a security that are owned in long or short.
124
+ Holdings are the value (postions * price) of the security that are owned in long or short.
125
+
126
+ Args:
127
+ positions : The positions for the symbols in the strategy.
128
+ holdings : The holdings for the symbols in the strategy.
129
+ """
130
+ for symbol in self.symbols:
131
+ if symbol in positions:
132
+ if positions[symbol] > 0:
133
+ self._positions[symbol]['LONG'] = positions[symbol]
134
+ elif positions[symbol] < 0:
135
+ self._positions[symbol]['SHORT'] = positions[symbol]
136
+ if symbol in holdings:
137
+ self._holdings[symbol] = holdings[symbol]
138
+
139
+ def update_trades_from_fill(self, event: FillEvent):
140
+ """
141
+ This method updates the trades for the strategy based on the fill event.
142
+ It is used to keep track of the number of trades executed for each order.
143
+ """
144
+ if event.type == 'FILL':
145
+ self._trades[event.symbol][event.order] += 1
88
146
 
89
147
  def calculate_signals(self, *args, **kwargs
90
148
  ) -> Dict[str, Union[str, dict, None]] | None:
@@ -115,7 +173,7 @@ class MT5Strategy(Strategy):
115
173
  """
116
174
  pass
117
175
 
118
- def get_quantity(self, symbol) -> int:
176
+ def get_quantity(self, symbol, volume=None) -> int:
119
177
  """
120
178
  Calculate the quantity to buy or sell for a given symbol based on the dollar value provided.
121
179
  The quantity calculated can be used to evalute a strategy's performance for each symbol
@@ -127,11 +185,15 @@ class MT5Strategy(Strategy):
127
185
  Returns:
128
186
  qty : The quantity to buy or sell for the symbol.
129
187
  """
130
- if self.volume is None:
188
+ if self.volume is None and volume is None:
131
189
  raise ValueError("Volume must be provided for the method.")
132
190
  current_price = self.data.get_latest_bar_value(symbol, 'close')
133
- qty = math.ceil(self.volume / current_price)
134
- return max(qty, 1)
191
+ try:
192
+ qty = math.ceil(self.volume or volume / current_price)
193
+ qty = max(qty, 1) / self.max_trades[symbol]
194
+ return max(math.ceil(qty), 1)
195
+ except Exception:
196
+ return 1
135
197
 
136
198
  def get_quantities(self, quantities: Union[None, dict, int]) -> dict:
137
199
  """
@@ -157,7 +219,7 @@ class MT5Strategy(Strategy):
157
219
  self.logger.info(
158
220
  f"{signal} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={quantity}, PRICE @{price}", custom_time=dtime)
159
221
 
160
- def buy(self, id: int, symbol: str, price: float, quantity: int,
222
+ def buy_mkt(self, id: int, symbol: str, price: float, quantity: int,
161
223
  strength: float=1.0, dtime: datetime | pd.Timestamp=None):
162
224
  """
163
225
  Open a long position
@@ -165,25 +227,22 @@ class MT5Strategy(Strategy):
165
227
  See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
166
228
  """
167
229
  self._send_order(id, symbol, 'LONG', strength, price, quantity, dtime)
168
- self.positions[symbol]['LONG'] += quantity
169
230
 
170
- def sell(self, id, symbol, price, quantity, strength=1.0, dtime=None):
231
+ def sell_mkt(self, id, symbol, price, quantity, strength=1.0, dtime=None):
171
232
  """
172
233
  Open a short position
173
234
 
174
235
  See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
175
236
  """
176
237
  self._send_order(id, symbol, 'SHORT', strength, price, quantity, dtime)
177
- self.positions[symbol]['SHORT'] += quantity
178
238
 
179
- def close(self, id, symbol, price, quantity, strength=1.0, dtime=None):
239
+ def close_positions(self, id, symbol, price, quantity, strength=1.0, dtime=None):
180
240
  """
181
- Close a position
241
+ Close a position or exit all positions
182
242
 
183
243
  See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
184
244
  """
185
245
  self._send_order(id, symbol, 'EXIT', strength, price, quantity, dtime)
186
- self.positions[symbol]['LONG'] -= quantity
187
246
 
188
247
  def buy_stop(self, id, symbol, price, quantity, strength=1.0, dtime=None):
189
248
  """
@@ -197,7 +256,7 @@ class MT5Strategy(Strategy):
197
256
  "The buy_stop price must be greater than the current price.")
198
257
  order = SignalEvent(id, symbol, dtime, 'LONG',
199
258
  quantity=quantity, strength=strength, price=price)
200
- self.orders[symbol]['BSTP'].append(order)
259
+ self._orders[symbol]['BSTP'].append(order)
201
260
 
202
261
  def sell_stop(self, id, symbol, price, quantity, strength=1.0, dtime=None):
203
262
  """
@@ -211,7 +270,7 @@ class MT5Strategy(Strategy):
211
270
  "The sell_stop price must be less than the current price.")
212
271
  order = SignalEvent(id, symbol, dtime, 'SHORT',
213
272
  quantity=quantity, strength=strength, price=price)
214
- self.orders[symbol]['SSTP'].append(order)
273
+ self._orders[symbol]['SSTP'].append(order)
215
274
 
216
275
  def buy_limit(self, id, symbol, price, quantity, strength=1.0, dtime=None):
217
276
  """
@@ -225,7 +284,7 @@ class MT5Strategy(Strategy):
225
284
  "The buy_limit price must be less than the current price.")
226
285
  order = SignalEvent(id, symbol, dtime, 'LONG',
227
286
  quantity=quantity, strength=strength, price=price)
228
- self.orders[symbol]['BLMT'].append(order)
287
+ self._orders[symbol]['BLMT'].append(order)
229
288
 
230
289
  def sell_limit(self, id, symbol, price, quantity, strength=1.0, dtime=None):
231
290
  """
@@ -239,7 +298,7 @@ class MT5Strategy(Strategy):
239
298
  "The sell_limit price must be greater than the current price.")
240
299
  order = SignalEvent(id, symbol, dtime, 'SHORT',
241
300
  quantity=quantity, strength=strength, price=price)
242
- self.orders[symbol]['SLMT'].append(order)
301
+ self._orders[symbol]['SLMT'].append(order)
243
302
 
244
303
  def buy_stop_limit(self, id: int, symbol: str, price: float, stoplimit: float,
245
304
  quantity: int, strength: float=1.0, dtime: datetime | pd.Timestamp = None):
@@ -257,7 +316,7 @@ class MT5Strategy(Strategy):
257
316
  f"The stop-limit price {stoplimit} must be greater than the price {price}.")
258
317
  order = SignalEvent(id, symbol, dtime, 'LONG',
259
318
  quantity=quantity, strength=strength, price=price, stoplimit=stoplimit)
260
- self.orders[symbol]['BSTPLMT'].append(order)
319
+ self._orders[symbol]['BSTPLMT'].append(order)
261
320
 
262
321
  def sell_stop_limit(self, id, symbol, price, stoplimit, quantity, strength=1.0, dtime=None):
263
322
  """
@@ -274,7 +333,7 @@ class MT5Strategy(Strategy):
274
333
  f"The stop-limit price {stoplimit} must be less than the price {price}.")
275
334
  order = SignalEvent(id, symbol, dtime, 'SHORT',
276
335
  quantity=quantity, strength=strength, price=price, stoplimit=stoplimit)
277
- self.orders[symbol]['SSTPLMT'].append(order)
336
+ self._orders[symbol]['SSTPLMT'].append(order)
278
337
 
279
338
  def check_pending_orders(self):
280
339
  """
@@ -282,54 +341,54 @@ class MT5Strategy(Strategy):
282
341
  """
283
342
  for symbol in self.symbols:
284
343
  dtime = self.data.get_latest_bar_datetime(symbol)
285
- for order in self.orders[symbol]['BLMT'].copy():
344
+ for order in self._orders[symbol]['BLMT'].copy():
286
345
  if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
287
- self.buy(order.strategy_id, symbol,
346
+ self.buy_mkt(order.strategy_id, symbol,
288
347
  order.price, order.quantity, dtime)
289
348
  self.logger.info(
290
349
  f"BUY LIMIT ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
291
350
  f"PRICE @ {order.price}", custom_time=dtime)
292
- self.orders[symbol]['BLMT'].remove(order)
293
- for order in self.orders[symbol]['SLMT'].copy():
351
+ self._orders[symbol]['BLMT'].remove(order)
352
+ for order in self._orders[symbol]['SLMT'].copy():
294
353
  if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
295
- self.sell(order.strategy_id, symbol,
354
+ self.sell_mkt(order.strategy_id, symbol,
296
355
  order.price, order.quantity, dtime)
297
356
  self.logger.info(
298
357
  f"SELL LIMIT ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
299
358
  f"PRICE @ {order.price}", custom_time=dtime)
300
- self.orders[symbol]['SLMT'].remove(order)
301
- for order in self.orders[symbol]['BSTP'].copy():
359
+ self._orders[symbol]['SLMT'].remove(order)
360
+ for order in self._orders[symbol]['BSTP'].copy():
302
361
  if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
303
- self.buy(order.strategy_id, symbol,
362
+ self.buy_mkt(order.strategy_id, symbol,
304
363
  order.price, order.quantity, dtime)
305
364
  self.logger.info(
306
365
  f"BUY STOP ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
307
366
  f"PRICE @ {order.price}", custom_time=dtime)
308
- self.orders[symbol]['BSTP'].remove(order)
309
- for order in self.orders[symbol]['SSTP'].copy():
367
+ self._orders[symbol]['BSTP'].remove(order)
368
+ for order in self._orders[symbol]['SSTP'].copy():
310
369
  if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
311
- self.sell(order.strategy_id, symbol,
370
+ self.sell_mkt(order.strategy_id, symbol,
312
371
  order.price, order.quantity, dtime)
313
372
  self.logger.info(
314
373
  f"SELL STOP ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
315
374
  f"PRICE @ {order.price}", custom_time=dtime)
316
- self.orders[symbol]['SSTP'].remove(order)
317
- for order in self.orders[symbol]['BSTPLMT'].copy():
375
+ self._orders[symbol]['SSTP'].remove(order)
376
+ for order in self._orders[symbol]['BSTPLMT'].copy():
318
377
  if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
319
378
  self.buy_limit(order.strategy_id, symbol,
320
379
  order.stoplimit, order.quantity, dtime)
321
380
  self.logger.info(
322
381
  f"BUY STOP LIMIT ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
323
382
  f"PRICE @ {order.price}", custom_time=dtime)
324
- self.orders[symbol]['BSTPLMT'].remove(order)
325
- for order in self.orders[symbol]['SSTPLMT'].copy():
383
+ self._orders[symbol]['BSTPLMT'].remove(order)
384
+ for order in self._orders[symbol]['SSTPLMT'].copy():
326
385
  if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
327
386
  self.sell_limit(order.strategy_id, symbol,
328
387
  order.stoplimit, order.quantity, dtime)
329
388
  self.logger.info(
330
389
  f"SELL STOP LIMIT ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
331
390
  f"PRICE @ {order.price}", custom_time=dtime)
332
- self.orders[symbol]['SSTPLMT'].remove(order)
391
+ self._orders[symbol]['SSTPLMT'].remove(order)
333
392
 
334
393
  def get_asset_values(self,
335
394
  symbol_list: List[str],
@@ -178,8 +178,10 @@ class Rates(object):
178
178
  return TIMEFRAMES[time_frame]
179
179
 
180
180
  def _fetch_data(
181
- self, start: Union[int, datetime , pd.Timestamp],
182
- count: Union[int, datetime, pd.Timestamp], lower_colnames=False, utc=False,
181
+ self,
182
+ start: Union[int, datetime, pd.Timestamp],
183
+ count: Union[int, datetime, pd.Timestamp],
184
+ lower_colnames=False, utc=False,
183
185
  ) -> Union[pd.DataFrame, None]:
184
186
  """Fetches data from MT5 and returns a DataFrame or None."""
185
187
  try:
@@ -187,6 +189,13 @@ class Rates(object):
187
189
  rates = Mt5.copy_rates_from_pos(
188
190
  self.symbol, self.time_frame, start, count
189
191
  )
192
+ elif (
193
+ isinstance(start, (datetime, pd.Timestamp)) and
194
+ isinstance(count, int)
195
+ ):
196
+ rates = Mt5.copy_rates_from(
197
+ self.symbol, self.time_frame, start, count
198
+ )
190
199
  elif (
191
200
  isinstance(start, (datetime, pd.Timestamp)) and
192
201
  isinstance(count, (datetime, pd.Timestamp))
@@ -320,6 +329,35 @@ class Rates(object):
320
329
  return self._filter_data(df, fill_na=fill_na)
321
330
  return df
322
331
 
332
+ def get_rates_from(self, date_from: datetime | pd.Timestamp, count: int=MAX_BARS,
333
+ filter=False, fill_na=False, lower_colnames=False, utc=False) -> Union[pd.DataFrame, None]:
334
+ """
335
+ Retrieves historical data within a specified date range.
336
+
337
+ Args:
338
+ date_from : Starting date for data retrieval.
339
+ The data will be retrieved from this date going to the past.
340
+
341
+ count : Number of bars to retrieve.
342
+
343
+ filter : See `Rates.get_historical_data` for more details.
344
+ fill_na : See `Rates.get_historical_data` for more details.
345
+ lower_colnames : If True, the column names will be converted to lowercase.
346
+ utc (bool, optional): If True, the data will be in UTC timezone.
347
+ Defaults to False.
348
+
349
+ Returns:
350
+ Union[pd.DataFrame, None]: A DataFrame containing historical
351
+ data if successful, otherwise None.
352
+ """
353
+ utc = self._check_filter(filter, utc)
354
+ df = self._fetch_data(date_from, count, lower_colnames=lower_colnames, utc=utc)
355
+ if df is None:
356
+ return None
357
+ if filter:
358
+ return self._filter_data(df, fill_na=fill_na)
359
+ return df
360
+
323
361
  @property
324
362
  def open(self):
325
363
  return self.__data['Open']
@@ -459,4 +497,14 @@ def get_data_from_pos(symbol, time_frame, start_pos=0, fill_na=False,
459
497
  rates = Rates(symbol, time_frame, start_pos, count, session_duration)
460
498
  data = rates.get_rates_from_pos(filter=filter, fill_na=fill_na,
461
499
  lower_colnames=lower_colnames, utc=utc)
500
+ return data
501
+
502
+ def get_data_from_date(symbol, time_frame, date_from, count=MAX_BARS, fill_na=False,
503
+ lower_colnames=False, utc=False, filter=False):
504
+ """Get historical data from a specific date.
505
+ See `Rates.get_rates_from` for more details.
506
+ """
507
+ rates = Rates(symbol, time_frame)
508
+ data = rates.get_rates_from(date_from, count, filter=filter, fill_na=fill_na,
509
+ lower_colnames=lower_colnames, utc=utc)
462
510
  return data
@@ -838,14 +838,16 @@ class Trade(RiskManagement):
838
838
  elif account and id is not None:
839
839
  # All open positions for a specific strategy or expert no matter the symbol
840
840
  positions = self.get_positions()
841
- positions = [position for position in positions if position.magic == id]
841
+ if positions is not None:
842
+ positions = [position for position in positions if position.magic == id]
842
843
  elif not account and id is None:
843
844
  # All open positions for the current symbol no matter the strategy or expert
844
845
  positions = self.get_positions(symbol=self.symbol)
845
846
  elif not account and id is not None:
846
847
  # All open positions for the current symbol and a specific strategy or expert
847
848
  positions = self.get_positions(symbol=self.symbol)
848
- positions = [position for position in positions if position.magic == id]
849
+ if positions is not None:
850
+ positions = [position for position in positions if position.magic == id]
849
851
  profit = 0.0
850
852
  balance = self.get_account_info().balance
851
853
  target = round((balance * self.target)/100, 2)
@@ -1026,8 +1028,9 @@ class Trade(RiskManagement):
1026
1028
  result.retcode, display=True, add_msg=f"{e}{addtionnal}")
1027
1029
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
1028
1030
  msg = trade_retcode_message(result.retcode)
1029
- self.logger.error(
1030
- f"Break-Even Order Request, Position: #{tiket}, RETCODE={result.retcode}: {msg}{addtionnal}")
1031
+ if result.retcode != Mt5.TRADE_RETCODE_NO_CHANGES:
1032
+ self.logger.error(
1033
+ f"Break-Even Order Request, Position: #{tiket}, RETCODE={result.retcode}: {msg}{addtionnal}")
1031
1034
  tries = 0
1032
1035
  while result.retcode != Mt5.TRADE_RETCODE_DONE and tries < 10:
1033
1036
  if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
@@ -0,0 +1,170 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from pypfopt import risk_models
4
+ from pypfopt import expected_returns
5
+ from pypfopt.efficient_frontier import EfficientFrontier
6
+ from pypfopt.hierarchical_portfolio import HRPOpt
7
+ import warnings
8
+
9
+ def markowitz_weights(prices=None, freq=252):
10
+ """
11
+ Calculates optimal portfolio weights using Markowitz's mean-variance optimization (Max Sharpe Ratio) with multiple solvers.
12
+
13
+ Parameters:
14
+ ----------
15
+ prices : pd.DataFrame, optional
16
+ Price data for assets, where rows represent time periods and columns represent assets.
17
+ freq : int, optional
18
+ Frequency of the data, such as 252 for daily returns in a year (default is 252).
19
+
20
+ Returns:
21
+ -------
22
+ dict
23
+ Dictionary containing the optimal asset weights for maximizing the Sharpe ratio, normalized to sum to 1.
24
+
25
+ Notes:
26
+ -----
27
+ This function attempts to maximize the Sharpe ratio by iterating through various solvers ('SCS', 'ECOS', 'OSQP')
28
+ from the PyPortfolioOpt library. If a solver fails, it proceeds to the next one. If none succeed, an error message
29
+ is printed for each solver that fails.
30
+
31
+ This function is useful for portfolio with a small number of assets, as it may not scale well for large portfolios.
32
+
33
+ Raises:
34
+ ------
35
+ Exception
36
+ If all solvers fail, each will print an exception error message during runtime.
37
+ """
38
+ returns = expected_returns.mean_historical_return(prices, frequency=freq)
39
+ cov = risk_models.sample_cov(prices, frequency=freq)
40
+
41
+ # Try different solvers to maximize Sharpe ratio
42
+ for solver in ['SCS', 'ECOS', 'OSQP']:
43
+ ef = EfficientFrontier(expected_returns=returns,
44
+ cov_matrix=cov,
45
+ weight_bounds=(0, 1),
46
+ solver=solver)
47
+ try:
48
+ weights = ef.max_sharpe()
49
+ return ef.clean_weights()
50
+ except Exception as e:
51
+ print(f"Solver {solver} failed with error: {e}")
52
+
53
+ def hierarchical_risk_parity(prices=None, returns=None, freq=252):
54
+ """
55
+ Computes asset weights using Hierarchical Risk Parity (HRP) for risk-averse portfolio allocation.
56
+
57
+ Parameters:
58
+ ----------
59
+ prices : pd.DataFrame, optional
60
+ Price data for assets; if provided, daily returns will be calculated.
61
+ returns : pd.DataFrame, optional
62
+ Daily returns for assets. One of `prices` or `returns` must be provided.
63
+ freq : int, optional
64
+ Number of days to consider in calculating portfolio weights (default is 252).
65
+
66
+ Returns:
67
+ -------
68
+ dict
69
+ Optimized asset weights using the HRP method, with asset weights summing to 1.
70
+
71
+ Raises:
72
+ ------
73
+ ValueError
74
+ If neither `prices` nor `returns` are provided.
75
+
76
+ Notes:
77
+ -----
78
+ Hierarchical Risk Parity is particularly useful for portfolios with a large number of assets,
79
+ as it mitigates issues of multicollinearity and estimation errors in covariance matrices by
80
+ using hierarchical clustering.
81
+ """
82
+ warnings.filterwarnings("ignore")
83
+ if returns is None and prices is None:
84
+ raise ValueError("Either prices or returns must be provided")
85
+ if returns is None:
86
+ returns = prices.pct_change().dropna()
87
+ # Remove duplicate columns and index
88
+ returns = returns.loc[:, ~returns.columns.duplicated()]
89
+ returns = returns.loc[~returns.index.duplicated(keep='first')]
90
+ hrp = HRPOpt(returns=returns.iloc[-freq:])
91
+ return hrp.optimize()
92
+
93
+ def equal_weighted(prices=None, returns=None, round_digits=5):
94
+ """
95
+ Generates an equal-weighted portfolio by assigning an equal proportion to each asset.
96
+
97
+ Parameters:
98
+ ----------
99
+ prices : pd.DataFrame, optional
100
+ Price data for assets, where each column represents an asset.
101
+ returns : pd.DataFrame, optional
102
+ Return data for assets. One of `prices` or `returns` must be provided.
103
+ round_digits : int, optional
104
+ Number of decimal places to round each weight to (default is 5).
105
+
106
+ Returns:
107
+ -------
108
+ dict
109
+ Dictionary with equal weights assigned to each asset, summing to 1.
110
+
111
+ Raises:
112
+ ------
113
+ ValueError
114
+ If neither `prices` nor `returns` are provided.
115
+
116
+ Notes:
117
+ -----
118
+ Equal weighting is a simple allocation method that assumes equal importance across all assets,
119
+ useful as a baseline model and when no strong views exist on asset return expectations or risk.
120
+ """
121
+ if returns is None and prices is None:
122
+ raise ValueError("Either prices or returns must be provided")
123
+ if returns is None:
124
+ n = len(prices.columns)
125
+ columns = prices.columns
126
+ else:
127
+ n = len(returns.columns)
128
+ columns = returns.columns
129
+ return {col: round(1/n, round_digits) for col in columns}
130
+
131
+ def optimized_weights(prices=None, returns=None, freq=252, method='markowitz'):
132
+ """
133
+ Selects an optimization method to calculate portfolio weights based on user preference.
134
+
135
+ Parameters:
136
+ ----------
137
+ prices : pd.DataFrame, optional
138
+ Price data for assets, required for certain methods.
139
+ returns : pd.DataFrame, optional
140
+ Returns data for assets, an alternative input for certain methods.
141
+ freq : int, optional
142
+ Number of days for calculating portfolio weights, such as 252 for a year's worth of daily returns (default is 252).
143
+ method : str, optional
144
+ Optimization method to use ('markowitz', 'hrp', or 'equal') (default is 'markowitz').
145
+
146
+ Returns:
147
+ -------
148
+ dict
149
+ Dictionary containing optimized asset weights based on the chosen method.
150
+
151
+ Raises:
152
+ ------
153
+ ValueError
154
+ If an unknown optimization method is specified.
155
+
156
+ Notes:
157
+ -----
158
+ This function integrates different optimization methods:
159
+ - 'markowitz': mean-variance optimization with max Sharpe ratio
160
+ - 'hrp': Hierarchical Risk Parity, for risk-based clustering of assets
161
+ - 'equal': Equal weighting across all assets
162
+ """
163
+ if method == 'markowitz':
164
+ return markowitz_weights(prices=prices, freq=freq)
165
+ elif method == 'hrp':
166
+ return hierarchical_risk_parity(prices=prices, returns=returns, freq=freq)
167
+ elif method == 'equal':
168
+ return equal_weighted(prices=prices, returns=returns)
169
+ else:
170
+ raise ValueError(f"Unknown method: {method}")
@@ -0,0 +1,202 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import seaborn as sns
4
+ import matplotlib.pyplot as plt
5
+ from sklearn.decomposition import PCA
6
+ from sklearn.preprocessing import scale
7
+ from bbstrader.models.optimization import (
8
+ markowitz_weights,
9
+ hierarchical_risk_parity,
10
+ equal_weighted
11
+ )
12
+
13
+
14
+ class EigenPortfolios(object):
15
+ """
16
+ The `EigenPortfolios` class applies Principal Component Analysis (PCA) to a covariance matrix of normalized asset returns
17
+ to derive portfolios (eigenportfolios) that capture distinct risk factors in the asset returns. Each eigenportfolio
18
+ represents a principal component of the return covariance matrix, ordered by the magnitude of its eigenvalue. These
19
+ portfolios capture most of the variance in asset returns and are mutually uncorrelated.
20
+
21
+ """
22
+ def __init__(self):
23
+ self.returns = None
24
+ self.n_portfolios = None
25
+ self._portfolios = None
26
+ self._fit_called = False
27
+
28
+ def get_portfolios(self) -> pd.DataFrame:
29
+ """
30
+ Returns the computed eigenportfolios (weights of assets in each portfolio).
31
+
32
+ Returns:
33
+ -------
34
+ pd.DataFrame
35
+ DataFrame containing eigenportfolio weights for each asset.
36
+
37
+ Raises:
38
+ ------
39
+ ValueError
40
+ If `fit()` has not been called before retrieving portfolios.
41
+ """
42
+ if not self._fit_called:
43
+ raise ValueError("fit() must be called first")
44
+ return self._portfolios
45
+
46
+ def fit(self, returns: pd.DataFrame, n_portfolios: int=4) -> pd.DataFrame:
47
+ """
48
+ Computes the eigenportfolios based on PCA of the asset returns' covariance matrix.
49
+
50
+ Parameters:
51
+ ----------
52
+ returns : pd.DataFrame
53
+ Historical returns of assets to be used for PCA.
54
+ n_portfolios : int, optional
55
+ Number of eigenportfolios to compute (default is 4).
56
+
57
+ Returns:
58
+ -------
59
+ pd.DataFrame
60
+ DataFrame containing normalized weights for each eigenportfolio.
61
+
62
+ Notes:
63
+ -----
64
+ This method performs winsorization and normalization on returns to reduce the impact of outliers
65
+ and achieve zero mean and unit variance. It uses the first `n_portfolios` principal components
66
+ as portfolio weights.
67
+ """
68
+ # Winsorize and normalize the returns
69
+ normed_returns = scale(returns
70
+ .clip(lower=returns.quantile(q=.025),
71
+ upper=returns.quantile(q=.975),
72
+ axis=1)
73
+ .apply(lambda x: x.sub(x.mean()).div(x.std())))
74
+ returns = returns.dropna(thresh=int(normed_returns.shape[0] * .95), axis=1)
75
+ returns = returns.dropna(thresh=int(normed_returns.shape[1] * .95))
76
+
77
+ cov = returns.cov()
78
+ cov.columns = cov.columns.astype(str)
79
+ pca = PCA()
80
+ pca.fit(cov)
81
+
82
+ top_portfolios = pd.DataFrame(pca.components_[:n_portfolios], columns=cov.columns)
83
+ eigen_portfolios = top_portfolios.div(top_portfolios.sum(axis=1), axis=0)
84
+ eigen_portfolios.index = [f"Portfolio {i}" for i in range(1, n_portfolios + 1)]
85
+ self._portfolios = eigen_portfolios
86
+ self.returns = returns
87
+ self.n_portfolios = n_portfolios
88
+ self._fit_called = True
89
+
90
+ def plot_weights(self):
91
+ """
92
+ Plots the weights of each asset in each eigenportfolio as bar charts.
93
+
94
+ Notes:
95
+ -----
96
+ Each subplot represents one eigenportfolio, showing the contribution of each asset.
97
+ """
98
+ eigen_portfolios = self.get_portfolios()
99
+ n_cols = 2
100
+ n_rows = (self.n_portfolios + 1) // n_cols
101
+ figsize = (n_cols * 10, n_rows * 5)
102
+ axes = eigen_portfolios.T.plot.bar(subplots=True,
103
+ layout=(n_rows, n_cols),
104
+ figsize=figsize,
105
+ legend=False)
106
+ for ax in axes.flatten():
107
+ ax.set_ylabel('Portfolio Weight')
108
+ ax.set_xlabel('')
109
+
110
+ sns.despine()
111
+ plt.tight_layout()
112
+ plt.show()
113
+
114
+ def plot_performance(self):
115
+ """
116
+ Plots the cumulative returns of each eigenportfolio over time.
117
+
118
+ Notes:
119
+ -----
120
+ This method calculates the historical cumulative performance of each eigenportfolio
121
+ by weighting asset returns according to eigenportfolio weights.
122
+ """
123
+ eigen_portfolios = self.get_portfolios()
124
+ returns = self.returns.copy()
125
+
126
+ n_cols = 2
127
+ n_rows = (self.n_portfolios + 1 + n_cols - 1) // n_cols
128
+ figsize = (n_cols * 10, n_rows * 5)
129
+ fig, axes = plt.subplots(nrows=n_rows, ncols=n_cols,
130
+ figsize=figsize, sharex=True)
131
+ axes = axes.flatten()
132
+ returns.mean(1).add(1).cumprod().sub(1).plot(title='The Market', ax=axes[0])
133
+
134
+ for i in range(self.n_portfolios):
135
+ rc = returns.mul(eigen_portfolios.iloc[i]).sum(1).add(1).cumprod().sub(1)
136
+ rc.plot(title=f'Portfolio {i+1}', ax=axes[i + 1], lw=1, rot=0)
137
+
138
+ for j in range(self.n_portfolios + 1, len(axes)):
139
+ fig.delaxes(axes[j])
140
+
141
+ for i in range(self.n_portfolios + 1):
142
+ axes[i].set_xlabel('')
143
+
144
+ sns.despine()
145
+ fig.tight_layout()
146
+ plt.show()
147
+
148
+ def optimize(self, portfolio: int = 1, optimizer: str = 'hrp', prices=None, freq=252, plot=True):
149
+ """
150
+ Optimizes the chosen eigenportfolio based on a specified optimization method.
151
+
152
+ Parameters:
153
+ ----------
154
+ portfolio : int, optional
155
+ Index of the eigenportfolio to optimize (default is 1).
156
+ optimizer : str, optional
157
+ Optimization method: 'markowitz', 'hrp' (Hierarchical Risk Parity), or 'equal' (default is 'hrp').
158
+ prices : pd.DataFrame, optional
159
+ Asset prices used for Markowitz optimization (required if optimizer is 'markowitz').
160
+ freq : int, optional
161
+ Frequency of returns (e.g., 252 for daily returns).
162
+ plot : bool, optional
163
+ Whether to plot the performance of the optimized portfolio (default is True).
164
+
165
+ Returns:
166
+ -------
167
+ dict
168
+ Dictionary of optimized asset weights.
169
+
170
+ Raises:
171
+ ------
172
+ ValueError
173
+ If an unknown optimizer is specified, or if prices are not provided when using Markowitz optimization.
174
+
175
+ Notes:
176
+ -----
177
+ The optimization method varies based on risk-return assumptions, with options for traditional Markowitz optimization,
178
+ Hierarchical Risk Parity, or equal weighting.
179
+ """
180
+ portfolio = self.get_portfolios().iloc[portfolio - 1]
181
+ returns = self.returns.loc[:, portfolio.index]
182
+ returns = returns.loc[:, ~returns.columns.duplicated()]
183
+ returns = returns.loc[~returns.index.duplicated(keep='first')]
184
+ if optimizer == 'markowitz':
185
+ if prices is None:
186
+ raise ValueError("prices must be provided for markowitz optimization")
187
+ prices = prices.loc[:, returns.columns]
188
+ weights = markowitz_weights(prices=prices, freq=freq)
189
+ elif optimizer == 'hrp':
190
+ weights = hierarchical_risk_parity(returns=returns, freq=freq)
191
+ elif optimizer == 'equal':
192
+ weights = equal_weighted(returns=returns)
193
+ else:
194
+ raise ValueError(f"Unknown optimizer: {optimizer}")
195
+ if plot:
196
+ # plot the optimized potfolio performance
197
+ returns = returns.filter(weights.keys())
198
+ rc = returns.mul(weights).sum(1).add(1).cumprod().sub(1)
199
+ rc.plot(title=f'Optimized {portfolio.name}', lw=1, rot=0)
200
+ sns.despine()
201
+ plt.show()
202
+ return weights
@@ -90,7 +90,7 @@ def _mt5_execution(
90
90
  bot_token = kwargs.get('bot_token')
91
91
  chat_id = kwargs.get('chat_id')
92
92
 
93
- def _send_notification(self, signal):
93
+ def _send_notification(signal):
94
94
  send_message(message=signal, notify_me=notify,
95
95
  telegram=telegram, token=bot_token, chat_id=chat_id)
96
96
 
@@ -104,8 +104,6 @@ def _mt5_execution(
104
104
  if not mm:
105
105
  return
106
106
  if buys is not None or sells is not None:
107
- logger.info(
108
- f"Checking for Break even, SYMBOL={symbol}...STRATEGY={STRATEGY}")
109
107
  trades_instances[symbol].break_even(
110
108
  mm=mm, trail=trail, stop_trail=stop_trail,
111
109
  trail_after_points=trail_after_points, be_plus_points=be_plus_points)
@@ -119,10 +117,10 @@ def _mt5_execution(
119
117
  check_mt5_connection()
120
118
  strategy: MT5Strategy = strategy_cls(symbol_list=symbols, mode='live', **kwargs)
121
119
  except Exception as e:
122
- logger.error(f"Error initializing strategy, {e}, STRATEGY={STRATEGY}")
120
+ logger.error(f"Initializing strategy, {e}, STRATEGY={STRATEGY}")
123
121
  return
124
122
  logger.info(
125
- f'Running {STRATEGY} Strategy on {symbols} in {time_frame} Interval ...')
123
+ f'Running {STRATEGY} Strategy in {time_frame} Interval ...')
126
124
 
127
125
  while True:
128
126
  try:
@@ -132,7 +130,9 @@ def _mt5_execution(
132
130
  time.sleep(0.5)
133
131
  positions_orders = {}
134
132
  for type in POSITIONS_TYPES + ORDERS_TYPES:
133
+ positions_orders[type] = {}
135
134
  for symbol in symbols:
135
+ positions_orders[type][symbol] = None
136
136
  func = getattr(trades_instances[symbol], f"get_current_{type}")
137
137
  positions_orders[type][symbol] = func()
138
138
  buys = positions_orders['buys']
@@ -154,7 +154,7 @@ def _mt5_execution(
154
154
  }
155
155
 
156
156
  except Exception as e:
157
- logger.error(f"{e}, STRATEGY={STRATEGY}")
157
+ logger.error(f"Handling Positions and Orders, {e}, STRATEGY={STRATEGY}")
158
158
  continue
159
159
  time.sleep(0.5)
160
160
  try:
@@ -183,7 +183,8 @@ def _mt5_execution(
183
183
  signal = 'SMKT' if signal == 'SHORT' else signal
184
184
  info = f"SIGNAL = {signal}, SYMBOL={trade.symbol}, STRATEGY={STRATEGY}"
185
185
  msg = f"Sending {signal} Order ... SYMBOL={trade.symbol}, STRATEGY={STRATEGY}"
186
- logger.info(info)
186
+ if signal not in EXIT_SIGNAL_ACTIONS:
187
+ logger.info(info)
187
188
  if signal in EXIT_SIGNAL_ACTIONS:
188
189
  for exit_signal, actions in EXIT_SIGNAL_ACTIONS.items():
189
190
  for position_type, order_type in actions.items():
@@ -235,13 +236,15 @@ def _mt5_execution(
235
236
  elif signal in SELLS and short_market[symbol]:
236
237
  logger.info(riskmsg)
237
238
  check(buys[symbol], sells[symbol], symbol)
239
+ else:
240
+ check(buys[symbol], sells[symbol], symbol)
238
241
  else:
239
242
  logger.info(
240
243
  f"Not trading Time !!! SYMBOL={trade.symbol}, STRATEGY={STRATEGY}")
241
244
  check(buys[symbol], sells[symbol], symbol)
242
245
 
243
246
  except Exception as e:
244
- logger.error(f"{e}, SYMBOL={symbol}, STRATEGY={STRATEGY}")
247
+ logger.error(f"Handling Signals {e}, SYMBOL={symbol}, STRATEGY={STRATEGY}")
245
248
  continue
246
249
  time.sleep((60 * iter_time) - 1.0)
247
250
  if iter_time == 1:
@@ -253,7 +256,6 @@ def _mt5_execution(
253
256
  f"iter_time must be a multiple of the {time_frame} !!!"
254
257
  f"(e.g; if time_frame is 15m, iter_time must be 1.5, 3, 3, 15 etc)"
255
258
  )
256
- print()
257
259
  try:
258
260
  FRIDAY = 'friday'
259
261
  check_mt5_connection()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bbstrader
3
- Version: 0.1.91
3
+ Version: 0.1.92
4
4
  Summary: Simplified Investment & Trading Toolkit
5
5
  Home-page: https://github.com/bbalouki/bbstrader
6
6
  Download-URL: https://pypi.org/project/bbstrader/
@@ -46,6 +46,7 @@ Requires-Dist: tqdm
46
46
  Requires-Dist: scikit-learn
47
47
  Requires-Dist: notify-py
48
48
  Requires-Dist: python-telegram-bot
49
+ Requires-Dist: pyportfolioopt
49
50
  Provides-Extra: mt5
50
51
  Requires-Dist: MetaTrader5 ; extra == 'mt5'
51
52
 
@@ -2,30 +2,31 @@ bbstrader/__ini__.py,sha256=rCTy-3g2RlDAgIZ7cSET9-I74MwuCXpp-xGVTFS8NNc,482
2
2
  bbstrader/config.py,sha256=_AD_Cd-w5zyabm1CBPNGhzcZuSjThB7jyzTcjbrIlUQ,3618
3
3
  bbstrader/tseries.py,sha256=qJKLxHnPOjB7dXon-ITK7vU1fAuvl8evzET6lSSnijQ,53572
4
4
  bbstrader/btengine/__init__.py,sha256=OaXZTjgDwqWrjPq-CNE4kJkmriKXt9t5pIghW1MDTeo,2911
5
- bbstrader/btengine/backtest.py,sha256=HDuCNUETLUBZ0R8AVLeT5gZVVk8UQ6oeReUtDvUFhIk,13681
6
- bbstrader/btengine/data.py,sha256=zeF3O7_vPT3NvAxlAcUS9OoxF09XhypDhGiVRml3U1A,17622
7
- bbstrader/btengine/event.py,sha256=DQTmUQ4l4yZgL47hW1dGv__CecNF-rDqsyFst5kCQvA,8453
8
- bbstrader/btengine/execution.py,sha256=gijnRvknNi921TjtF9wSyBG_nI0e2cda2NKIm3LJh1k,10222
5
+ bbstrader/btengine/backtest.py,sha256=bgQNiS_kb1zWyX_8_OIDGTBlHGC5pw7ZMIQ5csYDahA,14115
6
+ bbstrader/btengine/data.py,sha256=A6jUqDnjl-w1OSzbLLPfS1WfJ8Se25AqigJs9pbe0wc,17966
7
+ bbstrader/btengine/event.py,sha256=zF_ST4tcjV5uJJVV1IbRXQgCLbca2R2fmE7A2MaIno4,8748
8
+ bbstrader/btengine/execution.py,sha256=i-vI9LGqVtEIKfH_T5Airv-gI4t1X75CfuFIuQdogkA,10187
9
9
  bbstrader/btengine/performance.py,sha256=bKwj1_CSygvggLKTXPASp2eWhDdwyCf06ayUaXwdh4E,10655
10
- bbstrader/btengine/portfolio.py,sha256=uf1fx0RuTTQNNxiYmCYW_sh6sBWbtNq4uIRusKA5MdY,15399
11
- bbstrader/btengine/strategy.py,sha256=ktTzlJzSTo9zhZsILwj2vtPHjAkI-aSnKA8PcT3nF6Y,23206
10
+ bbstrader/btengine/portfolio.py,sha256=9Jw0UA2gPu-YkUNenoMSHt8t-axtbl6veWXgcRMTQ14,16074
11
+ bbstrader/btengine/strategy.py,sha256=w5vwlpJNIVjslaHprWDr-3j-JJPiO1EvqeyrJmorByE,25721
12
12
  bbstrader/metatrader/__init__.py,sha256=OLVOB_EieEb1P72I8V4Vem8kQWJ__D_L3c_wfwqY-9k,211
13
13
  bbstrader/metatrader/account.py,sha256=hVH83vnAdfMOzUsF9PiWelqxa7HaLSTpCVlUEePnSZg,53912
14
- bbstrader/metatrader/rates.py,sha256=xWTsM8PMPGdgwoPXcIRvHpJ1FKRIXHJcn4qrgmA7XRg,18737
14
+ bbstrader/metatrader/rates.py,sha256=1dJHbVqoT41m3EhF0wRe7dSGe5Kf3o5Maskkw-i5qsQ,20810
15
15
  bbstrader/metatrader/risk.py,sha256=8FcLY8pgV8_rxAcjx179sdqaMu66wl-fDFPZvdihfUw,25953
16
- bbstrader/metatrader/trade.py,sha256=68hYIA01OPApRgGNdS1YFgM83ZemkCmv6mTXOCS_kWc,70052
16
+ bbstrader/metatrader/trade.py,sha256=uigDah9n_rVJiwSslTAArLP94sde1dxYyGyRVIPPgb4,70210
17
17
  bbstrader/metatrader/utils.py,sha256=BTaZun4DKWpCxBBzY0SLQqqz7n_7F_R1F59APfyaa3E,17666
18
18
  bbstrader/models/__init__.py,sha256=6tAj9V9vgwesgPVMKznwRB3k8-Ec8Q73Di5p2UO0qlA,274
19
19
  bbstrader/models/factors.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  bbstrader/models/ml.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- bbstrader/models/optimization.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ bbstrader/models/optimization.py,sha256=0ZOCinveMCSxpL4gTBO5lbZ6jb1HXp0CeIjO-_fNGro,6521
22
+ bbstrader/models/portfolios.py,sha256=TyLcYwxW86t1qH6YS5xBaiKB9Owezq3ovffjR0Othlw,8184
22
23
  bbstrader/models/risk.py,sha256=Pm_WoGI-vtPW75fwo_7ptF2Br-xQYBwrAAOIgqDQmy8,15120
23
24
  bbstrader/trading/__init__.py,sha256=3CCzV5rQbH8NthjDJhD0_2FABvpiCmkeC9cVeoW7bi4,438
24
- bbstrader/trading/execution.py,sha256=5mGanLIB1nPfbXA9r_uKGDCkcV-ilVgZmssF7Oh4SeI,25186
25
+ bbstrader/trading/execution.py,sha256=Ze_YFrdgU9mRZsS4J08lZNE77HBwxrun5rdqHkS4dzE,25348
25
26
  bbstrader/trading/scripts.py,sha256=rQmnG_4F_MuUEc96RXpAQT4kXrC-FkscsgHKgDAR_-Y,1902
26
27
  bbstrader/trading/strategies.py,sha256=ztKNL4Nmlb-4N8_cq0OJyn3E2cRcdKdKu3FeTbZrHsU,36402
27
- bbstrader-0.1.91.dist-info/LICENSE,sha256=1EudjwwP2oTJy8Vh0e-Kzv8VZZU95y-t6c3DYhR51uc,1115
28
- bbstrader-0.1.91.dist-info/METADATA,sha256=ZwHR-DYmIhULp_5dh48oQ_lGR0VYQnkGG8GcaQe1KP4,9901
29
- bbstrader-0.1.91.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
30
- bbstrader-0.1.91.dist-info/top_level.txt,sha256=Wwj322jZmxGZ6gD_TdaPiPLjED5ReObm5omerwlmZIg,10
31
- bbstrader-0.1.91.dist-info/RECORD,,
28
+ bbstrader-0.1.92.dist-info/LICENSE,sha256=1EudjwwP2oTJy8Vh0e-Kzv8VZZU95y-t6c3DYhR51uc,1115
29
+ bbstrader-0.1.92.dist-info/METADATA,sha256=LZdzZUHKait-lIvprJ85AxfRuHp8VE5QPS69y8uZIIs,9932
30
+ bbstrader-0.1.92.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
31
+ bbstrader-0.1.92.dist-info/top_level.txt,sha256=Wwj322jZmxGZ6gD_TdaPiPLjED5ReObm5omerwlmZIg,10
32
+ bbstrader-0.1.92.dist-info/RECORD,,