bbstrader 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bbstrader might be problematic. Click here for more details.

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