bbstrader 2.0.3__cp312-cp312-macosx_11_0_arm64.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.
Files changed (45) hide show
  1. bbstrader/__init__.py +27 -0
  2. bbstrader/__main__.py +92 -0
  3. bbstrader/api/__init__.py +96 -0
  4. bbstrader/api/handlers.py +245 -0
  5. bbstrader/api/metatrader_client.cpython-312-darwin.so +0 -0
  6. bbstrader/api/metatrader_client.pyi +624 -0
  7. bbstrader/assets/bbs_.png +0 -0
  8. bbstrader/assets/bbstrader.ico +0 -0
  9. bbstrader/assets/bbstrader.png +0 -0
  10. bbstrader/assets/qs_metrics_1.png +0 -0
  11. bbstrader/btengine/__init__.py +54 -0
  12. bbstrader/btengine/backtest.py +358 -0
  13. bbstrader/btengine/data.py +737 -0
  14. bbstrader/btengine/event.py +229 -0
  15. bbstrader/btengine/execution.py +287 -0
  16. bbstrader/btengine/performance.py +408 -0
  17. bbstrader/btengine/portfolio.py +393 -0
  18. bbstrader/btengine/strategy.py +588 -0
  19. bbstrader/compat.py +28 -0
  20. bbstrader/config.py +100 -0
  21. bbstrader/core/__init__.py +27 -0
  22. bbstrader/core/data.py +628 -0
  23. bbstrader/core/strategy.py +466 -0
  24. bbstrader/metatrader/__init__.py +48 -0
  25. bbstrader/metatrader/_copier.py +720 -0
  26. bbstrader/metatrader/account.py +865 -0
  27. bbstrader/metatrader/broker.py +418 -0
  28. bbstrader/metatrader/copier.py +1487 -0
  29. bbstrader/metatrader/rates.py +495 -0
  30. bbstrader/metatrader/risk.py +667 -0
  31. bbstrader/metatrader/trade.py +1692 -0
  32. bbstrader/metatrader/utils.py +402 -0
  33. bbstrader/models/__init__.py +39 -0
  34. bbstrader/models/nlp.py +932 -0
  35. bbstrader/models/optimization.py +182 -0
  36. bbstrader/scripts.py +665 -0
  37. bbstrader/trading/__init__.py +33 -0
  38. bbstrader/trading/execution.py +1159 -0
  39. bbstrader/trading/strategy.py +362 -0
  40. bbstrader/trading/utils.py +69 -0
  41. bbstrader-2.0.3.dist-info/METADATA +396 -0
  42. bbstrader-2.0.3.dist-info/RECORD +45 -0
  43. bbstrader-2.0.3.dist-info/WHEEL +5 -0
  44. bbstrader-2.0.3.dist-info/entry_points.txt +3 -0
  45. bbstrader-2.0.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,393 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from queue import Queue
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ import pandas as pd
7
+ import quantstats as qs
8
+
9
+ from bbstrader.btengine.data import DataHandler
10
+ from bbstrader.btengine.event import (
11
+ Events,
12
+ FillEvent,
13
+ MarketEvent,
14
+ OrderEvent,
15
+ SignalEvent,
16
+ )
17
+ from bbstrader.btengine.performance import (
18
+ create_drawdowns,
19
+ create_sharpe_ratio,
20
+ create_sortino_ratio,
21
+ plot_monthly_yearly_returns,
22
+ plot_performance,
23
+ plot_returns_and_dd,
24
+ show_qs_stats,
25
+ )
26
+ from bbstrader.metatrader.utils import TIMEFRAMES
27
+
28
+ __all__ = [
29
+ "Portfolio",
30
+ ]
31
+
32
+
33
+ class Portfolio:
34
+ """
35
+ This describes a `Portfolio()` object that keeps track of the positions
36
+ within a portfolio and generates orders of a fixed quantity of stock based on signals.
37
+
38
+ The portfolio order management system is possibly the most complex component of
39
+ an event driven backtester. Its role is to keep track of all current market positions
40
+ as well as the market value of the positions (known as the "holdings").
41
+ This is simply an estimate of the liquidation value of the position and is derived in part
42
+ from the data handling facility of the backtester.
43
+
44
+ In addition to the positions and holdings management the portfolio must also be aware of
45
+ risk factors and position sizing techniques in order to optimise orders that are sent
46
+ to a brokerage or other form of market access.
47
+
48
+ Unfortunately, Portfolio and `Order Management Systems (OMS)` can become rather complex!
49
+ So let's keep the `Portfolio` object relatively straightforward anf improve it foward.
50
+
51
+ Continuing in the vein of the Event class hierarchy a Portfolio object must be able
52
+ to handle `SignalEvent` objects, generate `OrderEvent` objects and interpret `FillEvent`
53
+ objects to update positions. Thus it is no surprise that the Portfolio objects are often
54
+ the largest component of event-driven systems, in terms of lines of code (LOC).
55
+
56
+ The initialisation of the Portfolio object requires access to the bars `DataHandler`,
57
+ the `Event Queue`, a `start datetime stamp` and an `initial capital`
58
+ value (defaulting to `100,000 USD`) and others parameter based on the `Strategy` requirement.
59
+
60
+ The `Portfolio` is designed to handle position sizing and current holdings,
61
+ but will carry out trading orders by simply them to the brokerage with a predetermined
62
+ fixed quantity size, if the portfolio has enough cash to place the order.
63
+
64
+
65
+ The portfolio contains the `all_positions` and `current_positions` members.
66
+ The former stores a list of all previous positions recorded at the timestamp of a market data event.
67
+ A position is simply the quantity of the asset held. Negative positions mean the asset has been shorted.
68
+
69
+ The latter current_positions dictionary stores contains the current positions
70
+ for the last market bar update, for each symbol.
71
+
72
+ In addition to the positions data the portfolio stores `holdings`,
73
+ which describe the current market value of the positions held. "Current market value"
74
+ in this instance means the closing price obtained from the current market bar,
75
+ which is clearly an approximation, but is reasonable enough for the time being.
76
+ `all_holdings` stores the historical list of all symbol holdings, while current_holdings
77
+ stores the most up to date dictionary of all symbol holdings values.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ bars: DataHandler,
83
+ events: "Queue[Union[OrderEvent, FillEvent, SignalEvent]]",
84
+ start_date: datetime,
85
+ initial_capital: float = 100000.0,
86
+ **kwargs: Any,
87
+ ) -> None:
88
+ """
89
+ Initialises the portfolio with bars and an event queue.
90
+ Also includes a starting datetime index and initial capital
91
+ (USD unless otherwise stated).
92
+
93
+ Args:
94
+ bars (DataHandler): The DataHandler object with current market data.
95
+ events (Queue): The Event Queue object.
96
+ start_date (datetime): The start date (bar) of the portfolio.
97
+ initial_capital (float): The starting capital in USD.
98
+
99
+ kwargs (dict): Additional arguments
100
+ - `leverage`: The leverage to apply to the portfolio.
101
+ - `time_frame`: The time frame of the bars.
102
+ - `session_duration`: The number of trading hours in a day.
103
+ - `benchmark`: The benchmark symbol to compare the portfolio.
104
+ - `output_dir`: The directory to save the backtest results.
105
+ - `strategy_name`: The name of the strategy (the name must not include 'Strategy' in it).
106
+ - `print_stats`: Whether to print the backtest statistics.
107
+ """
108
+ self.bars = bars
109
+ self.events = events
110
+ self.symbol_list = self.bars.symbols
111
+ self.start_date = start_date
112
+ self.initial_capital = initial_capital
113
+ self._leverage = kwargs.get("leverage", 1)
114
+
115
+ self.trading_hours = kwargs.get("session_duration", 23)
116
+ self.benchmark = kwargs.get("benchmark", "SPY")
117
+ self.output_dir = kwargs.get("output_dir", None)
118
+ self.strategy_name = kwargs.get("strategy_name", "")
119
+ self.print_stats = kwargs.get("print_stats", True)
120
+ timeframe = kwargs.get("time_frame", "D1")
121
+ if timeframe not in TIMEFRAMES:
122
+ raise ValueError("Timeframe not supported")
123
+ if timeframe == "D1":
124
+ self.tf = 252
125
+ else:
126
+ if "m" in timeframe:
127
+ minutes = int(timeframe.replace("m", ""))
128
+ bars_per_day = self.trading_hours * (60 / minutes)
129
+ elif "h" in timeframe:
130
+ hours = int(timeframe.replace("h", ""))
131
+ bars_per_day = self.trading_hours / hours
132
+ else:
133
+ bars_per_day = 1 # Should not be reached given the check
134
+ self.tf = int(252 * bars_per_day)
135
+
136
+ self.all_positions: List[Dict[str, Any]] = self.construct_all_positions()
137
+ self.current_positions: Dict[str, Any] = dict(
138
+ (k, v) for k, v in [(s, 0) for s in self.symbol_list]
139
+ )
140
+ self.all_holdings: List[Dict[str, Any]] = self.construct_all_holdings()
141
+ self.current_holdings: Dict[str, Any] = self.construct_current_holdings()
142
+ self.equity_curve: Optional[pd.DataFrame] = None
143
+
144
+ def construct_all_positions(self) -> List[Dict[str, Any]]:
145
+ """
146
+ Constructs the positions list using the start_date
147
+ to determine when the time index will begin.
148
+ """
149
+ d = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
150
+ d["Datetime"] = self.start_date
151
+ return [d]
152
+
153
+ def construct_all_holdings(self) -> List[Dict[str, Any]]:
154
+ """
155
+ Constructs the holdings list using the start_date
156
+ to determine when the time index will begin.
157
+ """
158
+ d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
159
+ d["Datetime"] = self.start_date
160
+ d["Cash"] = self.initial_capital
161
+ d["Commission"] = 0.0
162
+ d["Total"] = self.initial_capital
163
+ return [d]
164
+
165
+ def construct_current_holdings(self) -> Dict[str, float]:
166
+ """
167
+ This constructs the dictionary which will hold the instantaneous
168
+ value of the portfolio across all symbols.
169
+ """
170
+ d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
171
+ d["Cash"] = self.initial_capital
172
+ d["Commission"] = 0.0
173
+ d["Total"] = self.initial_capital
174
+ return d
175
+
176
+ def _get_price(self, symbol: str) -> float:
177
+ try:
178
+ price = self.bars.get_latest_bar_value(symbol, "adj_close")
179
+ return price
180
+ except (AttributeError, KeyError, ValueError):
181
+ try:
182
+ price = self.bars.get_latest_bar_value(symbol, "close")
183
+ return price
184
+ except (AttributeError, KeyError, ValueError):
185
+ return 0.0
186
+
187
+ def update_timeindex(self, event: MarketEvent) -> None:
188
+ """
189
+ Adds a new record to the positions matrix for the current
190
+ market data bar. This reflects the PREVIOUS bar, i.e. all
191
+ current market data at this stage is known (OHLCV).
192
+ Makes use of a MarketEvent from the events queue.
193
+ """
194
+ latest_datetime = self.bars.get_latest_bar_datetime(self.symbol_list[0])
195
+ # Update positions
196
+ # ================
197
+ dp = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
198
+ dp["Datetime"] = latest_datetime
199
+ for s in self.symbol_list:
200
+ dp[s] = self.current_positions[s]
201
+ # Append the current positions
202
+ self.all_positions.append(dp)
203
+
204
+ # Update holdings
205
+ # ===============
206
+ dh = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
207
+ dh["Datetime"] = latest_datetime
208
+ dh["Cash"] = self.current_holdings["Cash"]
209
+ dh["Commission"] = self.current_holdings["Commission"]
210
+ dh["Total"] = self.current_holdings["Cash"]
211
+ for s in self.symbol_list:
212
+ # Approximation to the real value
213
+ price = self._get_price(s)
214
+ market_value = self.current_positions[s] * price
215
+ dh[s] = market_value
216
+ dh["Total"] += market_value
217
+
218
+ # Append the current holdings
219
+ self.all_holdings.append(dh)
220
+
221
+ def update_positions_from_fill(self, fill: FillEvent) -> None:
222
+ """
223
+ Takes a Fill object and updates the position matrix to
224
+ reflect the new position.
225
+
226
+ Args:
227
+ fill (FillEvent): The Fill object to update the positions with.
228
+ """
229
+ # Check whether the fill is a buy or sell
230
+ fill_dir = 0
231
+ if fill.direction == "BUY":
232
+ fill_dir = 1
233
+ if fill.direction == "SELL":
234
+ fill_dir = -1
235
+
236
+ # Update positions list with new quantities
237
+ self.current_positions[fill.symbol] += fill_dir * fill.quantity
238
+
239
+ def update_holdings_from_fill(self, fill: FillEvent) -> None:
240
+ """
241
+ Takes a Fill object and updates the holdings matrix to
242
+ reflect the holdings value.
243
+
244
+ Args:
245
+ fill (FillEvent): The Fill object to update the holdings with.
246
+ """
247
+ # Check whether the fill is a buy or sell
248
+ fill_dir = 0
249
+ if fill.direction == "BUY":
250
+ fill_dir = 1
251
+ if fill.direction == "SELL":
252
+ fill_dir = -1
253
+
254
+ # Update holdings list with new quantities
255
+ price = self._get_price(fill.symbol)
256
+ cost = fill_dir * price * fill.quantity
257
+ self.current_holdings[fill.symbol] += cost
258
+ self.current_holdings["Commission"] += fill.commission
259
+ self.current_holdings["Cash"] -= cost + fill.commission
260
+ self.current_holdings["Total"] -= cost + fill.commission
261
+
262
+ def update_fill(self, event: FillEvent) -> None:
263
+ """
264
+ Updates the portfolio current positions and holdings
265
+ from a FillEvent.
266
+ """
267
+ if event.type == Events.FILL:
268
+ self.update_positions_from_fill(event)
269
+ self.update_holdings_from_fill(event)
270
+
271
+ def generate_order(self, signal: SignalEvent) -> Optional[OrderEvent]:
272
+ """
273
+ Turns a SignalEvent into an OrderEvent.
274
+
275
+ Args:
276
+ signal (SignalEvent): The tuple containing Signal information.
277
+
278
+ Returns:
279
+ OrderEvent: The OrderEvent to be executed.
280
+ """
281
+ order = None
282
+
283
+ symbol = signal.symbol
284
+ direction = signal.signal_type
285
+ quantity = signal.quantity
286
+ strength = signal.strength
287
+ price = signal.price or self._get_price(symbol)
288
+ cur_quantity = self.current_positions[symbol]
289
+ mkt_quantity = round(float(quantity) * float(strength), 2)
290
+ new_quantity = mkt_quantity * self._leverage
291
+
292
+ if direction in ["LONG", "SHORT", "EXIT"]:
293
+ order_type = "MKT"
294
+ else:
295
+ order_type = direction
296
+
297
+ if direction == "LONG" and new_quantity > 0:
298
+ order = OrderEvent(
299
+ symbol, order_type, new_quantity, "BUY", price, direction
300
+ )
301
+ if direction == "SHORT" and new_quantity > 0:
302
+ order = OrderEvent(
303
+ symbol, order_type, new_quantity, "SELL", price, direction
304
+ )
305
+
306
+ if direction == "EXIT" and cur_quantity > 0:
307
+ order = OrderEvent(
308
+ symbol, order_type, abs(cur_quantity), "SELL", price, direction
309
+ )
310
+ if direction == "EXIT" and cur_quantity < 0:
311
+ order = OrderEvent(
312
+ symbol, order_type, abs(cur_quantity), "BUY", price, direction
313
+ )
314
+
315
+ return order
316
+
317
+ def update_signal(self, event: SignalEvent) -> None:
318
+ """
319
+ Acts on a SignalEvent to generate new orders
320
+ based on the portfolio logic.
321
+ """
322
+ if event.type == Events.SIGNAL:
323
+ order_event = self.generate_order(event)
324
+ self.events.put(order_event)
325
+
326
+ def create_equity_curve_dataframe(self) -> None:
327
+ """
328
+ Creates a pandas DataFrame from the all_holdings
329
+ list of dictionaries.
330
+ """
331
+ curve = pd.DataFrame(self.all_holdings)
332
+ curve["Datetime"] = pd.to_datetime(curve["Datetime"], utc=True)
333
+ curve.set_index("Datetime", inplace=True)
334
+ curve["Returns"] = curve["Total"].pct_change(fill_method=None)
335
+ curve["Equity Curve"] = (1.0 + curve["Returns"]).cumprod()
336
+ self.equity_curve = curve
337
+
338
+ def output_summary_stats(self) -> List[Any]:
339
+ """
340
+ Creates a list of summary statistics for the portfolio.
341
+ """
342
+ if self.equity_curve is None:
343
+ self.create_equity_curve_dataframe()
344
+
345
+ total_return = self.equity_curve["Equity Curve"].iloc[-1] # type: ignore
346
+ returns = self.equity_curve["Returns"] # type: ignore
347
+ pnl = self.equity_curve["Equity Curve"] # type: ignore
348
+
349
+ sharpe_ratio = create_sharpe_ratio(returns, periods=self.tf)
350
+ sortino_ratio = create_sortino_ratio(returns, periods=self.tf)
351
+ drawdown, _, _ = create_drawdowns(pnl)
352
+ drawdown = drawdown.fillna(0.0)
353
+ max_dd = qs.stats.max_drawdown(returns)
354
+ dd_details = qs.stats.drawdown_details(drawdown)
355
+ if dd_details.empty:
356
+ dd_duration = 0
357
+ else:
358
+ dd_duration = dd_details["days"].max()
359
+ self.equity_curve["Drawdown"] = drawdown
360
+
361
+ stats = [
362
+ ("Total Return", f"{(total_return - 1.0) * 100.0:.2f}%"),
363
+ ("Sharpe Ratio", f"{sharpe_ratio:.2f}"),
364
+ ("Sortino Ratio", f"{sortino_ratio:.2f}"),
365
+ ("Max Drawdown", f"{max_dd * 100.0:.2f}%"),
366
+ ("Drawdown Duration", f"{dd_duration}"),
367
+ ]
368
+ now = datetime.now().strftime("%Y%m%d%H%M%S")
369
+ strategy_name = self.strategy_name.replace(" ", "_")
370
+ if self.output_dir:
371
+ results_dir = Path(self.output_dir) / strategy_name
372
+ else:
373
+ results_dir = Path(".backtests") / strategy_name
374
+ results_dir.mkdir(parents=True, exist_ok=True)
375
+
376
+ csv_file = f"{strategy_name}_{now}_equities.csv"
377
+ png_file = f"{strategy_name}_{now}_returns_heatmap.png"
378
+ html_file = f"{strategy_name}_{now}_report.html"
379
+ self.equity_curve.to_csv(results_dir / csv_file)
380
+
381
+ if self.print_stats:
382
+ plot_performance(self.equity_curve, self.strategy_name)
383
+ plot_returns_and_dd(self.equity_curve, self.benchmark, self.strategy_name)
384
+ qs.plots.monthly_heatmap(returns, savefig=f"{results_dir}/{png_file}")
385
+ plot_monthly_yearly_returns(self.equity_curve, self.strategy_name)
386
+ show_qs_stats(
387
+ returns,
388
+ self.benchmark,
389
+ self.strategy_name,
390
+ save_dir=f"{results_dir}/{html_file}",
391
+ )
392
+
393
+ return stats