bbstrader 0.0.1__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 +17 -0
- bbstrader/btengine/__init__.py +50 -0
- bbstrader/btengine/backtest.py +900 -0
- bbstrader/btengine/data.py +374 -0
- bbstrader/btengine/event.py +201 -0
- bbstrader/btengine/execution.py +83 -0
- bbstrader/btengine/performance.py +309 -0
- bbstrader/btengine/portfolio.py +326 -0
- bbstrader/btengine/strategy.py +31 -0
- bbstrader/metatrader/__init__.py +6 -0
- bbstrader/metatrader/account.py +1038 -0
- bbstrader/metatrader/rates.py +226 -0
- bbstrader/metatrader/risk.py +626 -0
- bbstrader/metatrader/trade.py +1296 -0
- bbstrader/metatrader/utils.py +669 -0
- bbstrader/models/__init__.py +6 -0
- bbstrader/models/risk.py +349 -0
- bbstrader/strategies.py +681 -0
- bbstrader/trading/__init__.py +4 -0
- bbstrader/trading/execution.py +965 -0
- bbstrader/trading/run.py +131 -0
- bbstrader/trading/utils.py +153 -0
- bbstrader/tseries.py +592 -0
- bbstrader-0.0.1.dist-info/LICENSE +21 -0
- bbstrader-0.0.1.dist-info/METADATA +132 -0
- bbstrader-0.0.1.dist-info/RECORD +28 -0
- bbstrader-0.0.1.dist-info/WHEEL +5 -0
- bbstrader-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import seaborn as sns
|
|
4
|
+
import yfinance as yf
|
|
5
|
+
|
|
6
|
+
from scipy.stats import mstats
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from matplotlib.ticker import MaxNLocator
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
warnings.filterwarnings("ignore")
|
|
12
|
+
sns.set_theme()
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"create_drawdowns",
|
|
16
|
+
"plot_performance",
|
|
17
|
+
"create_sharpe_ratio",
|
|
18
|
+
"create_sortino_ratio",
|
|
19
|
+
"plot_returns_and_dd",
|
|
20
|
+
"plot_monthly_yearly_returns"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_sharpe_ratio(returns, periods=252):
|
|
25
|
+
"""
|
|
26
|
+
Create the Sharpe ratio for the strategy, based on a
|
|
27
|
+
benchmark of zero (i.e. no risk-free rate information).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
returns : A pandas Series representing period percentage returns.
|
|
31
|
+
periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
S (float): Sharpe ratio
|
|
35
|
+
"""
|
|
36
|
+
if np.std(returns) != 0:
|
|
37
|
+
return np.sqrt(periods) * (np.mean(returns)) / np.std(returns)
|
|
38
|
+
else:
|
|
39
|
+
return 0.0
|
|
40
|
+
|
|
41
|
+
# Define a function to calculate the Sortino Ratio
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_sortino_ratio(returns, periods=252):
|
|
45
|
+
"""
|
|
46
|
+
Create the Sortino ratio for the strategy, based on a
|
|
47
|
+
benchmark of zero (i.e. no risk-free rate information).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
returns : A pandas Series representing period percentage returns.
|
|
51
|
+
periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
S (float): Sortino ratio
|
|
55
|
+
"""
|
|
56
|
+
# Calculate the annualized return
|
|
57
|
+
annualized_return = np.power(1 + np.mean(returns), periods) - 1
|
|
58
|
+
# Calculate the downside deviation
|
|
59
|
+
downside_returns = returns.copy()
|
|
60
|
+
downside_returns[returns > 0] = 0
|
|
61
|
+
annualized_downside_std = np.std(downside_returns) * np.sqrt(periods)
|
|
62
|
+
if annualized_downside_std != 0:
|
|
63
|
+
return annualized_return / annualized_downside_std
|
|
64
|
+
else:
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_drawdowns(pnl):
|
|
69
|
+
"""
|
|
70
|
+
Calculate the largest peak-to-trough drawdown of the PnL curve
|
|
71
|
+
as well as the duration of the drawdown. Requires that the
|
|
72
|
+
pnl_returns is a pandas Series.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
pnl : A pandas Series representing period percentage returns.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
(tuple): drawdown, duration - high-water mark, duration.
|
|
79
|
+
"""
|
|
80
|
+
# Calculate the cumulative returns curve
|
|
81
|
+
# and set up the High Water Mark
|
|
82
|
+
hwm = pd.Series(index=pnl.index)
|
|
83
|
+
hwm.iloc[0] = 0
|
|
84
|
+
|
|
85
|
+
# Create the drawdown and duration series
|
|
86
|
+
idx = pnl.index
|
|
87
|
+
drawdown = pd.Series(index=idx)
|
|
88
|
+
duration = pd.Series(index=idx)
|
|
89
|
+
|
|
90
|
+
# Loop over the index range
|
|
91
|
+
for t in range(1, len(idx)):
|
|
92
|
+
hwm.iloc[t] = max(hwm.iloc[t-1], pnl.iloc[t])
|
|
93
|
+
drawdown.iloc[t] = (hwm.iloc[t] - pnl.iloc[t])
|
|
94
|
+
duration.iloc[t] = (0 if drawdown.iloc[t] ==
|
|
95
|
+
0 else duration.iloc[t-1]+1)
|
|
96
|
+
|
|
97
|
+
return drawdown, drawdown.max(), duration.max()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plot_performance(df, title):
|
|
101
|
+
"""
|
|
102
|
+
Plot the performance of the strategy:
|
|
103
|
+
- (Portfolio value, %)
|
|
104
|
+
- (Period returns, %)
|
|
105
|
+
- (Drawdowns, %)
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
df (pd.DataFrame):
|
|
109
|
+
The DataFrame containing the strategy returns and drawdowns.
|
|
110
|
+
title (str): The title of the plot.
|
|
111
|
+
|
|
112
|
+
Note:
|
|
113
|
+
The DataFrame should contain the following columns
|
|
114
|
+
- Datetime: The timestamp of the data
|
|
115
|
+
- Equity Curve: The portfolio value
|
|
116
|
+
- Returns: The period returns
|
|
117
|
+
- Drawdown: The drawdowns
|
|
118
|
+
- Total : The total returns
|
|
119
|
+
"""
|
|
120
|
+
data = df.copy()
|
|
121
|
+
data = data.sort_values(by='Datetime')
|
|
122
|
+
# Plot three charts: Equity curve,
|
|
123
|
+
# period returns, drawdowns
|
|
124
|
+
fig = plt.figure(figsize=(14, 8))
|
|
125
|
+
fig.suptitle(f'{title} Strategy Performance', fontsize=16)
|
|
126
|
+
|
|
127
|
+
# Set the outer colour to white
|
|
128
|
+
sns.set_theme()
|
|
129
|
+
|
|
130
|
+
# Plot the equity curve
|
|
131
|
+
ax1 = fig.add_subplot(311, ylabel='Portfolio value, %')
|
|
132
|
+
data['Equity Curve'].plot(ax=ax1, color="blue", lw=2.)
|
|
133
|
+
ax1.set_xlabel('')
|
|
134
|
+
plt.grid(True)
|
|
135
|
+
|
|
136
|
+
# Plot the returns
|
|
137
|
+
ax2 = fig.add_subplot(312, ylabel='Period returns, %')
|
|
138
|
+
data['Returns'].plot(ax=ax2, color="black", lw=2.)
|
|
139
|
+
ax2.set_xlabel('')
|
|
140
|
+
plt.grid(True)
|
|
141
|
+
|
|
142
|
+
# Plot Drawdown
|
|
143
|
+
ax3 = fig.add_subplot(313, ylabel='Drawdowns, %')
|
|
144
|
+
data['Drawdown'].plot(ax=ax3, color="red", lw=2.)
|
|
145
|
+
ax3.set_xlabel('')
|
|
146
|
+
plt.grid(True)
|
|
147
|
+
|
|
148
|
+
# Plot the figure
|
|
149
|
+
plt.tight_layout()
|
|
150
|
+
plt.show()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def plot_returns_and_dd(df, benchmark: str, title):
|
|
154
|
+
"""
|
|
155
|
+
Plot the returns and drawdowns of the strategy
|
|
156
|
+
compared to a benchmark.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
df (pd.DataFrame):
|
|
160
|
+
The DataFrame containing the strategy returns and drawdowns.
|
|
161
|
+
benchmark (str):
|
|
162
|
+
The ticker symbol of the benchmark to compare the strategy to.
|
|
163
|
+
title (str): The title of the plot.
|
|
164
|
+
|
|
165
|
+
Note:
|
|
166
|
+
The DataFrame should contain the following columns:
|
|
167
|
+
- Datetime : The timestamp of the data
|
|
168
|
+
- Equity Curve : The portfolio value
|
|
169
|
+
- Returns : The period returns
|
|
170
|
+
- Drawdown : The drawdowns
|
|
171
|
+
- Total : The total returns
|
|
172
|
+
"""
|
|
173
|
+
# Ensure data is sorted by Datetime
|
|
174
|
+
data = df.copy()
|
|
175
|
+
data.reset_index(inplace=True)
|
|
176
|
+
data = data.sort_values(by='Datetime')
|
|
177
|
+
data.sort_values(by='Datetime', inplace=True)
|
|
178
|
+
|
|
179
|
+
# Get the first and last Datetime values
|
|
180
|
+
first_date = data['Datetime'].iloc[0]
|
|
181
|
+
last_date = data['Datetime'].iloc[-1]
|
|
182
|
+
|
|
183
|
+
# Download benchmark data from Yahoo Finance
|
|
184
|
+
# To avoid errors, we use the try-except block
|
|
185
|
+
# in case the benchmark is not available
|
|
186
|
+
try:
|
|
187
|
+
bm = yf.download(benchmark, start=first_date, end=last_date)
|
|
188
|
+
bm['log_return'] = np.log(bm['Close'] / bm['Close'].shift(1))
|
|
189
|
+
# Use exponential to get cumulative returns
|
|
190
|
+
bm_returns = np.exp(np.cumsum(bm['log_return'].fillna(0)))
|
|
191
|
+
|
|
192
|
+
# Normalize bm series to start at 1.0
|
|
193
|
+
bm_returns_normalized = bm_returns / bm_returns.iloc[0]
|
|
194
|
+
except Exception:
|
|
195
|
+
bm = None
|
|
196
|
+
|
|
197
|
+
# Create figure and plot space
|
|
198
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(
|
|
199
|
+
14, 8), gridspec_kw={'height_ratios': [3, 1]})
|
|
200
|
+
|
|
201
|
+
# Plot the Equity Curve for the strategy
|
|
202
|
+
ax1.plot(data['Datetime'], data['Equity Curve'],
|
|
203
|
+
label='Backtest', color='green', lw=2.5)
|
|
204
|
+
# Check benchmarck an Plot the Returns for the benchmark
|
|
205
|
+
if bm is not None:
|
|
206
|
+
ax1.plot(bm.index, bm_returns_normalized,
|
|
207
|
+
label='benchmark', color='gray', lw=2.5)
|
|
208
|
+
ax1.set_title(f'{title} Strategy vs. Benchmark ({benchmark})')
|
|
209
|
+
else:
|
|
210
|
+
ax1.set_title(f'{title} Strategy Returns')
|
|
211
|
+
ax1.set_xlabel('Date')
|
|
212
|
+
ax1.set_ylabel('Cumulative Returns')
|
|
213
|
+
ax1.grid(True)
|
|
214
|
+
ax1.legend(loc='upper left')
|
|
215
|
+
|
|
216
|
+
# Plot the Drawdown
|
|
217
|
+
ax2.fill_between(data['Datetime'], data['Drawdown'],
|
|
218
|
+
0, color='red', step="pre", alpha=0.5)
|
|
219
|
+
ax2.plot(data['Datetime'], data['Drawdown'], color='red',
|
|
220
|
+
alpha=0.6, lw=2.5) # Overlay the line
|
|
221
|
+
ax2.set_title('Drawdown (%)')
|
|
222
|
+
ax2.set_xlabel('Date')
|
|
223
|
+
ax2.set_ylabel('Drawdown')
|
|
224
|
+
ax2.grid(True)
|
|
225
|
+
|
|
226
|
+
# Display the plot
|
|
227
|
+
plt.tight_layout()
|
|
228
|
+
plt.show()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def plot_monthly_yearly_returns(df, title):
|
|
232
|
+
"""
|
|
233
|
+
Plot the monthly and yearly returns of the strategy.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
df (pd.DataFrame):
|
|
237
|
+
The DataFrame containing the strategy returns and drawdowns.
|
|
238
|
+
title (str): The title of the plot.
|
|
239
|
+
|
|
240
|
+
Note:
|
|
241
|
+
The DataFrame should contain the following columns:
|
|
242
|
+
- Datetime : The timestamp of the data
|
|
243
|
+
- Equity Curve : The portfolio value
|
|
244
|
+
- Returns : The period returns
|
|
245
|
+
- Drawdown : The drawdowns
|
|
246
|
+
- Total : The total returns
|
|
247
|
+
"""
|
|
248
|
+
equity_df = df.copy()
|
|
249
|
+
equity_df.reset_index(inplace=True)
|
|
250
|
+
equity_df['Datetime'] = pd.to_datetime(equity_df['Datetime'])
|
|
251
|
+
equity_df.set_index('Datetime', inplace=True)
|
|
252
|
+
|
|
253
|
+
# Calculate daily returns
|
|
254
|
+
equity_df['Daily Returns'] = equity_df['Total'].pct_change()
|
|
255
|
+
|
|
256
|
+
# Group by year and month to get monthly returns
|
|
257
|
+
monthly_returns = equity_df['Daily Returns'].groupby(
|
|
258
|
+
[equity_df.index.year, equity_df.index.month]
|
|
259
|
+
).apply(lambda x: (1 + x).prod() - 1)
|
|
260
|
+
|
|
261
|
+
# Prepare monthly returns DataFrame
|
|
262
|
+
monthly_returns_df = monthly_returns.unstack(level=-1) * 100
|
|
263
|
+
monthly_returns_df.columns = monthly_returns_df.columns.map(
|
|
264
|
+
lambda x: pd.to_datetime(x, format='%m').strftime('%b'))
|
|
265
|
+
|
|
266
|
+
# Calculate and prepare yearly returns DataFrame
|
|
267
|
+
yearly_returns_df = equity_df['Total'].resample(
|
|
268
|
+
'YE').last().pct_change().to_frame(name='Yearly Returns') * 100
|
|
269
|
+
|
|
270
|
+
# Set the aesthetics for the plots
|
|
271
|
+
sns.set_theme(style="darkgrid")
|
|
272
|
+
|
|
273
|
+
# Initialize the matplotlib figure,
|
|
274
|
+
# adjust the height_ratios to give more space to the yearly returns
|
|
275
|
+
f, (ax1, ax2) = plt.subplots(2, 1, figsize=(
|
|
276
|
+
12, 8), gridspec_kw={'height_ratios': [2, 1]})
|
|
277
|
+
f.suptitle(f'{title} Strategy Monthly and Yearly Returns')
|
|
278
|
+
# Find the min and max values in the data to set the color scale range.
|
|
279
|
+
vmin = monthly_returns_df.min().min()
|
|
280
|
+
vmax = monthly_returns_df.max().max()
|
|
281
|
+
# Define the color palette for the heatmap
|
|
282
|
+
cmap = sns.diverging_palette(10, 133, sep=3, n=256, center="light")
|
|
283
|
+
|
|
284
|
+
# Create the heatmap with the larger legend
|
|
285
|
+
sns.heatmap(monthly_returns_df, annot=True, fmt=".1f",
|
|
286
|
+
linewidths=.5, ax=ax1, cbar_kws={"shrink": .8},
|
|
287
|
+
cmap=cmap, center=0, vmin=vmin, vmax=vmax)
|
|
288
|
+
|
|
289
|
+
# Rotate the year labels on the y-axis to vertical
|
|
290
|
+
ax1.set_yticklabels(ax1.get_yticklabels(), rotation=0)
|
|
291
|
+
ax1.set_ylabel('')
|
|
292
|
+
ax1.set_xlabel('')
|
|
293
|
+
|
|
294
|
+
# Create the bar plot
|
|
295
|
+
yearly_returns_df.plot(kind='bar', ax=ax2, legend=None, color='skyblue')
|
|
296
|
+
|
|
297
|
+
# Set plot titles and labels
|
|
298
|
+
ax1.set_title('Monthly Returns (%)')
|
|
299
|
+
ax2.set_title('Yearly Returns (%)')
|
|
300
|
+
|
|
301
|
+
# Rotate the x labels for the yearly returns bar plot
|
|
302
|
+
ax2.set_xticklabels(yearly_returns_df.index.strftime('%Y'), rotation=45)
|
|
303
|
+
ax2.set_xlabel('')
|
|
304
|
+
|
|
305
|
+
# Adjust layout spacing
|
|
306
|
+
plt.tight_layout()
|
|
307
|
+
|
|
308
|
+
# Show the plot
|
|
309
|
+
plt.show()
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from queue import Queue
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from bbstrader.btengine.event import (
|
|
5
|
+
OrderEvent, FillEvent, MarketEvent, SignalEvent
|
|
6
|
+
)
|
|
7
|
+
from bbstrader.btengine.data import DataHandler
|
|
8
|
+
from bbstrader.btengine.performance import (
|
|
9
|
+
create_drawdowns, plot_performance,
|
|
10
|
+
create_sharpe_ratio, create_sortino_ratio,
|
|
11
|
+
plot_returns_and_dd, plot_monthly_yearly_returns
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Portfolio(object):
|
|
16
|
+
"""
|
|
17
|
+
This describes a `Portfolio()` object that keeps track of the positions
|
|
18
|
+
within a portfolio and generates orders of a fixed quantity of stock based on signals.
|
|
19
|
+
More sophisticated portfolio objects could include risk management and position
|
|
20
|
+
sizing tools (such as the `Kelly Criterion`).
|
|
21
|
+
|
|
22
|
+
The portfolio order management system is possibly the most complex component of
|
|
23
|
+
an eventdriven backtester. Its role is to keep track of all current market positions
|
|
24
|
+
as well as the market value of the positions (known as the "holdings").
|
|
25
|
+
This is simply an estimate of the liquidation value of the position and is derived in part
|
|
26
|
+
from the data handling facility of the backtester.
|
|
27
|
+
|
|
28
|
+
In addition to the positions and holdings management the portfolio must also be aware of
|
|
29
|
+
risk factors and position sizing techniques in order to optimise orders that are sent
|
|
30
|
+
to a brokerage or other form of market access.
|
|
31
|
+
|
|
32
|
+
Unfortunately, Portfolio and `Order Management Systems (OMS)` can become rather complex!
|
|
33
|
+
So let's keep the `Portfolio` object relatively straightforward anf improve it foward.
|
|
34
|
+
|
|
35
|
+
Continuing in the vein of the Event class hierarchy a Portfolio object must be able
|
|
36
|
+
to handle `SignalEvent` objects, generate `OrderEvent` objects and interpret `FillEvent`
|
|
37
|
+
objects to update positions. Thus it is no surprise that the Portfolio objects are often
|
|
38
|
+
the largest component of event-driven systems, in terms of lines of code (LOC).
|
|
39
|
+
|
|
40
|
+
The initialisation of the Portfolio object requires access to the bars `DataHandler`,
|
|
41
|
+
the `Event Queue`, a `start datetime stamp` and an `initial capital`
|
|
42
|
+
value (defaulting to `100,000 USD`) and others parameter based on the `Strategy` requirement.
|
|
43
|
+
|
|
44
|
+
The `Portfolio` is designed to handle position sizing and current holdings,
|
|
45
|
+
but will carry out trading orders in a "dumb" manner by simply sending them directly
|
|
46
|
+
to the brokerage with a predetermined fixed quantity size, irrespective of cash held.
|
|
47
|
+
These are all unrealistic assumptions, but they help to outline how a portfolio order
|
|
48
|
+
management system (OMS) functions in an eventdriven fashion.
|
|
49
|
+
|
|
50
|
+
The portfolio contains the `all_positions` and `current_positions` members.
|
|
51
|
+
The former stores a list of all previous positions recorded at the timestamp of a market data event.
|
|
52
|
+
A position is simply the quantity of the asset held. Negative positions mean the asset has been shorted.
|
|
53
|
+
|
|
54
|
+
The latter current_positions dictionary stores contains the current positions
|
|
55
|
+
for the last market bar update, for each symbol.
|
|
56
|
+
|
|
57
|
+
In addition to the positions data the portfolio stores `holdings`,
|
|
58
|
+
which describe the current market value of the positions held. "Current market value"
|
|
59
|
+
in this instance means the closing price obtained from the current market bar,
|
|
60
|
+
which is clearly an approximation, but is reasonable enough for the time being.
|
|
61
|
+
`all_holdings` stores the historical list of all symbol holdings, while current_holdings
|
|
62
|
+
stores the most up to date dictionary of all symbol holdings values.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self,
|
|
66
|
+
bars: DataHandler,
|
|
67
|
+
events: Queue,
|
|
68
|
+
start_date: datetime,
|
|
69
|
+
initial_capital=100000.0,
|
|
70
|
+
**kwargs
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialises the portfolio with bars and an event queue.
|
|
74
|
+
Also includes a starting datetime index and initial capital
|
|
75
|
+
(USD unless otherwise stated).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
bars (DataHandler): The DataHandler object with current market data.
|
|
79
|
+
events (Queue): The Event Queue object.
|
|
80
|
+
start_date (datetime): The start date (bar) of the portfolio.
|
|
81
|
+
initial_capital (float): The starting capital in USD.
|
|
82
|
+
|
|
83
|
+
kwargs (dict): Additional arguments
|
|
84
|
+
- `time_frame`: The time frame of the bars.
|
|
85
|
+
- `trading_hours`: The number of trading hours in a day.
|
|
86
|
+
- `benchmark`: The benchmark symbol to compare the portfolio.
|
|
87
|
+
- `strategy_name`: The name of the strategy (the name must not include 'Strategy' in it).
|
|
88
|
+
"""
|
|
89
|
+
self.bars = bars
|
|
90
|
+
self.events = events
|
|
91
|
+
self.symbol_list = self.bars.symbol_list
|
|
92
|
+
self.start_date = start_date
|
|
93
|
+
self.initial_capital = initial_capital
|
|
94
|
+
|
|
95
|
+
self.timeframe = kwargs.get("time_frame", "D1")
|
|
96
|
+
self.trading_hours = kwargs.get("session_duration", 6.5)
|
|
97
|
+
self.benchmark = kwargs.get('benchmark', 'SPY')
|
|
98
|
+
self.strategy_name = kwargs.get('strategy_name', 'Strategy')
|
|
99
|
+
if self.timeframe not in self._tf_mapping():
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Timeframe not supported,"
|
|
102
|
+
f"please choose one of the following: "
|
|
103
|
+
f"{', '.join(list(self._tf_mapping().keys()))}"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
self.tf = self._tf_mapping()[self.timeframe]
|
|
107
|
+
|
|
108
|
+
self.all_positions = self.construct_all_positions()
|
|
109
|
+
self.current_positions = dict((k, v) for k, v in
|
|
110
|
+
[(s, 0) for s in self.symbol_list])
|
|
111
|
+
self.all_holdings = self.construct_all_holdings()
|
|
112
|
+
self.current_holdings = self.construct_current_holdings()
|
|
113
|
+
|
|
114
|
+
def _tf_mapping(self):
|
|
115
|
+
"""
|
|
116
|
+
Returns a dictionary mapping the time frames
|
|
117
|
+
to the number of bars in a year.
|
|
118
|
+
"""
|
|
119
|
+
th = self.trading_hours
|
|
120
|
+
time_frame_mapping = {}
|
|
121
|
+
for minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20,
|
|
122
|
+
30, 60, 120, 180, 240, 360, 480, 720]:
|
|
123
|
+
key = f"{minutes//60}h" if minutes >= 60 else f"{minutes}m"
|
|
124
|
+
time_frame_mapping[key] = int(252 * (60 / minutes) * th)
|
|
125
|
+
time_frame_mapping['D1'] = 252
|
|
126
|
+
return time_frame_mapping
|
|
127
|
+
|
|
128
|
+
def construct_all_positions(self):
|
|
129
|
+
"""
|
|
130
|
+
Constructs the positions list using the start_date
|
|
131
|
+
to determine when the time index will begin.
|
|
132
|
+
"""
|
|
133
|
+
d = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
|
|
134
|
+
d['Datetime'] = self.start_date
|
|
135
|
+
return [d]
|
|
136
|
+
|
|
137
|
+
def construct_all_holdings(self):
|
|
138
|
+
"""
|
|
139
|
+
Constructs the holdings list using the start_date
|
|
140
|
+
to determine when the time index will begin.
|
|
141
|
+
"""
|
|
142
|
+
d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
|
|
143
|
+
d['Datetime'] = self.start_date
|
|
144
|
+
d['Cash'] = self.initial_capital
|
|
145
|
+
d['Commission'] = 0.0
|
|
146
|
+
d['Total'] = self.initial_capital
|
|
147
|
+
return [d]
|
|
148
|
+
|
|
149
|
+
def construct_current_holdings(self):
|
|
150
|
+
"""
|
|
151
|
+
This constructs the dictionary which will hold the instantaneous
|
|
152
|
+
value of the portfolio across all symbols.
|
|
153
|
+
"""
|
|
154
|
+
d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
|
|
155
|
+
d['Cash'] = self.initial_capital
|
|
156
|
+
d['Commission'] = 0.0
|
|
157
|
+
d['Total'] = self.initial_capital
|
|
158
|
+
return d
|
|
159
|
+
|
|
160
|
+
def update_timeindex(self, event: MarketEvent):
|
|
161
|
+
"""
|
|
162
|
+
Adds a new record to the positions matrix for the current
|
|
163
|
+
market data bar. This reflects the PREVIOUS bar, i.e. all
|
|
164
|
+
current market data at this stage is known (OHLCV).
|
|
165
|
+
Makes use of a MarketEvent from the events queue.
|
|
166
|
+
"""
|
|
167
|
+
latest_datetime = self.bars.get_latest_bar_datetime(
|
|
168
|
+
self.symbol_list[0]
|
|
169
|
+
)
|
|
170
|
+
# Update positions
|
|
171
|
+
# ================
|
|
172
|
+
dp = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
|
|
173
|
+
dp['Datetime'] = latest_datetime
|
|
174
|
+
for s in self.symbol_list:
|
|
175
|
+
dp[s] = self.current_positions[s]
|
|
176
|
+
# Append the current positions
|
|
177
|
+
self.all_positions.append(dp)
|
|
178
|
+
|
|
179
|
+
# Update holdings
|
|
180
|
+
# ===============
|
|
181
|
+
dh = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
|
|
182
|
+
dh['Datetime'] = latest_datetime
|
|
183
|
+
dh['Cash'] = self.current_holdings['Cash']
|
|
184
|
+
dh['Commission'] = self.current_holdings['Commission']
|
|
185
|
+
dh['Total'] = self.current_holdings['Cash']
|
|
186
|
+
for s in self.symbol_list:
|
|
187
|
+
# Approximation to the real value
|
|
188
|
+
market_value = self.current_positions[s] * \
|
|
189
|
+
self.bars.get_latest_bar_value(s, "Adj Close")
|
|
190
|
+
dh[s] = market_value
|
|
191
|
+
dh['Total'] += market_value
|
|
192
|
+
|
|
193
|
+
# Append the current holdings
|
|
194
|
+
self.all_holdings.append(dh)
|
|
195
|
+
|
|
196
|
+
def update_positions_from_fill(self, fill: FillEvent):
|
|
197
|
+
"""
|
|
198
|
+
Takes a Fill object and updates the position matrix to
|
|
199
|
+
reflect the new position.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
fill (FillEvent): The Fill object to update the positions with.
|
|
203
|
+
"""
|
|
204
|
+
# Check whether the fill is a buy or sell
|
|
205
|
+
fill_dir = 0
|
|
206
|
+
if fill.direction == 'BUY':
|
|
207
|
+
fill_dir = 1
|
|
208
|
+
if fill.direction == 'SELL':
|
|
209
|
+
fill_dir = -1
|
|
210
|
+
|
|
211
|
+
# Update positions list with new quantities
|
|
212
|
+
self.current_positions[fill.symbol] += fill_dir*fill.quantity
|
|
213
|
+
|
|
214
|
+
def update_holdings_from_fill(self, fill: FillEvent):
|
|
215
|
+
"""
|
|
216
|
+
Takes a Fill object and updates the holdings matrix to
|
|
217
|
+
reflect the holdings value.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
fill (FillEvent): The Fill object to update the holdings with.
|
|
221
|
+
"""
|
|
222
|
+
# Check whether the fill is a buy or sell
|
|
223
|
+
fill_dir = 0
|
|
224
|
+
if fill.direction == 'BUY':
|
|
225
|
+
fill_dir = 1
|
|
226
|
+
if fill.direction == 'SELL':
|
|
227
|
+
fill_dir = -1
|
|
228
|
+
|
|
229
|
+
# Update holdings list with new quantities
|
|
230
|
+
fill_cost = self.bars.get_latest_bar_value(
|
|
231
|
+
fill.symbol, "Adj Close"
|
|
232
|
+
)
|
|
233
|
+
cost = fill_dir * fill_cost * fill.quantity
|
|
234
|
+
self.current_holdings[fill.symbol] += cost
|
|
235
|
+
self.current_holdings['Commission'] += fill.commission
|
|
236
|
+
self.current_holdings['Cash'] -= (cost + fill.commission)
|
|
237
|
+
self.current_holdings['Total'] -= (cost + fill.commission)
|
|
238
|
+
|
|
239
|
+
def update_fill(self, event: FillEvent):
|
|
240
|
+
"""
|
|
241
|
+
Updates the portfolio current positions and holdings
|
|
242
|
+
from a FillEvent.
|
|
243
|
+
"""
|
|
244
|
+
if event.type == 'FILL':
|
|
245
|
+
self.update_positions_from_fill(event)
|
|
246
|
+
self.update_holdings_from_fill(event)
|
|
247
|
+
|
|
248
|
+
def generate_naive_order(self, signal: SignalEvent):
|
|
249
|
+
"""
|
|
250
|
+
Simply files an Order object as a constant quantity
|
|
251
|
+
sizing of the signal object, without risk management or
|
|
252
|
+
position sizing considerations.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
signal (SignalEvent): The tuple containing Signal information.
|
|
256
|
+
"""
|
|
257
|
+
order = None
|
|
258
|
+
|
|
259
|
+
symbol = signal.symbol
|
|
260
|
+
direction = signal.signal_type
|
|
261
|
+
quantity = signal.quantity
|
|
262
|
+
strength = signal.strength
|
|
263
|
+
cur_quantity = self.current_positions[symbol]
|
|
264
|
+
|
|
265
|
+
order_type = 'MKT'
|
|
266
|
+
mkt_quantity = round(quantity * strength)
|
|
267
|
+
|
|
268
|
+
if direction == 'LONG' and cur_quantity == 0:
|
|
269
|
+
order = OrderEvent(symbol, order_type, mkt_quantity, 'BUY')
|
|
270
|
+
if direction == 'SHORT' and cur_quantity == 0:
|
|
271
|
+
order = OrderEvent(symbol, order_type, mkt_quantity, 'SELL')
|
|
272
|
+
|
|
273
|
+
if direction == 'EXIT' and cur_quantity > 0:
|
|
274
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL')
|
|
275
|
+
if direction == 'EXIT' and cur_quantity < 0:
|
|
276
|
+
order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY')
|
|
277
|
+
|
|
278
|
+
return order
|
|
279
|
+
|
|
280
|
+
def update_signal(self, event: SignalEvent):
|
|
281
|
+
"""
|
|
282
|
+
Acts on a SignalEvent to generate new orders
|
|
283
|
+
based on the portfolio logic.
|
|
284
|
+
"""
|
|
285
|
+
if event.type == 'SIGNAL':
|
|
286
|
+
order_event = self.generate_naive_order(event)
|
|
287
|
+
self.events.put(order_event)
|
|
288
|
+
|
|
289
|
+
def create_equity_curve_dataframe(self):
|
|
290
|
+
"""
|
|
291
|
+
Creates a pandas DataFrame from the all_holdings
|
|
292
|
+
list of dictionaries.
|
|
293
|
+
"""
|
|
294
|
+
curve = pd.DataFrame(self.all_holdings)
|
|
295
|
+
curve.set_index('Datetime', inplace=True)
|
|
296
|
+
curve['Returns'] = curve['Total'].pct_change(fill_method=None)
|
|
297
|
+
curve['Equity Curve'] = (1.0+curve['Returns']).cumprod()
|
|
298
|
+
self.equity_curve = curve
|
|
299
|
+
|
|
300
|
+
def output_summary_stats(self):
|
|
301
|
+
"""
|
|
302
|
+
Creates a list of summary statistics for the portfolio.
|
|
303
|
+
"""
|
|
304
|
+
total_return = self.equity_curve['Equity Curve'].iloc[-1]
|
|
305
|
+
returns = self.equity_curve['Returns']
|
|
306
|
+
pnl = self.equity_curve['Equity Curve']
|
|
307
|
+
|
|
308
|
+
sharpe_ratio = create_sharpe_ratio(returns, periods=self.tf)
|
|
309
|
+
sortino_ratio = create_sortino_ratio(returns, periods=self.tf)
|
|
310
|
+
drawdown, max_dd, dd_duration = create_drawdowns(pnl)
|
|
311
|
+
self.equity_curve['Drawdown'] = drawdown
|
|
312
|
+
|
|
313
|
+
stats = [
|
|
314
|
+
("Total Return", f"{(total_return-1.0) * 100.0:.2f}%"),
|
|
315
|
+
("Sharpe Ratio", f"{sharpe_ratio:.2f}"),
|
|
316
|
+
("Sortino Ratio", f"{sortino_ratio:.2f}"),
|
|
317
|
+
("Max Drawdown", f"{max_dd * 100.0:.2f}%"),
|
|
318
|
+
("Drawdown Duration", f"{dd_duration}")
|
|
319
|
+
]
|
|
320
|
+
self.equity_curve.to_csv('equity.csv')
|
|
321
|
+
plot_performance(self.equity_curve, self.strategy_name)
|
|
322
|
+
plot_returns_and_dd(self.equity_curve,
|
|
323
|
+
self.benchmark, self.strategy_name)
|
|
324
|
+
plot_monthly_yearly_returns(self.equity_curve, self.strategy_name)
|
|
325
|
+
|
|
326
|
+
return stats
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Strategy(metaclass=ABCMeta):
|
|
5
|
+
"""
|
|
6
|
+
A `Strategy()` object encapsulates all calculation on market data
|
|
7
|
+
that generate advisory signals to a `Portfolio` object. Thus all of
|
|
8
|
+
the "strategy logic" resides within this class. We opted to separate
|
|
9
|
+
out the `Strategy` and `Portfolio` objects for this backtester,
|
|
10
|
+
since we believe this is more amenable to the situation of multiple
|
|
11
|
+
strategies feeding "ideas" to a larger `Portfolio`, which then can handle
|
|
12
|
+
its own risk (such as sector allocation, leverage). In higher frequency trading,
|
|
13
|
+
the strategy and portfolio concepts will be tightly coupled and extremely
|
|
14
|
+
hardware dependent.
|
|
15
|
+
|
|
16
|
+
At this stage in the event-driven backtester development there is no concept of
|
|
17
|
+
an indicator or filter, such as those found in technical trading. These are also
|
|
18
|
+
good candidates for creating a class hierarchy.
|
|
19
|
+
|
|
20
|
+
The strategy hierarchy is relatively simple as it consists of an abstract
|
|
21
|
+
base class with a single pure virtual method for generating `SignalEvent` objects.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def calculate_signals(self):
|
|
26
|
+
"""
|
|
27
|
+
Provides the mechanisms to calculate the list of signals.
|
|
28
|
+
"""
|
|
29
|
+
raise NotImplementedError(
|
|
30
|
+
"Should implement calculate_signals()"
|
|
31
|
+
)
|