bbstrader 0.1.9__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.
- bbstrader/__ini__.py +4 -2
- bbstrader/btengine/__init__.py +5 -5
- bbstrader/btengine/backtest.py +60 -12
- bbstrader/btengine/data.py +157 -57
- bbstrader/btengine/event.py +14 -5
- bbstrader/btengine/execution.py +128 -26
- bbstrader/btengine/performance.py +4 -7
- bbstrader/btengine/portfolio.py +83 -36
- bbstrader/btengine/strategy.py +525 -6
- bbstrader/config.py +111 -0
- bbstrader/metatrader/__init__.py +4 -4
- bbstrader/metatrader/account.py +348 -53
- bbstrader/metatrader/rates.py +280 -27
- bbstrader/metatrader/risk.py +34 -23
- bbstrader/metatrader/trade.py +328 -169
- bbstrader/metatrader/utils.py +2 -53
- bbstrader/models/factors.py +0 -0
- bbstrader/models/ml.py +0 -0
- bbstrader/models/optimization.py +170 -0
- bbstrader/models/portfolios.py +202 -0
- bbstrader/trading/__init__.py +1 -1
- bbstrader/trading/execution.py +275 -169
- bbstrader/trading/scripts.py +57 -0
- bbstrader/trading/strategies.py +41 -65
- bbstrader/tseries.py +274 -39
- {bbstrader-0.1.9.dist-info → bbstrader-0.1.92.dist-info}/METADATA +12 -3
- bbstrader-0.1.92.dist-info/RECORD +32 -0
- {bbstrader-0.1.9.dist-info → bbstrader-0.1.92.dist-info}/WHEEL +1 -1
- bbstrader-0.1.9.dist-info/RECORD +0 -26
- {bbstrader-0.1.9.dist-info → bbstrader-0.1.92.dist-info}/LICENSE +0 -0
- {bbstrader-0.1.9.dist-info → bbstrader-0.1.92.dist-info}/top_level.txt +0 -0
bbstrader/btengine/event.py
CHANGED
|
@@ -57,7 +57,8 @@ class SignalEvent(Event):
|
|
|
57
57
|
signal_type: Literal['LONG', 'SHORT', 'EXIT'],
|
|
58
58
|
quantity: int | float = 100,
|
|
59
59
|
strength: int | float = 1.0,
|
|
60
|
-
price: int | float = None
|
|
60
|
+
price: int | float = None,
|
|
61
|
+
stoplimit: int | float = None
|
|
61
62
|
):
|
|
62
63
|
"""
|
|
63
64
|
Initialises the SignalEvent.
|
|
@@ -73,6 +74,7 @@ class SignalEvent(Event):
|
|
|
73
74
|
strength (int | float): An adjustment factor "suggestion" used to scale
|
|
74
75
|
quantity at the portfolio level. Useful for pairs strategies.
|
|
75
76
|
price (int | float): An optional price to be used when the signal is generated.
|
|
77
|
+
stoplimit (int | float): An optional stop-limit price for the signal
|
|
76
78
|
"""
|
|
77
79
|
self.type = 'SIGNAL'
|
|
78
80
|
self.strategy_id = strategy_id
|
|
@@ -82,6 +84,7 @@ class SignalEvent(Event):
|
|
|
82
84
|
self.quantity = quantity
|
|
83
85
|
self.strength = strength
|
|
84
86
|
self.price = price
|
|
87
|
+
self.stoplimit = stoplimit
|
|
85
88
|
|
|
86
89
|
|
|
87
90
|
class OrderEvent(Event):
|
|
@@ -99,14 +102,15 @@ class OrderEvent(Event):
|
|
|
99
102
|
|
|
100
103
|
def __init__(self,
|
|
101
104
|
symbol: str,
|
|
102
|
-
order_type: Literal['MKT', 'LMT'],
|
|
105
|
+
order_type: Literal['MKT', 'LMT', 'STP', 'STPLMT'],
|
|
103
106
|
quantity: int | float,
|
|
104
107
|
direction: Literal['BUY', 'SELL'],
|
|
105
|
-
price: int | float = None
|
|
108
|
+
price: int | float = None,
|
|
109
|
+
signal: str = None
|
|
106
110
|
):
|
|
107
111
|
"""
|
|
108
112
|
Initialises the order type, setting whether it is
|
|
109
|
-
a Market order ('MKT') or Limit order ('LMT'),
|
|
113
|
+
a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP').
|
|
110
114
|
a quantity (integral or float) and its direction ('BUY' or 'SELL').
|
|
111
115
|
|
|
112
116
|
Args:
|
|
@@ -115,6 +119,7 @@ class OrderEvent(Event):
|
|
|
115
119
|
quantity (int | float): Non-negative number for quantity.
|
|
116
120
|
direction (str): 'BUY' or 'SELL' for long or short.
|
|
117
121
|
price (int | float): The price at which to order.
|
|
122
|
+
signal (str): The signal that generated the order.
|
|
118
123
|
"""
|
|
119
124
|
self.type = 'ORDER'
|
|
120
125
|
self.symbol = symbol
|
|
@@ -122,6 +127,7 @@ class OrderEvent(Event):
|
|
|
122
127
|
self.quantity = quantity
|
|
123
128
|
self.direction = direction
|
|
124
129
|
self.price = price
|
|
130
|
+
self.signal = signal
|
|
125
131
|
|
|
126
132
|
def print_order(self):
|
|
127
133
|
"""
|
|
@@ -159,7 +165,8 @@ class FillEvent(Event):
|
|
|
159
165
|
quantity: int | float,
|
|
160
166
|
direction: Literal['BUY', 'SELL'],
|
|
161
167
|
fill_cost: int | float | None,
|
|
162
|
-
commission: float | None = None
|
|
168
|
+
commission: float | None = None,
|
|
169
|
+
order: str = None
|
|
163
170
|
):
|
|
164
171
|
"""
|
|
165
172
|
Initialises the FillEvent object. Sets the symbol, exchange,
|
|
@@ -178,6 +185,7 @@ class FillEvent(Event):
|
|
|
178
185
|
direction (str): The direction of fill `('LONG', 'SHORT', 'EXIT')`
|
|
179
186
|
fill_cost (int | float): Price of the shares when filled.
|
|
180
187
|
commission (float | None): An optional commission sent from IB.
|
|
188
|
+
order (str): The order that this fill is related
|
|
181
189
|
"""
|
|
182
190
|
self.type = 'FILL'
|
|
183
191
|
self.timeindex = timeindex
|
|
@@ -191,6 +199,7 @@ class FillEvent(Event):
|
|
|
191
199
|
self.commission = self.calculate_ib_commission()
|
|
192
200
|
else:
|
|
193
201
|
self.commission = commission
|
|
202
|
+
self.order = order
|
|
194
203
|
|
|
195
204
|
def calculate_ib_commission(self):
|
|
196
205
|
"""
|
bbstrader/btengine/execution.py
CHANGED
|
@@ -2,15 +2,15 @@ from datetime import datetime
|
|
|
2
2
|
from queue import Queue
|
|
3
3
|
from abc import ABCMeta, abstractmethod
|
|
4
4
|
from bbstrader.btengine.event import FillEvent, OrderEvent
|
|
5
|
+
from bbstrader.btengine.data import DataHandler
|
|
5
6
|
from bbstrader.metatrader.account import Account
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"ExecutionHandler",
|
|
9
|
-
"
|
|
10
|
+
"SimExecutionHandler",
|
|
10
11
|
"MT5ExecutionHandler"
|
|
11
12
|
]
|
|
12
13
|
|
|
13
|
-
|
|
14
14
|
class ExecutionHandler(metaclass=ABCMeta):
|
|
15
15
|
"""
|
|
16
16
|
The ExecutionHandler abstract class handles the interaction
|
|
@@ -25,7 +25,8 @@ class ExecutionHandler(metaclass=ABCMeta):
|
|
|
25
25
|
|
|
26
26
|
The ExecutionHandler described here is exceedingly simple,
|
|
27
27
|
since it fills all orders at the current market price.
|
|
28
|
-
This is highly unrealistic,
|
|
28
|
+
This is highly unrealistic, for other markets thant ``CFDs``
|
|
29
|
+
but serves as a good baseline for improvement.
|
|
29
30
|
"""
|
|
30
31
|
|
|
31
32
|
@abstractmethod
|
|
@@ -42,7 +43,7 @@ class ExecutionHandler(metaclass=ABCMeta):
|
|
|
42
43
|
)
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
class
|
|
46
|
+
class SimExecutionHandler(ExecutionHandler):
|
|
46
47
|
"""
|
|
47
48
|
The simulated execution handler simply converts all order
|
|
48
49
|
objects into their equivalent fill objects automatically
|
|
@@ -53,7 +54,7 @@ class SimulatedExecutionHandler(ExecutionHandler):
|
|
|
53
54
|
handler.
|
|
54
55
|
"""
|
|
55
56
|
|
|
56
|
-
def __init__(self, events: Queue, **kwargs):
|
|
57
|
+
def __init__(self, events: Queue, data: DataHandler, **kwargs):
|
|
57
58
|
"""
|
|
58
59
|
Initialises the handler, setting the event queues
|
|
59
60
|
up internally.
|
|
@@ -62,6 +63,8 @@ class SimulatedExecutionHandler(ExecutionHandler):
|
|
|
62
63
|
events (Queue): The Queue of Event objects.
|
|
63
64
|
"""
|
|
64
65
|
self.events = events
|
|
66
|
+
self.bardata = data
|
|
67
|
+
self.logger = kwargs.get("logger")
|
|
65
68
|
|
|
66
69
|
def execute_order(self, event: OrderEvent):
|
|
67
70
|
"""
|
|
@@ -72,51 +75,145 @@ class SimulatedExecutionHandler(ExecutionHandler):
|
|
|
72
75
|
event (OrderEvent): Contains an Event object with order information.
|
|
73
76
|
"""
|
|
74
77
|
if event.type == 'ORDER':
|
|
78
|
+
dtime = self.bardata.get_latest_bar_datetime(event.symbol)
|
|
75
79
|
fill_event = FillEvent(
|
|
76
|
-
|
|
77
|
-
'ARCA', event.quantity, event.direction,
|
|
80
|
+
dtime, event.symbol,
|
|
81
|
+
'ARCA', event.quantity, event.direction, order=event.signal
|
|
78
82
|
)
|
|
79
83
|
self.events.put(fill_event)
|
|
84
|
+
self.logger.info(
|
|
85
|
+
f"{event.direction} ORDER FILLED: SYMBOL={event.symbol}, "
|
|
86
|
+
f"QUANTITY={event.quantity}, PRICE @{event.price} EXCHANGE={fill_event.exchange}",
|
|
87
|
+
custom_time=fill_event.timeindex
|
|
88
|
+
)
|
|
80
89
|
|
|
81
90
|
|
|
82
91
|
class MT5ExecutionHandler(ExecutionHandler):
|
|
83
|
-
|
|
92
|
+
"""
|
|
93
|
+
The main role of `MT5ExecutionHandler` class is to estimate the execution fees
|
|
94
|
+
for different asset classes on the MT5 terminal.
|
|
95
|
+
|
|
96
|
+
Generally we have four types of fees when we execute trades using the MT5 terminal
|
|
97
|
+
(commissions, swap, spread and other fees). But most of these fees depend on the specifications
|
|
98
|
+
of each instrument and the duration of the transaction for the swap for example.
|
|
99
|
+
|
|
100
|
+
Calculating the exact fees for each instrument would be a bit complex because our Backtest engine
|
|
101
|
+
and the Portfolio class do not take into account the duration of each trade to apply the appropriate
|
|
102
|
+
rate for the swap for example. So we have to use only the model of calculating the commissions
|
|
103
|
+
for each asset class and each instrument.
|
|
104
|
+
|
|
105
|
+
The second thing that must be taken into account on MT5 is the type of account offered by the broker.
|
|
106
|
+
Brokers have different account categories each with its specifications for each asset class and each instrument.
|
|
107
|
+
Again considering all these conditions would make our class very complex. So we took the `Raw Spread`
|
|
108
|
+
account fee calculation model from [Just Market](https://one.justmarkets.link/a/tufvj0xugm/registration/trader)
|
|
109
|
+
for indicies, forex, commodities and crypto. We used the [Admiral Market](https://cabinet.a-partnership.com/visit/?bta=35537&brand=admiralmarkets)
|
|
110
|
+
account fee calculation model from `Trade.MT5` account type for stocks and ETFs.
|
|
111
|
+
|
|
112
|
+
NOTE:
|
|
113
|
+
This class only works with `bbstrader.metatrader.data.MT5DataHandler` class.
|
|
114
|
+
"""
|
|
115
|
+
def __init__(self, events: Queue, data: DataHandler, **kwargs):
|
|
84
116
|
"""
|
|
117
|
+
Initialises the handler, setting the event queues up internally.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
events (Queue): The Queue of Event objects.
|
|
85
121
|
"""
|
|
86
122
|
self.events = events
|
|
87
|
-
self.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# TODO: Implement the calculation of fees based on the broker's fees
|
|
91
|
-
# https://www.metatrader5.com/en/terminal/help/trading/market_watch
|
|
92
|
-
# Calculate fees based on the broker's fees , swap and commission
|
|
93
|
-
return 0.0
|
|
123
|
+
self.bardata = data
|
|
124
|
+
self.logger = kwargs.get("logger")
|
|
125
|
+
self.__account = Account()
|
|
94
126
|
|
|
95
127
|
def _calculate_lot(self, symbol, quantity, price):
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
FUT = self.account.get_symbol_type(symbol) == 'FUT'
|
|
99
|
-
CRYPTO = self.account.get_symbol_type(symbol) == 'CRYPTO'
|
|
100
|
-
symbol_info = self.account.get_symbol_info(symbol)
|
|
128
|
+
symbol_type = self.__account.get_symbol_type(symbol)
|
|
129
|
+
symbol_info = self.__account.get_symbol_info(symbol)
|
|
101
130
|
contract_size = symbol_info.trade_contract_size
|
|
102
131
|
|
|
103
132
|
lot = (quantity*price) / (contract_size * price)
|
|
104
133
|
if contract_size == 1:
|
|
105
134
|
lot = quantity
|
|
106
|
-
if
|
|
135
|
+
if symbol_type in [
|
|
136
|
+
'COMD', 'FUT', 'CRYPTO'] and contract_size > 1:
|
|
107
137
|
lot = quantity / contract_size
|
|
108
|
-
if FX:
|
|
138
|
+
if symbol_type == 'FX':
|
|
109
139
|
lot = (quantity*price / contract_size)
|
|
110
140
|
return self._check_lot(symbol, lot)
|
|
111
141
|
|
|
112
142
|
def _check_lot(self, symbol, lot):
|
|
113
|
-
symbol_info =
|
|
143
|
+
symbol_info = self.__account.get_symbol_info(symbol)
|
|
114
144
|
if lot < symbol_info.volume_min:
|
|
115
145
|
return symbol_info.volume_min
|
|
116
146
|
elif lot > symbol_info.volume_max:
|
|
117
147
|
return symbol_info.volume_max
|
|
118
148
|
return round(lot, 2)
|
|
119
149
|
|
|
150
|
+
def _estimate_total_fees(self, symbol, lot, qty, price):
|
|
151
|
+
symbol_type = self.__account.get_symbol_type(symbol)
|
|
152
|
+
if symbol_type in ['STK', 'ETF']:
|
|
153
|
+
return self._estimate_stock_commission(symbol, qty, price)
|
|
154
|
+
elif symbol_type == 'FX':
|
|
155
|
+
return self._estimate_forex_commission(lot)
|
|
156
|
+
elif symbol_type == 'COMD':
|
|
157
|
+
return self._estimate_commodity_commission(lot)
|
|
158
|
+
elif symbol_type == 'IDX':
|
|
159
|
+
return self._estimate_index_commission(lot)
|
|
160
|
+
elif symbol_type == 'FUT':
|
|
161
|
+
return self._estimate_futures_commission()
|
|
162
|
+
elif symbol_type == 'CRYPTO':
|
|
163
|
+
return self._estimate_crypto_commission()
|
|
164
|
+
else:
|
|
165
|
+
return 0.0
|
|
166
|
+
|
|
167
|
+
def _estimate_stock_commission(self, symbol, qty, price):
|
|
168
|
+
# https://admiralmarkets.com/start-trading/contract-specifications?regulator=jsc
|
|
169
|
+
min_com = 1.0
|
|
170
|
+
min_aud = 8.0
|
|
171
|
+
min_dkk = 30.0
|
|
172
|
+
min_nok = min_sek = 10.0
|
|
173
|
+
us_com = 0.02 # per chare
|
|
174
|
+
ger_fr_uk_cm = 0.001 # percent
|
|
175
|
+
eu_asia_cm = 0.0015 # percent
|
|
176
|
+
if (
|
|
177
|
+
symbol in self.__account.get_stocks_from_country('USA')
|
|
178
|
+
or self.__account.get_symbol_type(symbol) == 'ETF'
|
|
179
|
+
and self.__account.get_currency_rates(symbol)['mc'] == 'USD'
|
|
180
|
+
):
|
|
181
|
+
return max(min_com, qty * us_com)
|
|
182
|
+
elif (
|
|
183
|
+
symbol in self.__account.get_stocks_from_country('GBR')
|
|
184
|
+
or symbol in self.__account.get_stocks_from_country('FRA')
|
|
185
|
+
or symbol in self.__account.get_stocks_from_country('DEU')
|
|
186
|
+
or self.__account.get_symbol_type(symbol) == 'ETF'
|
|
187
|
+
and self.__account.get_currency_rates(symbol)['mc'] in ['GBP', 'EUR']
|
|
188
|
+
):
|
|
189
|
+
return max(min_com, qty*price*ger_fr_uk_cm)
|
|
190
|
+
else:
|
|
191
|
+
if self.__account.get_currency_rates(symbol)['mc'] == 'AUD':
|
|
192
|
+
return max(min_aud, qty*price*eu_asia_cm)
|
|
193
|
+
elif self.__account.get_currency_rates(symbol)['mc'] == 'DKK':
|
|
194
|
+
return max(min_dkk, qty*price*eu_asia_cm)
|
|
195
|
+
elif self.__account.get_currency_rates(symbol)['mc'] == 'NOK':
|
|
196
|
+
return max(min_nok, qty*price*eu_asia_cm)
|
|
197
|
+
elif self.__account.get_currency_rates(symbol)['mc'] == 'SEK':
|
|
198
|
+
return max(min_sek, qty*price*eu_asia_cm)
|
|
199
|
+
else:
|
|
200
|
+
return max(min_com, qty*price*eu_asia_cm)
|
|
201
|
+
|
|
202
|
+
def _estimate_forex_commission(self, lot):
|
|
203
|
+
return 3.0 * lot
|
|
204
|
+
|
|
205
|
+
def _estimate_commodity_commission(self, lot):
|
|
206
|
+
return 3.0 * lot
|
|
207
|
+
|
|
208
|
+
def _estimate_index_commission(self, lot):
|
|
209
|
+
return 0.25 * lot
|
|
210
|
+
|
|
211
|
+
def _estimate_futures_commission(self):
|
|
212
|
+
return 0.0
|
|
213
|
+
|
|
214
|
+
def _estimate_crypto_commission(self):
|
|
215
|
+
return 0.0
|
|
216
|
+
|
|
120
217
|
def execute_order(self, event: OrderEvent):
|
|
121
218
|
"""
|
|
122
219
|
Executes an Order event by converting it into a Fill event.
|
|
@@ -130,13 +227,18 @@ class MT5ExecutionHandler(ExecutionHandler):
|
|
|
130
227
|
quantity = event.quantity
|
|
131
228
|
price = event.price
|
|
132
229
|
lot = self._calculate_lot(symbol, quantity, price)
|
|
133
|
-
fees = self._estimate_total_fees(symbol, lot)
|
|
230
|
+
fees = self._estimate_total_fees(symbol, lot, quantity, price)
|
|
231
|
+
dtime = self.bardata.get_latest_bar_datetime(symbol)
|
|
134
232
|
fill_event = FillEvent(
|
|
135
|
-
timeindex=
|
|
233
|
+
timeindex=dtime, symbol=symbol,
|
|
136
234
|
exchange='MT5', quantity=quantity, direction=direction,
|
|
137
|
-
fill_cost=None, commission=fees
|
|
235
|
+
fill_cost=None, commission=fees, order=event.signal
|
|
138
236
|
)
|
|
139
237
|
self.events.put(fill_event)
|
|
238
|
+
self.logger.info(
|
|
239
|
+
f"{direction} ORDER FILLED: SYMBOL={symbol}, QUANTITY={quantity}, "
|
|
240
|
+
f"PRICE @{price} EXCHANGE={fill_event.exchange}", custom_time=fill_event.timeindex
|
|
241
|
+
)
|
|
140
242
|
|
|
141
243
|
|
|
142
244
|
class IBExecutionHandler(ExecutionHandler):
|
|
@@ -2,15 +2,11 @@ import numpy as np
|
|
|
2
2
|
import pandas as pd
|
|
3
3
|
import seaborn as sns
|
|
4
4
|
import yfinance as yf
|
|
5
|
-
|
|
6
5
|
from scipy.stats import mstats
|
|
7
6
|
import matplotlib.pyplot as plt
|
|
8
7
|
from matplotlib.ticker import MaxNLocator
|
|
9
8
|
import quantstats as qs
|
|
10
9
|
|
|
11
|
-
import warnings
|
|
12
|
-
warnings.filterwarnings("ignore")
|
|
13
|
-
warnings.simplefilter(action='ignore', category=FutureWarning)
|
|
14
10
|
sns.set_theme()
|
|
15
11
|
|
|
16
12
|
__all__ = [
|
|
@@ -53,6 +49,7 @@ def create_sortino_ratio(returns, periods=252) -> float:
|
|
|
53
49
|
"""
|
|
54
50
|
return qs.stats.sortino(returns, periods=periods)
|
|
55
51
|
|
|
52
|
+
|
|
56
53
|
def create_drawdowns(pnl):
|
|
57
54
|
"""
|
|
58
55
|
Calculate the largest peak-to-trough drawdown of the PnL curve
|
|
@@ -138,7 +135,7 @@ def plot_performance(df, title):
|
|
|
138
135
|
plt.show()
|
|
139
136
|
|
|
140
137
|
|
|
141
|
-
def plot_returns_and_dd(df, benchmark: str, title):
|
|
138
|
+
def plot_returns_and_dd(df: pd.DataFrame, benchmark: str, title):
|
|
142
139
|
"""
|
|
143
140
|
Plot the returns and drawdowns of the strategy
|
|
144
141
|
compared to a benchmark.
|
|
@@ -216,7 +213,7 @@ def plot_returns_and_dd(df, benchmark: str, title):
|
|
|
216
213
|
plt.show()
|
|
217
214
|
|
|
218
215
|
|
|
219
|
-
def plot_monthly_yearly_returns(df, title):
|
|
216
|
+
def plot_monthly_yearly_returns(df:pd.DataFrame, title):
|
|
220
217
|
"""
|
|
221
218
|
Plot the monthly and yearly returns of the strategy.
|
|
222
219
|
|
|
@@ -253,7 +250,7 @@ def plot_monthly_yearly_returns(df, title):
|
|
|
253
250
|
|
|
254
251
|
# Calculate and prepare yearly returns DataFrame
|
|
255
252
|
yearly_returns_df = equity_df['Total'].resample(
|
|
256
|
-
'
|
|
253
|
+
'A').last().pct_change().to_frame(name='Yearly Returns') * 100
|
|
257
254
|
|
|
258
255
|
# Set the aesthetics for the plots
|
|
259
256
|
sns.set_theme(style="darkgrid")
|
bbstrader/btengine/portfolio.py
CHANGED
|
@@ -3,23 +3,34 @@ from queue import Queue
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from bbstrader.btengine.event import (
|
|
6
|
-
OrderEvent,
|
|
6
|
+
OrderEvent,
|
|
7
|
+
FillEvent,
|
|
8
|
+
MarketEvent,
|
|
9
|
+
SignalEvent
|
|
7
10
|
)
|
|
8
11
|
from bbstrader.btengine.data import DataHandler
|
|
9
12
|
from bbstrader.btengine.performance import (
|
|
10
|
-
create_drawdowns,
|
|
11
|
-
|
|
13
|
+
create_drawdowns,
|
|
14
|
+
create_sharpe_ratio,
|
|
15
|
+
create_sortino_ratio,
|
|
16
|
+
plot_performance,
|
|
17
|
+
show_qs_stats,
|
|
18
|
+
plot_returns_and_dd,
|
|
12
19
|
plot_monthly_yearly_returns
|
|
13
20
|
)
|
|
21
|
+
from bbstrader.config import BBSTRADER_DIR
|
|
14
22
|
import quantstats as qs
|
|
15
23
|
|
|
16
24
|
|
|
25
|
+
__all__ = [
|
|
26
|
+
'Portfolio',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
17
30
|
class Portfolio(object):
|
|
18
31
|
"""
|
|
19
32
|
This describes a `Portfolio()` object that keeps track of the positions
|
|
20
33
|
within a portfolio and generates orders of a fixed quantity of stock based on signals.
|
|
21
|
-
More sophisticated portfolio objects could include risk management and position
|
|
22
|
-
sizing tools (such as the `Kelly Criterion`).
|
|
23
34
|
|
|
24
35
|
The portfolio order management system is possibly the most complex component of
|
|
25
36
|
an event driven backtester. Its role is to keep track of all current market positions
|
|
@@ -44,10 +55,9 @@ class Portfolio(object):
|
|
|
44
55
|
value (defaulting to `100,000 USD`) and others parameter based on the `Strategy` requirement.
|
|
45
56
|
|
|
46
57
|
The `Portfolio` is designed to handle position sizing and current holdings,
|
|
47
|
-
but will carry out trading orders
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
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
|
+
|
|
51
61
|
|
|
52
62
|
The portfolio contains the `all_positions` and `current_positions` members.
|
|
53
63
|
The former stores a list of all previous positions recorded at the timestamp of a market data event.
|
|
@@ -95,9 +105,11 @@ class Portfolio(object):
|
|
|
95
105
|
self.initial_capital = initial_capital
|
|
96
106
|
|
|
97
107
|
self.timeframe = kwargs.get("time_frame", "D1")
|
|
98
|
-
self.trading_hours = kwargs.get("session_duration",
|
|
108
|
+
self.trading_hours = kwargs.get("session_duration", 23)
|
|
99
109
|
self.benchmark = kwargs.get('benchmark', 'SPY')
|
|
110
|
+
self.output_dir = kwargs.get('output_dir', None)
|
|
100
111
|
self.strategy_name = kwargs.get('strategy_name', '')
|
|
112
|
+
self.print_stats = kwargs.get('print_stats', True)
|
|
101
113
|
if self.timeframe not in self._tf_mapping():
|
|
102
114
|
raise ValueError(
|
|
103
115
|
f"Timeframe not supported,"
|
|
@@ -112,6 +124,7 @@ class Portfolio(object):
|
|
|
112
124
|
[(s, 0) for s in self.symbol_list])
|
|
113
125
|
self.all_holdings = self.construct_all_holdings()
|
|
114
126
|
self.current_holdings = self.construct_current_holdings()
|
|
127
|
+
self.equity_curve = None
|
|
115
128
|
|
|
116
129
|
def _tf_mapping(self):
|
|
117
130
|
"""
|
|
@@ -158,6 +171,23 @@ class Portfolio(object):
|
|
|
158
171
|
d['Commission'] = 0.0
|
|
159
172
|
d['Total'] = self.initial_capital
|
|
160
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
|
+
)
|
|
161
191
|
|
|
162
192
|
def update_timeindex(self, event: MarketEvent):
|
|
163
193
|
"""
|
|
@@ -187,8 +217,8 @@ class Portfolio(object):
|
|
|
187
217
|
dh['Total'] = self.current_holdings['Cash']
|
|
188
218
|
for s in self.symbol_list:
|
|
189
219
|
# Approximation to the real value
|
|
190
|
-
|
|
191
|
-
|
|
220
|
+
price = self._get_price(s)
|
|
221
|
+
market_value = self.current_positions[s] * price
|
|
192
222
|
dh[s] = market_value
|
|
193
223
|
dh['Total'] += market_value
|
|
194
224
|
|
|
@@ -229,9 +259,7 @@ class Portfolio(object):
|
|
|
229
259
|
fill_dir = -1
|
|
230
260
|
|
|
231
261
|
# Update holdings list with new quantities
|
|
232
|
-
price = self.
|
|
233
|
-
fill.symbol, "Adj Close"
|
|
234
|
-
)
|
|
262
|
+
price = self._get_price(fill.symbol)
|
|
235
263
|
cost = fill_dir * price * fill.quantity
|
|
236
264
|
self.current_holdings[fill.symbol] += cost
|
|
237
265
|
self.current_holdings['Commission'] += fill.commission
|
|
@@ -247,14 +275,16 @@ class Portfolio(object):
|
|
|
247
275
|
self.update_positions_from_fill(event)
|
|
248
276
|
self.update_holdings_from_fill(event)
|
|
249
277
|
|
|
250
|
-
def
|
|
278
|
+
def generate_order(self, signal: SignalEvent):
|
|
251
279
|
"""
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
position sizing considerations.
|
|
280
|
+
Check if the portfolio has enough cash to place an order
|
|
281
|
+
and generate an OrderEvent, else return None.
|
|
255
282
|
|
|
256
283
|
Args:
|
|
257
284
|
signal (SignalEvent): The tuple containing Signal information.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
OrderEvent: The OrderEvent to be executed.
|
|
258
288
|
"""
|
|
259
289
|
order = None
|
|
260
290
|
|
|
@@ -262,21 +292,33 @@ class Portfolio(object):
|
|
|
262
292
|
direction = signal.signal_type
|
|
263
293
|
quantity = signal.quantity
|
|
264
294
|
strength = signal.strength
|
|
265
|
-
price = signal.price
|
|
295
|
+
price = signal.price or self._get_price(symbol)
|
|
266
296
|
cur_quantity = self.current_positions[symbol]
|
|
297
|
+
cash = self.current_holdings['Cash']
|
|
267
298
|
|
|
268
|
-
|
|
299
|
+
if direction in ['LONG', 'SHORT', 'EXIT']:
|
|
300
|
+
order_type = 'MKT'
|
|
301
|
+
else:
|
|
302
|
+
order_type = direction
|
|
269
303
|
mkt_quantity = round(quantity * strength)
|
|
304
|
+
cost = mkt_quantity * price
|
|
270
305
|
|
|
271
|
-
if
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
312
|
+
|
|
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)
|
|
275
317
|
|
|
276
318
|
if direction == 'EXIT' and cur_quantity > 0:
|
|
277
|
-
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price)
|
|
319
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL', price, direction)
|
|
278
320
|
if direction == 'EXIT' and cur_quantity < 0:
|
|
279
|
-
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price)
|
|
321
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY', price, direction)
|
|
280
322
|
|
|
281
323
|
return order
|
|
282
324
|
|
|
@@ -286,7 +328,7 @@ class Portfolio(object):
|
|
|
286
328
|
based on the portfolio logic.
|
|
287
329
|
"""
|
|
288
330
|
if event.type == 'SIGNAL':
|
|
289
|
-
order_event = self.
|
|
331
|
+
order_event = self.generate_order(event)
|
|
290
332
|
self.events.put(order_event)
|
|
291
333
|
|
|
292
334
|
def create_equity_curve_dataframe(self):
|
|
@@ -295,6 +337,7 @@ class Portfolio(object):
|
|
|
295
337
|
list of dictionaries.
|
|
296
338
|
"""
|
|
297
339
|
curve = pd.DataFrame(self.all_holdings)
|
|
340
|
+
curve['Datetime'] = pd.to_datetime(curve['Datetime'], utc=True)
|
|
298
341
|
curve.set_index('Datetime', inplace=True)
|
|
299
342
|
curve['Returns'] = curve['Total'].pct_change(fill_method=None)
|
|
300
343
|
curve['Equity Curve'] = (1.0+curve['Returns']).cumprod()
|
|
@@ -325,20 +368,24 @@ class Portfolio(object):
|
|
|
325
368
|
]
|
|
326
369
|
now = datetime.now().strftime('%Y%m%d%H%M%S')
|
|
327
370
|
strategy_name = self.strategy_name.replace(' ', '_')
|
|
328
|
-
|
|
371
|
+
if self.output_dir:
|
|
372
|
+
results_dir = Path(self.output_dir) / strategy_name
|
|
373
|
+
else:
|
|
374
|
+
results_dir = Path('.backtests') / strategy_name
|
|
329
375
|
results_dir.mkdir(parents=True, exist_ok=True)
|
|
330
376
|
|
|
331
|
-
csv_file = f"{strategy_name}_{now}
|
|
377
|
+
csv_file = f"{strategy_name}_{now}_equities.csv"
|
|
332
378
|
png_file = f'{strategy_name}_{now}_returns_heatmap.png'
|
|
333
379
|
html_file = f"{strategy_name}_{now}_report.html"
|
|
334
380
|
self.equity_curve.to_csv(results_dir / csv_file)
|
|
335
381
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
382
|
+
if self.print_stats:
|
|
383
|
+
plot_performance(self.equity_curve, self.strategy_name)
|
|
384
|
+
plot_returns_and_dd(self.equity_curve, self.benchmark, self.strategy_name)
|
|
385
|
+
qs.plots.monthly_heatmap(returns, savefig=f"{results_dir}/{png_file}")
|
|
386
|
+
plot_monthly_yearly_returns(self.equity_curve, self.strategy_name)
|
|
387
|
+
show_qs_stats(returns, self.benchmark, self.strategy_name,
|
|
388
|
+
save_dir=f"{results_dir}/{html_file}")
|
|
342
389
|
|
|
343
390
|
return stats
|
|
344
391
|
|