bbstrader 0.1.91__py3-none-any.whl → 0.1.93__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/btengine/backtest.py +26 -13
- bbstrader/btengine/data.py +10 -2
- bbstrader/btengine/event.py +10 -4
- bbstrader/btengine/execution.py +8 -7
- bbstrader/btengine/performance.py +2 -0
- bbstrader/btengine/portfolio.py +50 -28
- bbstrader/btengine/strategy.py +223 -69
- bbstrader/metatrader/account.py +1 -1
- bbstrader/metatrader/rates.py +50 -2
- bbstrader/metatrader/risk.py +28 -3
- bbstrader/metatrader/trade.py +7 -4
- bbstrader/models/__init__.py +5 -1
- bbstrader/models/optimization.py +177 -0
- bbstrader/models/portfolios.py +205 -0
- bbstrader/trading/execution.py +31 -16
- {bbstrader-0.1.91.dist-info → bbstrader-0.1.93.dist-info}/METADATA +2 -1
- bbstrader-0.1.93.dist-info/RECORD +32 -0
- {bbstrader-0.1.91.dist-info → bbstrader-0.1.93.dist-info}/WHEEL +1 -1
- bbstrader-0.1.91.dist-info/RECORD +0 -31
- {bbstrader-0.1.91.dist-info → bbstrader-0.1.93.dist-info}/LICENSE +0 -0
- {bbstrader-0.1.91.dist-info → bbstrader-0.1.93.dist-info}/top_level.txt +0 -0
bbstrader/btengine/backtest.py
CHANGED
|
@@ -85,6 +85,8 @@ class BacktestEngine(Backtest):
|
|
|
85
85
|
strategy (Strategy): Generates signals based on market data.
|
|
86
86
|
kwargs : Additional parameters based on the `ExecutionHandler`,
|
|
87
87
|
the `DataHandler`, the `Strategy` used and the `Portfolio`.
|
|
88
|
+
- show_equity (bool): Show the equity curve of the portfolio.
|
|
89
|
+
- stats_file (str): File to save the summary stats.
|
|
88
90
|
"""
|
|
89
91
|
self.symbol_list = symbol_list
|
|
90
92
|
self.initial_capital = initial_capital
|
|
@@ -104,6 +106,7 @@ class BacktestEngine(Backtest):
|
|
|
104
106
|
|
|
105
107
|
self._generate_trading_instances()
|
|
106
108
|
self.show_equity = kwargs.get("show_equity", False)
|
|
109
|
+
self.stats_file = kwargs.get("stats_file", None)
|
|
107
110
|
|
|
108
111
|
def _generate_trading_instances(self):
|
|
109
112
|
"""
|
|
@@ -111,8 +114,9 @@ class BacktestEngine(Backtest):
|
|
|
111
114
|
their class types.
|
|
112
115
|
"""
|
|
113
116
|
print(
|
|
114
|
-
f"\
|
|
115
|
-
f"
|
|
117
|
+
f"\n[======= STARTING BACKTEST =======]\n"
|
|
118
|
+
f"START DATE: {self.start_date} \n"
|
|
119
|
+
f"INITIAL CAPITAL: {self.initial_capital}\n"
|
|
116
120
|
)
|
|
117
121
|
self.data_handler: DataHandler = self.dh_cls(
|
|
118
122
|
self.events, self.symbol_list, **self.kwargs
|
|
@@ -136,13 +140,21 @@ class BacktestEngine(Backtest):
|
|
|
136
140
|
i = 0
|
|
137
141
|
while True:
|
|
138
142
|
i += 1
|
|
139
|
-
|
|
143
|
+
value = self.portfolio.all_holdings[-1]['Total']
|
|
140
144
|
if self.data_handler.continue_backtest == True:
|
|
145
|
+
# Update the market bars
|
|
141
146
|
self.data_handler.update_bars()
|
|
142
147
|
self.strategy.check_pending_orders()
|
|
148
|
+
self.strategy.get_update_from_portfolio(
|
|
149
|
+
self.portfolio.current_positions,
|
|
150
|
+
self.portfolio.current_holdings
|
|
151
|
+
)
|
|
152
|
+
self.strategy.cash = value
|
|
143
153
|
else:
|
|
144
|
-
print("\n[======= BACKTEST
|
|
145
|
-
print(f"
|
|
154
|
+
print("\n[======= BACKTEST COMPLETED =======]")
|
|
155
|
+
print(f"END DATE: {self.data_handler.get_latest_bar_datetime(self.symbol_list[0])}")
|
|
156
|
+
print(f"TOTAL BARS: {i} ")
|
|
157
|
+
print(f"PORFOLIO VALUE: {round(value, 2)}")
|
|
146
158
|
break
|
|
147
159
|
|
|
148
160
|
# Handle the events
|
|
@@ -168,6 +180,7 @@ class BacktestEngine(Backtest):
|
|
|
168
180
|
elif event.type == 'FILL':
|
|
169
181
|
self.fills += 1
|
|
170
182
|
self.portfolio.update_fill(event)
|
|
183
|
+
self.strategy.update_trades_from_fill(event)
|
|
171
184
|
|
|
172
185
|
time.sleep(self.heartbeat)
|
|
173
186
|
|
|
@@ -185,13 +198,13 @@ class BacktestEngine(Backtest):
|
|
|
185
198
|
stat2['Orders'] = self.orders
|
|
186
199
|
stat2['Fills'] = self.fills
|
|
187
200
|
stats.extend(stat2.items())
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
tab_stats = tabulate(stats, headers=["Metric", "Value"], tablefmt="outline")
|
|
202
|
+
print(tab_stats, "\n")
|
|
203
|
+
if self.stats_file:
|
|
204
|
+
with open(self.stats_file, 'a') as f:
|
|
205
|
+
f.write("\n[======= Summary Stats =======]\n")
|
|
206
|
+
f.write(tab_stats)
|
|
207
|
+
f.write("\n")
|
|
195
208
|
|
|
196
209
|
if self.show_equity:
|
|
197
210
|
print("\nCreating equity curve...")
|
|
@@ -329,7 +342,7 @@ def run_backtest_with(engine: Literal["bbstrader", "cerebro", "zipline"], **kwar
|
|
|
329
342
|
data_handler=kwargs.get("data_handler"),
|
|
330
343
|
strategy=kwargs.get("strategy"),
|
|
331
344
|
exc_handler=kwargs.get("exc_handler"),
|
|
332
|
-
initial_capital=kwargs.get("initial_capital"),
|
|
345
|
+
initial_capital=kwargs.get("initial_capital", 100000.0),
|
|
333
346
|
heartbeat=kwargs.get("heartbeat", 0.0),
|
|
334
347
|
**kwargs
|
|
335
348
|
)
|
bbstrader/btengine/data.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
"""
|
bbstrader/btengine/event.py
CHANGED
|
@@ -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'),
|
|
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
|
"""
|
bbstrader/btengine/execution.py
CHANGED
|
@@ -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,
|
|
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"
|
|
67
|
+
self.logger = kwargs.get("logger")
|
|
68
68
|
|
|
69
69
|
def execute_order(self, event: OrderEvent):
|
|
70
70
|
"""
|
|
@@ -77,8 +77,9 @@ class SimExecutionHandler(ExecutionHandler):
|
|
|
77
77
|
if event.type == 'ORDER':
|
|
78
78
|
dtime = self.bardata.get_latest_bar_datetime(event.symbol)
|
|
79
79
|
fill_event = FillEvent(
|
|
80
|
-
dtime, event.symbol,
|
|
81
|
-
'ARCA', event.quantity, event.direction,
|
|
80
|
+
timeindex=dtime, symbol=event.symbol,
|
|
81
|
+
exchange='ARCA', quantity=event.quantity, direction=event.direction,
|
|
82
|
+
fill_cost=None, commission=None, order=event.signal
|
|
82
83
|
)
|
|
83
84
|
self.events.put(fill_event)
|
|
84
85
|
self.logger.info(
|
|
@@ -121,7 +122,7 @@ class MT5ExecutionHandler(ExecutionHandler):
|
|
|
121
122
|
"""
|
|
122
123
|
self.events = events
|
|
123
124
|
self.bardata = data
|
|
124
|
-
self.logger = kwargs.get("logger"
|
|
125
|
+
self.logger = kwargs.get("logger")
|
|
125
126
|
self.__account = Account()
|
|
126
127
|
|
|
127
128
|
def _calculate_lot(self, symbol, quantity, price):
|
|
@@ -232,7 +233,7 @@ class MT5ExecutionHandler(ExecutionHandler):
|
|
|
232
233
|
fill_event = FillEvent(
|
|
233
234
|
timeindex=dtime, symbol=symbol,
|
|
234
235
|
exchange='MT5', quantity=quantity, direction=direction,
|
|
235
|
-
fill_cost=None, commission=fees
|
|
236
|
+
fill_cost=None, commission=fees, order=event.signal
|
|
236
237
|
)
|
|
237
238
|
self.events.put(fill_event)
|
|
238
239
|
self.logger.info(
|
bbstrader/btengine/portfolio.py
CHANGED
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
@@ -96,19 +93,23 @@ class Portfolio(object):
|
|
|
96
93
|
initial_capital (float): The starting capital in USD.
|
|
97
94
|
|
|
98
95
|
kwargs (dict): Additional arguments
|
|
96
|
+
- `leverage`: The leverage to apply to the portfolio.
|
|
99
97
|
- `time_frame`: The time frame of the bars.
|
|
100
|
-
- `
|
|
98
|
+
- `session_duration`: The number of trading hours in a day.
|
|
101
99
|
- `benchmark`: The benchmark symbol to compare the portfolio.
|
|
102
|
-
- `
|
|
100
|
+
- `output_dir`: The directory to save the backtest results.
|
|
101
|
+
- `strategy_name`: The name of the strategy (the name must not include 'Strategy' in it).
|
|
102
|
+
- `print_stats`: Whether to print the backtest statistics.
|
|
103
103
|
"""
|
|
104
104
|
self.bars = bars
|
|
105
105
|
self.events = events
|
|
106
106
|
self.symbol_list = self.bars.symbol_list
|
|
107
107
|
self.start_date = start_date
|
|
108
108
|
self.initial_capital = initial_capital
|
|
109
|
+
self._leverage = kwargs.get('leverage', 1)
|
|
109
110
|
|
|
110
111
|
self.timeframe = kwargs.get("time_frame", "D1")
|
|
111
|
-
self.trading_hours = kwargs.get("session_duration",
|
|
112
|
+
self.trading_hours = kwargs.get("session_duration", 23)
|
|
112
113
|
self.benchmark = kwargs.get('benchmark', 'SPY')
|
|
113
114
|
self.output_dir = kwargs.get('output_dir', None)
|
|
114
115
|
self.strategy_name = kwargs.get('strategy_name', '')
|
|
@@ -174,6 +175,23 @@ class Portfolio(object):
|
|
|
174
175
|
d['Commission'] = 0.0
|
|
175
176
|
d['Total'] = self.initial_capital
|
|
176
177
|
return d
|
|
178
|
+
|
|
179
|
+
def _get_price(self, symbol: str) -> float:
|
|
180
|
+
try:
|
|
181
|
+
price = self.bars.get_latest_bar_value(
|
|
182
|
+
symbol, "adj_close"
|
|
183
|
+
)
|
|
184
|
+
return price
|
|
185
|
+
except AttributeError:
|
|
186
|
+
try:
|
|
187
|
+
price = self.bars.get_latest_bar_value(
|
|
188
|
+
symbol, "close"
|
|
189
|
+
)
|
|
190
|
+
return price
|
|
191
|
+
except AttributeError:
|
|
192
|
+
raise AttributeError(
|
|
193
|
+
f"Bars object must have 'adj_close' or 'close' prices"
|
|
194
|
+
)
|
|
177
195
|
|
|
178
196
|
def update_timeindex(self, event: MarketEvent):
|
|
179
197
|
"""
|
|
@@ -203,8 +221,8 @@ class Portfolio(object):
|
|
|
203
221
|
dh['Total'] = self.current_holdings['Cash']
|
|
204
222
|
for s in self.symbol_list:
|
|
205
223
|
# Approximation to the real value
|
|
206
|
-
|
|
207
|
-
|
|
224
|
+
price = self._get_price(s)
|
|
225
|
+
market_value = self.current_positions[s] * price
|
|
208
226
|
dh[s] = market_value
|
|
209
227
|
dh['Total'] += market_value
|
|
210
228
|
|
|
@@ -245,9 +263,7 @@ class Portfolio(object):
|
|
|
245
263
|
fill_dir = -1
|
|
246
264
|
|
|
247
265
|
# Update holdings list with new quantities
|
|
248
|
-
price = self.
|
|
249
|
-
fill.symbol, "adj_close"
|
|
250
|
-
)
|
|
266
|
+
price = self._get_price(fill.symbol)
|
|
251
267
|
cost = fill_dir * price * fill.quantity
|
|
252
268
|
self.current_holdings[fill.symbol] += cost
|
|
253
269
|
self.current_holdings['Commission'] += fill.commission
|
|
@@ -263,14 +279,15 @@ class Portfolio(object):
|
|
|
263
279
|
self.update_positions_from_fill(event)
|
|
264
280
|
self.update_holdings_from_fill(event)
|
|
265
281
|
|
|
266
|
-
def
|
|
282
|
+
def generate_order(self, signal: SignalEvent):
|
|
267
283
|
"""
|
|
268
|
-
|
|
269
|
-
sizing of the signal object, without risk management or
|
|
270
|
-
position sizing considerations.
|
|
284
|
+
Turns a SignalEvent into an OrderEvent.
|
|
271
285
|
|
|
272
286
|
Args:
|
|
273
287
|
signal (SignalEvent): The tuple containing Signal information.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
OrderEvent: The OrderEvent to be executed.
|
|
274
291
|
"""
|
|
275
292
|
order = None
|
|
276
293
|
|
|
@@ -278,31 +295,36 @@ class Portfolio(object):
|
|
|
278
295
|
direction = signal.signal_type
|
|
279
296
|
quantity = signal.quantity
|
|
280
297
|
strength = signal.strength
|
|
281
|
-
price = signal.price
|
|
298
|
+
price = signal.price or self._get_price(symbol)
|
|
282
299
|
cur_quantity = self.current_positions[symbol]
|
|
300
|
+
mkt_quantity = round(quantity * strength, 2)
|
|
301
|
+
new_quantity = mkt_quantity * self._leverage
|
|
283
302
|
|
|
284
|
-
|
|
285
|
-
|
|
303
|
+
if direction in ['LONG', 'SHORT', 'EXIT']:
|
|
304
|
+
order_type = 'MKT'
|
|
305
|
+
else:
|
|
306
|
+
order_type = direction
|
|
286
307
|
|
|
287
|
-
if
|
|
288
|
-
order = OrderEvent(symbol, order_type,
|
|
289
|
-
if direction == 'SHORT' and
|
|
290
|
-
order = OrderEvent(symbol, order_type,
|
|
308
|
+
if direction == 'LONG' and new_quantity > 0:
|
|
309
|
+
order = OrderEvent(symbol, order_type, new_quantity, 'BUY', price, direction)
|
|
310
|
+
if direction == 'SHORT' and new_quantity > 0:
|
|
311
|
+
order = OrderEvent(symbol, order_type, new_quantity, 'SELL', price, direction)
|
|
291
312
|
|
|
292
313
|
if direction == 'EXIT' and cur_quantity > 0:
|
|
293
|
-
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price)
|
|
314
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price, direction)
|
|
294
315
|
if direction == 'EXIT' and cur_quantity < 0:
|
|
295
|
-
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price)
|
|
316
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price, direction)
|
|
296
317
|
|
|
297
318
|
return order
|
|
298
319
|
|
|
320
|
+
|
|
299
321
|
def update_signal(self, event: SignalEvent):
|
|
300
322
|
"""
|
|
301
323
|
Acts on a SignalEvent to generate new orders
|
|
302
324
|
based on the portfolio logic.
|
|
303
325
|
"""
|
|
304
326
|
if event.type == 'SIGNAL':
|
|
305
|
-
order_event = self.
|
|
327
|
+
order_event = self.generate_order(event)
|
|
306
328
|
self.events.put(order_event)
|
|
307
329
|
|
|
308
330
|
def create_equity_curve_dataframe(self):
|