bbstrader 2.0.3__cp312-cp312-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bbstrader/__init__.py +27 -0
- bbstrader/__main__.py +92 -0
- bbstrader/api/__init__.py +96 -0
- bbstrader/api/handlers.py +245 -0
- bbstrader/api/metatrader_client.cpython-312-darwin.so +0 -0
- bbstrader/api/metatrader_client.pyi +624 -0
- bbstrader/assets/bbs_.png +0 -0
- bbstrader/assets/bbstrader.ico +0 -0
- bbstrader/assets/bbstrader.png +0 -0
- bbstrader/assets/qs_metrics_1.png +0 -0
- bbstrader/btengine/__init__.py +54 -0
- bbstrader/btengine/backtest.py +358 -0
- bbstrader/btengine/data.py +737 -0
- bbstrader/btengine/event.py +229 -0
- bbstrader/btengine/execution.py +287 -0
- bbstrader/btengine/performance.py +408 -0
- bbstrader/btengine/portfolio.py +393 -0
- bbstrader/btengine/strategy.py +588 -0
- bbstrader/compat.py +28 -0
- bbstrader/config.py +100 -0
- bbstrader/core/__init__.py +27 -0
- bbstrader/core/data.py +628 -0
- bbstrader/core/strategy.py +466 -0
- bbstrader/metatrader/__init__.py +48 -0
- bbstrader/metatrader/_copier.py +720 -0
- bbstrader/metatrader/account.py +865 -0
- bbstrader/metatrader/broker.py +418 -0
- bbstrader/metatrader/copier.py +1487 -0
- bbstrader/metatrader/rates.py +495 -0
- bbstrader/metatrader/risk.py +667 -0
- bbstrader/metatrader/trade.py +1692 -0
- bbstrader/metatrader/utils.py +402 -0
- bbstrader/models/__init__.py +39 -0
- bbstrader/models/nlp.py +932 -0
- bbstrader/models/optimization.py +182 -0
- bbstrader/scripts.py +665 -0
- bbstrader/trading/__init__.py +33 -0
- bbstrader/trading/execution.py +1159 -0
- bbstrader/trading/strategy.py +362 -0
- bbstrader/trading/utils.py +69 -0
- bbstrader-2.0.3.dist-info/METADATA +396 -0
- bbstrader-2.0.3.dist-info/RECORD +45 -0
- bbstrader-2.0.3.dist-info/WHEEL +5 -0
- bbstrader-2.0.3.dist-info/entry_points.txt +3 -0
- bbstrader-2.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import os.path
|
|
2
|
+
from abc import ABCMeta, abstractmethod
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from queue import Queue
|
|
6
|
+
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import yfinance as yf
|
|
11
|
+
from eodhd import APIClient
|
|
12
|
+
from financetoolkit import Toolkit
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
from pytz import timezone
|
|
15
|
+
|
|
16
|
+
from bbstrader.btengine.event import MarketEvent
|
|
17
|
+
from bbstrader.config import BBSTRADER_DIR
|
|
18
|
+
from bbstrader.metatrader.rates import download_historical_data
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DataHandler",
|
|
22
|
+
"CSVDataHandler",
|
|
23
|
+
"MT5DataHandler",
|
|
24
|
+
"YFDataHandler",
|
|
25
|
+
"EODHDataHandler",
|
|
26
|
+
"FMPDataHandler",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DataHandler(metaclass=ABCMeta):
|
|
31
|
+
"""
|
|
32
|
+
One of the goals of an event-driven trading system is to minimise
|
|
33
|
+
duplication of code between the backtesting element and the live execution
|
|
34
|
+
element. Ideally it would be optimal to utilise the same signal generation
|
|
35
|
+
methodology and portfolio management components for both historical testing
|
|
36
|
+
and live trading. In order for this to work the Strategy object which generates
|
|
37
|
+
the Signals, and the `Portfolio` object which provides Orders based on them,
|
|
38
|
+
must utilise an identical interface to a market feed for both historic and live
|
|
39
|
+
running.
|
|
40
|
+
|
|
41
|
+
This motivates the concept of a class hierarchy based on a `DataHandler` object,
|
|
42
|
+
which givesall subclasses an interface for providing market data to the remaining
|
|
43
|
+
components within thesystem. In this way any subclass data handler can be "swapped out",
|
|
44
|
+
without affecting strategy or portfolio calculation.
|
|
45
|
+
|
|
46
|
+
Specific example subclasses could include `HistoricCSVDataHandler`,
|
|
47
|
+
`YFinanceDataHandler`, `FMPDataHandler`, `IBMarketFeedDataHandler` etc.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def symbols(self) -> List[str]:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def data(self) -> Dict[str, pd.DataFrame]:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def labels(self) -> List[str]:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def index(self) -> Union[str, List[str]]:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def get_latest_bar(self, symbol: str) -> pd.Series:
|
|
68
|
+
"""
|
|
69
|
+
Returns the last bar updated.
|
|
70
|
+
"""
|
|
71
|
+
raise NotImplementedError("Should implement get_latest_bar()")
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_latest_bars(
|
|
75
|
+
self, symbol: str, N: int = 1, df: bool = True
|
|
76
|
+
) -> Union[pd.DataFrame, List[pd.Series]]:
|
|
77
|
+
"""
|
|
78
|
+
Returns the last N bars updated.
|
|
79
|
+
"""
|
|
80
|
+
raise NotImplementedError("Should implement get_latest_bars()")
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def get_latest_bar_datetime(self, symbol: str) -> Union[datetime, pd.Timestamp]:
|
|
84
|
+
"""
|
|
85
|
+
Returns a Python datetime object for the last bar.
|
|
86
|
+
"""
|
|
87
|
+
raise NotImplementedError("Should implement get_latest_bar_datetime()")
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def get_latest_bar_value(self, symbol: str, val_type: str) -> float:
|
|
91
|
+
"""
|
|
92
|
+
Returns one of the Open, High, Low, Close, Adj Close, Volume or Returns
|
|
93
|
+
from the last bar.
|
|
94
|
+
"""
|
|
95
|
+
raise NotImplementedError("Should implement get_latest_bar_value()")
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def get_latest_bars_values(self, symbol: str, val_type: str, N: int = 1) -> NDArray:
|
|
99
|
+
"""
|
|
100
|
+
Returns the last N bar values from the
|
|
101
|
+
latest_symbol list, or N-k if less available.
|
|
102
|
+
"""
|
|
103
|
+
raise NotImplementedError("Should implement get_latest_bars_values()")
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def update_bars(self) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Pushes the latest bars to the bars_queue for each symbol
|
|
109
|
+
in a tuple OHLCVI format: (datetime, Open, High, Low,
|
|
110
|
+
Close, Adj Close, Volume, Retruns).
|
|
111
|
+
"""
|
|
112
|
+
raise NotImplementedError("Should implement update_bars()")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BaseCSVDataHandler(DataHandler):
|
|
116
|
+
"""
|
|
117
|
+
Base class for handling data loaded from CSV files.
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
events: "Queue[MarketEvent]",
|
|
124
|
+
symbol_list: List[str],
|
|
125
|
+
csv_dir: str,
|
|
126
|
+
columns: Optional[List[str]] = None,
|
|
127
|
+
index_col: Union[str, int, List[str], List[int]] = 0,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Initialises the data handler by requesting the location of the CSV files
|
|
131
|
+
and a list of symbols.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
events : The Event Queue.
|
|
135
|
+
symbol_list : A list of symbol strings.
|
|
136
|
+
csv_dir : Absolute directory path to the CSV files.
|
|
137
|
+
columns : List of column names to use for the data.
|
|
138
|
+
index_col : Column to use as the index.
|
|
139
|
+
"""
|
|
140
|
+
self.events = events
|
|
141
|
+
self.symbol_list = symbol_list
|
|
142
|
+
self.csv_dir = csv_dir
|
|
143
|
+
self.columns = columns
|
|
144
|
+
self.index_col = index_col
|
|
145
|
+
self.symbol_data: Dict[str, Union[pd.DataFrame, Generator]] = {}
|
|
146
|
+
self.latest_symbol_data: Dict[str, List[Any]] = {}
|
|
147
|
+
self.continue_backtest = True
|
|
148
|
+
self._index: Optional[Union[str, List[str]]] = None
|
|
149
|
+
self._load_and_process_data()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def symbols(self) -> List[str]:
|
|
153
|
+
return self.symbol_list
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def data(self) -> Dict[str, pd.DataFrame]:
|
|
157
|
+
return self.symbol_data # type: ignore
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def datadir(self) -> str:
|
|
161
|
+
return self.csv_dir
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def labels(self) -> List[str]:
|
|
165
|
+
return self.columns # type: ignore
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def index(self) -> Union[str, List[str]]:
|
|
169
|
+
return self._index # type: ignore
|
|
170
|
+
|
|
171
|
+
def _load_and_process_data(self) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Opens the CSV files from the data directory, converting
|
|
174
|
+
them into pandas DataFrames within a symbol dictionary.
|
|
175
|
+
"""
|
|
176
|
+
default_names = pd.read_csv(
|
|
177
|
+
os.path.join(self.csv_dir, f"{self.symbol_list[0]}.csv")
|
|
178
|
+
).columns.to_list()
|
|
179
|
+
new_names = self.columns or default_names
|
|
180
|
+
new_names = [name.strip().lower().replace(" ", "_") for name in new_names]
|
|
181
|
+
self.columns = new_names
|
|
182
|
+
assert "adj_close" in new_names or "close" in new_names, (
|
|
183
|
+
"Column names must contain 'Adj Close' and 'Close' or adj_close and close"
|
|
184
|
+
)
|
|
185
|
+
comb_index = None
|
|
186
|
+
for s in self.symbol_list:
|
|
187
|
+
# Load the CSV file with no header information,
|
|
188
|
+
# indexed on date
|
|
189
|
+
self.symbol_data[s] = pd.read_csv(
|
|
190
|
+
os.path.join(self.csv_dir, f"{s}.csv"),
|
|
191
|
+
header=0,
|
|
192
|
+
index_col=self.index_col,
|
|
193
|
+
parse_dates=True,
|
|
194
|
+
names=new_names,
|
|
195
|
+
)
|
|
196
|
+
self.symbol_data[s].sort_index(inplace=True)
|
|
197
|
+
# Combine the index to pad forward values
|
|
198
|
+
if comb_index is None:
|
|
199
|
+
comb_index = self.symbol_data[s].index
|
|
200
|
+
elif len(self.symbol_data[s].index) > len(comb_index):
|
|
201
|
+
comb_index = self.symbol_data[s].index
|
|
202
|
+
# Set the latest symbol_data to None
|
|
203
|
+
self.latest_symbol_data[s] = []
|
|
204
|
+
|
|
205
|
+
# Reindex the dataframes
|
|
206
|
+
for s in self.symbol_list:
|
|
207
|
+
self.symbol_data[s] = self.symbol_data[s].reindex( # type: ignore
|
|
208
|
+
index=comb_index, method="pad"
|
|
209
|
+
)
|
|
210
|
+
if "adj_close" not in new_names:
|
|
211
|
+
self.columns.append("adj_close")
|
|
212
|
+
self.symbol_data[s]["adj_close"] = self.symbol_data[s]["close"] # type: ignore
|
|
213
|
+
self.symbol_data[s]["returns"] = ( # type: ignore
|
|
214
|
+
self.symbol_data[s][ # type: ignore
|
|
215
|
+
"adj_close" if "adj_close" in new_names else "close"
|
|
216
|
+
]
|
|
217
|
+
.pct_change()
|
|
218
|
+
.dropna()
|
|
219
|
+
)
|
|
220
|
+
self._index = self.symbol_data[s].index.name # type: ignore
|
|
221
|
+
self.symbol_data[s].to_csv(os.path.join(self.csv_dir, f"{s}.csv")) # type: ignore
|
|
222
|
+
if self.events is not None:
|
|
223
|
+
self.symbol_data[s] = self.symbol_data[s].iterrows() # type: ignore
|
|
224
|
+
|
|
225
|
+
def _get_new_bar(self, symbol: str) -> Generator[Tuple[Any, Any], Any, None]:
|
|
226
|
+
"""
|
|
227
|
+
Returns the latest bar from the data feed.
|
|
228
|
+
"""
|
|
229
|
+
for b in self.symbol_data[symbol]: # type: ignore
|
|
230
|
+
yield b
|
|
231
|
+
|
|
232
|
+
def get_latest_bar(self, symbol: str) -> pd.Series:
|
|
233
|
+
"""
|
|
234
|
+
Returns the last bar from the latest_symbol list.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
bars_list = self.latest_symbol_data[symbol]
|
|
238
|
+
except KeyError:
|
|
239
|
+
print(f"{symbol} not available in the historical data set.")
|
|
240
|
+
raise
|
|
241
|
+
else:
|
|
242
|
+
return bars_list[-1]
|
|
243
|
+
|
|
244
|
+
def get_latest_bars(
|
|
245
|
+
self, symbol: str, N: int = 1, df: bool = True
|
|
246
|
+
) -> Union[pd.DataFrame, List[pd.Series]]:
|
|
247
|
+
"""
|
|
248
|
+
Returns the last N bars from the latest_symbol list,
|
|
249
|
+
or N-k if less available.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
bars_list = self.latest_symbol_data[symbol]
|
|
253
|
+
except KeyError:
|
|
254
|
+
print(f"{symbol} not available in the historical data set.")
|
|
255
|
+
raise
|
|
256
|
+
else:
|
|
257
|
+
if df:
|
|
258
|
+
df_ = pd.DataFrame([bar[1] for bar in bars_list[-N:]])
|
|
259
|
+
df_.index.name = self._index # type: ignore
|
|
260
|
+
return df_
|
|
261
|
+
return bars_list[-N:]
|
|
262
|
+
|
|
263
|
+
def get_latest_bar_datetime(self, symbol: str) -> Union[datetime, pd.Timestamp]:
|
|
264
|
+
"""
|
|
265
|
+
Returns a Python datetime object for the last bar.
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
bars_list = self.latest_symbol_data[symbol]
|
|
269
|
+
except KeyError:
|
|
270
|
+
print(f"{symbol} not available in the historical data set.")
|
|
271
|
+
raise
|
|
272
|
+
else:
|
|
273
|
+
return bars_list[-1][0]
|
|
274
|
+
|
|
275
|
+
def get_latest_bars_datetime(
|
|
276
|
+
self, symbol: str, N: int = 1
|
|
277
|
+
) -> List[Union[datetime, pd.Timestamp]]:
|
|
278
|
+
"""
|
|
279
|
+
Returns a list of Python datetime objects for the last N bars.
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
bars_list = self.get_latest_bars(symbol, N) # type: ignore
|
|
283
|
+
except KeyError:
|
|
284
|
+
print(f"{symbol} not available in the historical data set for .")
|
|
285
|
+
raise
|
|
286
|
+
else:
|
|
287
|
+
return [b[0] for b in bars_list] # type: ignore
|
|
288
|
+
|
|
289
|
+
def get_latest_bar_value(self, symbol: str, val_type: str) -> float:
|
|
290
|
+
"""
|
|
291
|
+
Returns one of the Open, High, Low, Close, Volume or OI
|
|
292
|
+
values from the pandas Bar series object.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
bars_list = self.latest_symbol_data[symbol]
|
|
296
|
+
except KeyError:
|
|
297
|
+
print(f"{symbol} not available in the historical data set.")
|
|
298
|
+
raise
|
|
299
|
+
else:
|
|
300
|
+
try:
|
|
301
|
+
return getattr(bars_list[-1][1], val_type)
|
|
302
|
+
except AttributeError:
|
|
303
|
+
print(
|
|
304
|
+
f"Value type {val_type} not available in the historical data set for {symbol}."
|
|
305
|
+
)
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
def get_latest_bars_values(self, symbol: str, val_type: str, N: int = 1) -> NDArray:
|
|
309
|
+
"""
|
|
310
|
+
Returns the last N bar values from the
|
|
311
|
+
latest_symbol list, or N-k if less available.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
bars_list = self.get_latest_bars(symbol, N, df=False)
|
|
315
|
+
except KeyError:
|
|
316
|
+
print(f"{symbol} not available in the historical data set.")
|
|
317
|
+
raise
|
|
318
|
+
else:
|
|
319
|
+
try:
|
|
320
|
+
return np.array([getattr(b[1], val_type) for b in bars_list])
|
|
321
|
+
except AttributeError:
|
|
322
|
+
print(
|
|
323
|
+
f"Value type {val_type} not available in the historical data set."
|
|
324
|
+
)
|
|
325
|
+
raise
|
|
326
|
+
|
|
327
|
+
def update_bars(self) -> None:
|
|
328
|
+
"""
|
|
329
|
+
Pushes the latest bar to the latest_symbol_data structure
|
|
330
|
+
for all symbols in the symbol list.
|
|
331
|
+
"""
|
|
332
|
+
for s in self.symbol_list:
|
|
333
|
+
try:
|
|
334
|
+
bar = next(self._get_new_bar(s))
|
|
335
|
+
except StopIteration:
|
|
336
|
+
self.continue_backtest = False
|
|
337
|
+
else:
|
|
338
|
+
if bar is not None:
|
|
339
|
+
self.latest_symbol_data[s].append(bar)
|
|
340
|
+
self.events.put(MarketEvent())
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class CSVDataHandler(BaseCSVDataHandler):
|
|
344
|
+
"""
|
|
345
|
+
`CSVDataHandler` is designed to read CSV files for
|
|
346
|
+
each requested symbol from disk and provide an interface
|
|
347
|
+
to obtain the "latest" bar in a manner identical to a live
|
|
348
|
+
trading interface.
|
|
349
|
+
|
|
350
|
+
This class is useful when you have your own data or you want
|
|
351
|
+
to cutomize specific data in some form based on your `Strategy()` .
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(
|
|
355
|
+
self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
|
|
356
|
+
) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Initialises the historic data handler by requesting
|
|
359
|
+
the location of the CSV files and a list of symbols.
|
|
360
|
+
It will be assumed that all files are of the form
|
|
361
|
+
`symbol.csv`, where `symbol` is a string in the list.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
events (Queue): The Event Queue.
|
|
365
|
+
symbol_list (List[str]): A list of symbol strings.
|
|
366
|
+
csv_dir (str): Absolute directory path to the CSV files.
|
|
367
|
+
|
|
368
|
+
NOTE:
|
|
369
|
+
All csv fille can be stored in 'Home/.bbstrader/data/csv_data'
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
csv_dir = kwargs.get("csv_dir")
|
|
373
|
+
csv_dir = csv_dir or BBSTRADER_DIR / "data" / "csv_data"
|
|
374
|
+
super().__init__(
|
|
375
|
+
events,
|
|
376
|
+
symbol_list,
|
|
377
|
+
str(csv_dir),
|
|
378
|
+
columns=kwargs.get("columns"),
|
|
379
|
+
index_col=kwargs.get("index_col", 0),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class MT5DataHandler(BaseCSVDataHandler):
|
|
384
|
+
"""
|
|
385
|
+
Downloads historical data from MetaTrader 5 (MT5) and provides
|
|
386
|
+
an interface for accessing this data bar-by-bar, simulating
|
|
387
|
+
a live market feed for backtesting.
|
|
388
|
+
|
|
389
|
+
Data is downloaded from MT5, saved as CSV files, and then loaded
|
|
390
|
+
using the functionality inherited from `BaseCSVDataHandler`.
|
|
391
|
+
|
|
392
|
+
This class is useful when you need to get data from specific broker
|
|
393
|
+
for different time frames.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def __init__(
|
|
397
|
+
self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
|
|
398
|
+
) -> None:
|
|
399
|
+
"""
|
|
400
|
+
Args:
|
|
401
|
+
events (Queue): The Event Queue for passing market events.
|
|
402
|
+
symbol_list (List[str]): A list of symbol strings to download data for.
|
|
403
|
+
**kwargs: Keyword arguments for data retrieval:
|
|
404
|
+
time_frame (str): MT5 time frame (e.g., 'D1' for daily).
|
|
405
|
+
mt5_start (datetime): Start date for historical data.
|
|
406
|
+
mt5_end (datetime): End date for historical data.
|
|
407
|
+
data_dir (str): Directory for storing data .
|
|
408
|
+
|
|
409
|
+
Note:
|
|
410
|
+
Requires a working connection to an MT5 terminal.
|
|
411
|
+
See `bbstrader.metatrader.rates.Rates` for other arguments.
|
|
412
|
+
See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
|
|
413
|
+
"""
|
|
414
|
+
self.tf = kwargs.get("time_frame", "D1")
|
|
415
|
+
self.start = kwargs.get("mt5_start", datetime(2000, 1, 1))
|
|
416
|
+
self.end = kwargs.get("mt5_end", datetime.now())
|
|
417
|
+
self.use_utc = kwargs.get("use_utc", False)
|
|
418
|
+
self.filer = kwargs.get("filter", False)
|
|
419
|
+
self.fill_na = kwargs.get("fill_na", False)
|
|
420
|
+
self.lower_cols = kwargs.get("lower_cols", True)
|
|
421
|
+
self.data_dir = kwargs.get("data_dir")
|
|
422
|
+
self.symbol_list = symbol_list
|
|
423
|
+
self.kwargs = kwargs
|
|
424
|
+
self.kwargs["backtest"] = (
|
|
425
|
+
True # Ensure backtest mode is set to avoid InvalidBroker errors
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
csv_dir = self._download_and_cache_data(self.data_dir)
|
|
429
|
+
super().__init__(
|
|
430
|
+
events,
|
|
431
|
+
symbol_list,
|
|
432
|
+
str(csv_dir),
|
|
433
|
+
columns=kwargs.get("columns"),
|
|
434
|
+
index_col=kwargs.get("index_col", 0),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def _download_and_cache_data(self, cache_dir: Optional[str]) -> Path:
|
|
438
|
+
data_dir = (
|
|
439
|
+
Path(cache_dir) if cache_dir else BBSTRADER_DIR / "data" / "mt5" / self.tf
|
|
440
|
+
)
|
|
441
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
for symbol in self.symbol_list:
|
|
443
|
+
try:
|
|
444
|
+
data = download_historical_data(
|
|
445
|
+
symbol=symbol,
|
|
446
|
+
timeframe=self.tf,
|
|
447
|
+
date_from=self.start,
|
|
448
|
+
date_to=self.end,
|
|
449
|
+
utc=self.use_utc,
|
|
450
|
+
filter=self.filer,
|
|
451
|
+
fill_na=self.fill_na,
|
|
452
|
+
lower_colnames=self.lower_cols,
|
|
453
|
+
**self.kwargs,
|
|
454
|
+
)
|
|
455
|
+
if data is None:
|
|
456
|
+
raise ValueError(f"No data found for {symbol}")
|
|
457
|
+
data.to_csv(data_dir / f"{symbol}.csv")
|
|
458
|
+
except Exception as e:
|
|
459
|
+
raise ValueError(f"Error downloading {symbol}: {e}")
|
|
460
|
+
return data_dir
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class YFDataHandler(BaseCSVDataHandler):
|
|
464
|
+
"""
|
|
465
|
+
Downloads historical data from Yahoo Finance and provides
|
|
466
|
+
an interface for accessing this data bar-by-bar, simulating
|
|
467
|
+
a live market feed for backtesting.
|
|
468
|
+
|
|
469
|
+
Data is fetched using the `yfinance` library and optionally cached
|
|
470
|
+
to disk to speed up subsequent runs.
|
|
471
|
+
|
|
472
|
+
This class is useful when working with historical daily prices.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
def __init__(
|
|
476
|
+
self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
|
|
477
|
+
) -> None:
|
|
478
|
+
"""
|
|
479
|
+
Args:
|
|
480
|
+
events (Queue): The Event Queue for passing market events.
|
|
481
|
+
symbol_list (list[str]): List of symbols to download data for.
|
|
482
|
+
yf_start (str): Start date for historical data (YYYY-MM-DD).
|
|
483
|
+
yf_end (str): End date for historical data (YYYY-MM-DD).
|
|
484
|
+
data_dir (str, optional): Directory for caching data .
|
|
485
|
+
|
|
486
|
+
Note:
|
|
487
|
+
See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
|
|
488
|
+
"""
|
|
489
|
+
self.symbol_list = symbol_list
|
|
490
|
+
self.start_date = kwargs.get("yf_start")
|
|
491
|
+
self.end_date = kwargs.get("yf_end", datetime.now())
|
|
492
|
+
self.cache_dir = kwargs.get("data_dir")
|
|
493
|
+
|
|
494
|
+
csv_dir = self._download_and_cache_data(self.cache_dir)
|
|
495
|
+
|
|
496
|
+
super().__init__(
|
|
497
|
+
events,
|
|
498
|
+
symbol_list,
|
|
499
|
+
str(csv_dir),
|
|
500
|
+
columns=kwargs.get("columns"),
|
|
501
|
+
index_col=kwargs.get("index_col", 0),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def _download_and_cache_data(self, cache_dir: Optional[str]) -> str:
|
|
505
|
+
"""Downloads and caches historical data as CSV files."""
|
|
506
|
+
_cache_dir = cache_dir or BBSTRADER_DIR / "data" / "yfinance" / "daily"
|
|
507
|
+
os.makedirs(_cache_dir, exist_ok=True)
|
|
508
|
+
for symbol in self.symbol_list:
|
|
509
|
+
filepath = os.path.join(_cache_dir, f"{symbol}.csv")
|
|
510
|
+
try:
|
|
511
|
+
data = yf.download(
|
|
512
|
+
symbol,
|
|
513
|
+
start=self.start_date,
|
|
514
|
+
end=self.end_date,
|
|
515
|
+
multi_level_index=False,
|
|
516
|
+
auto_adjust=True,
|
|
517
|
+
progress=False,
|
|
518
|
+
)
|
|
519
|
+
if "Adj Close" not in data.columns:
|
|
520
|
+
data["Adj Close"] = data["Close"]
|
|
521
|
+
if data.empty:
|
|
522
|
+
raise ValueError(f"No data found for {symbol}")
|
|
523
|
+
data.to_csv(filepath)
|
|
524
|
+
except Exception as e:
|
|
525
|
+
raise ValueError(f"Error downloading {symbol}: {e}")
|
|
526
|
+
return str(_cache_dir)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class EODHDataHandler(BaseCSVDataHandler):
|
|
530
|
+
"""
|
|
531
|
+
Downloads historical data from EOD Historical Data.
|
|
532
|
+
Data is fetched using the `eodhd` library.
|
|
533
|
+
|
|
534
|
+
To use this class, you need to sign up for an API key at
|
|
535
|
+
https://eodhistoricaldata.com/ and provide the key as an argument.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__(
|
|
539
|
+
self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
|
|
540
|
+
) -> None:
|
|
541
|
+
"""
|
|
542
|
+
Args:
|
|
543
|
+
events (Queue): The Event Queue for passing market events.
|
|
544
|
+
symbol_list (list[str]): List of symbols to download data for.
|
|
545
|
+
eodhd_start (str): Start date for historical data (YYYY-MM-DD).
|
|
546
|
+
eodhd_end (str): End date for historical data (YYYY-MM-DD).
|
|
547
|
+
data_dir (str, optional): Directory for caching data .
|
|
548
|
+
eodhd_period (str, optional): Time period for historical data (e.g., 'd', 'w', 'm', '1m', '5m', '1h').
|
|
549
|
+
eodhd_api_key (str, optional): API key for EOD Historical Data.
|
|
550
|
+
|
|
551
|
+
Note:
|
|
552
|
+
See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
|
|
553
|
+
"""
|
|
554
|
+
self.symbol_list = symbol_list
|
|
555
|
+
self.start_date = kwargs.get("eodhd_start")
|
|
556
|
+
self.end_date = kwargs.get("eodhd_end", datetime.now().strftime("%Y-%m-%d"))
|
|
557
|
+
self.period = kwargs.get("eodhd_period", "d")
|
|
558
|
+
self.cache_dir = kwargs.get("data_dir")
|
|
559
|
+
self.__api_key = kwargs.get("eodhd_api_key", "demo")
|
|
560
|
+
|
|
561
|
+
csv_dir = self._download_and_cache_data(self.cache_dir)
|
|
562
|
+
|
|
563
|
+
super().__init__(
|
|
564
|
+
events,
|
|
565
|
+
symbol_list,
|
|
566
|
+
str(csv_dir),
|
|
567
|
+
columns=kwargs.get("columns"),
|
|
568
|
+
index_col=kwargs.get("index_col", 0),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def _get_data(
|
|
572
|
+
self, symbol: str, period: str
|
|
573
|
+
) -> Union[pd.DataFrame, List[Dict[str, Any]]]:
|
|
574
|
+
if not self.__api_key:
|
|
575
|
+
raise ValueError("API key is required for EODHD data.")
|
|
576
|
+
client = APIClient(api_key=self.__api_key)
|
|
577
|
+
if period in ["d", "w", "m"]:
|
|
578
|
+
return client.get_historical_data(
|
|
579
|
+
symbol=symbol,
|
|
580
|
+
interval=period,
|
|
581
|
+
iso8601_start=self.start_date,
|
|
582
|
+
iso8601_end=self.end_date,
|
|
583
|
+
)
|
|
584
|
+
elif period in ["1m", "5m", "1h"]:
|
|
585
|
+
hms = " 00:00:00"
|
|
586
|
+
fmt = "%Y-%m-%d %H:%M:%S"
|
|
587
|
+
startdt = datetime.strptime(str(self.start_date) + hms, fmt)
|
|
588
|
+
enddt = datetime.strptime(str(self.end_date) + hms, fmt)
|
|
589
|
+
startdt = startdt.replace(tzinfo=timezone("UTC"))
|
|
590
|
+
enddt = enddt.replace(tzinfo=timezone("UTC"))
|
|
591
|
+
unix_start = int(startdt.timestamp())
|
|
592
|
+
unix_end = int(enddt.timestamp())
|
|
593
|
+
return client.get_intraday_historical_data(
|
|
594
|
+
symbol=symbol,
|
|
595
|
+
interval=period,
|
|
596
|
+
from_unix_time=unix_start,
|
|
597
|
+
to_unix_time=unix_end,
|
|
598
|
+
)
|
|
599
|
+
raise ValueError(f"Unsupported period: {period}")
|
|
600
|
+
|
|
601
|
+
def _format_data(
|
|
602
|
+
self, data: Union[List[Dict[str, Any]], pd.DataFrame]
|
|
603
|
+
) -> pd.DataFrame:
|
|
604
|
+
if isinstance(data, pd.DataFrame):
|
|
605
|
+
if data.empty or len(data) == 0:
|
|
606
|
+
raise ValueError("No data found.")
|
|
607
|
+
df = data.drop(labels=["symbol", "interval"], axis=1)
|
|
608
|
+
df = df.rename(columns={"adjusted_close": "adj_close"})
|
|
609
|
+
return df
|
|
610
|
+
|
|
611
|
+
elif isinstance(data, list):
|
|
612
|
+
if not data or len(data) == 0:
|
|
613
|
+
raise ValueError("No data found.")
|
|
614
|
+
df = pd.DataFrame(data)
|
|
615
|
+
df = df.drop(columns=["timestamp", "gmtoffset"], axis=1)
|
|
616
|
+
df = df.rename(columns={"datetime": "date"})
|
|
617
|
+
df["adj_close"] = df["close"]
|
|
618
|
+
df = df[["date", "open", "high", "low", "close", "adj_close", "volume"]]
|
|
619
|
+
df.date = pd.to_datetime(df.date)
|
|
620
|
+
df = df.set_index("date")
|
|
621
|
+
return df
|
|
622
|
+
raise TypeError(f"Unsupported data type: {type(data)}")
|
|
623
|
+
|
|
624
|
+
def _download_and_cache_data(self, cache_dir: Optional[str]) -> str:
|
|
625
|
+
"""Downloads and caches historical data as CSV files."""
|
|
626
|
+
_cache_dir = cache_dir or BBSTRADER_DIR / "data" / "eodhd" / self.period
|
|
627
|
+
os.makedirs(_cache_dir, exist_ok=True)
|
|
628
|
+
for symbol in self.symbol_list:
|
|
629
|
+
filepath = os.path.join(_cache_dir, f"{symbol}.csv")
|
|
630
|
+
try:
|
|
631
|
+
data = self._get_data(symbol, self.period)
|
|
632
|
+
data = self._format_data(data)
|
|
633
|
+
data.to_csv(filepath)
|
|
634
|
+
except Exception as e:
|
|
635
|
+
raise ValueError(f"Error downloading {symbol}: {e}")
|
|
636
|
+
return str(_cache_dir)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class FMPDataHandler(BaseCSVDataHandler):
|
|
640
|
+
"""
|
|
641
|
+
Downloads historical data from Financial Modeling Prep (FMP).
|
|
642
|
+
Data is fetched using the `financetoolkit` library.
|
|
643
|
+
|
|
644
|
+
To use this class, you need to sign up for an API key at
|
|
645
|
+
https://financialmodelingprep.com/developer/docs/pricing and
|
|
646
|
+
provide the key as an argument.
|
|
647
|
+
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
def __init__(
|
|
651
|
+
self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
|
|
652
|
+
) -> None:
|
|
653
|
+
"""
|
|
654
|
+
Args:
|
|
655
|
+
events (Queue): The Event Queue for passing market events.
|
|
656
|
+
symbol_list (list[str]): List of symbols to download data for.
|
|
657
|
+
fmp_start (str): Start date for historical data (YYYY-MM-DD).
|
|
658
|
+
fmp_end (str): End date for historical data (YYYY-MM-DD).
|
|
659
|
+
data_dir (str, optional): Directory for caching data .
|
|
660
|
+
fmp_period (str, optional): Time period for historical data
|
|
661
|
+
(e.g. daily, weekly, monthly, quarterly, yearly, "1min", "5min", "15min", "30min", "1hour").
|
|
662
|
+
fmp_api_key (str): API key for Financial Modeling Prep.
|
|
663
|
+
|
|
664
|
+
Note:
|
|
665
|
+
See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
|
|
666
|
+
"""
|
|
667
|
+
self.symbol_list = symbol_list
|
|
668
|
+
self.start_date = kwargs.get("fmp_start")
|
|
669
|
+
self.end_date = kwargs.get("fmp_end", datetime.now().strftime("%Y-%m-%d"))
|
|
670
|
+
self.period = kwargs.get("fmp_period", "daily")
|
|
671
|
+
self.cache_dir = kwargs.get("data_dir")
|
|
672
|
+
self.__api_key = kwargs.get("fmp_api_key")
|
|
673
|
+
|
|
674
|
+
csv_dir = self._download_and_cache_data(self.cache_dir)
|
|
675
|
+
|
|
676
|
+
super().__init__(
|
|
677
|
+
events,
|
|
678
|
+
symbol_list,
|
|
679
|
+
str(csv_dir),
|
|
680
|
+
columns=kwargs.get("columns"),
|
|
681
|
+
index_col=kwargs.get("index_col", 0),
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
def _get_data(self, symbol: str, period: str) -> pd.DataFrame:
|
|
685
|
+
if not self.__api_key:
|
|
686
|
+
raise ValueError("API key is required for FMP data.")
|
|
687
|
+
toolkit = Toolkit(
|
|
688
|
+
symbol,
|
|
689
|
+
api_key=self.__api_key,
|
|
690
|
+
start_date=self.start_date,
|
|
691
|
+
end_date=self.end_date,
|
|
692
|
+
benchmark_ticker=None,
|
|
693
|
+
progress_bar=False,
|
|
694
|
+
)
|
|
695
|
+
if period in ["daily", "weekly", "monthly", "quarterly", "yearly"]:
|
|
696
|
+
return toolkit.get_historical_data(period=period, progress_bar=False)
|
|
697
|
+
elif period in ["1min", "5min", "15min", "30min", "1hour"]:
|
|
698
|
+
return toolkit.get_intraday_data(period=period, progress_bar=False)
|
|
699
|
+
raise ValueError(f"Unsupported period: {period}")
|
|
700
|
+
|
|
701
|
+
def _format_data(self, data: pd.DataFrame, period: str) -> pd.DataFrame:
|
|
702
|
+
if data.empty or len(data) == 0:
|
|
703
|
+
raise ValueError("No data found.")
|
|
704
|
+
if period[0].isnumeric():
|
|
705
|
+
data = data.drop(columns=["Return", "Volatility", "Cumulative Return"])
|
|
706
|
+
else:
|
|
707
|
+
data = data.drop(
|
|
708
|
+
columns=[
|
|
709
|
+
"Dividends",
|
|
710
|
+
"Return",
|
|
711
|
+
"Volatility",
|
|
712
|
+
"Excess Return",
|
|
713
|
+
"Excess Volatility",
|
|
714
|
+
"Cumulative Return",
|
|
715
|
+
]
|
|
716
|
+
)
|
|
717
|
+
data = data.reset_index()
|
|
718
|
+
if "Adj Close" not in data.columns:
|
|
719
|
+
data["Adj Close"] = data["Close"]
|
|
720
|
+
data["date"] = data["date"].dt.to_timestamp() # type: ignore
|
|
721
|
+
data["date"] = pd.to_datetime(data["date"])
|
|
722
|
+
data.set_index("date", inplace=True)
|
|
723
|
+
return data
|
|
724
|
+
|
|
725
|
+
def _download_and_cache_data(self, cache_dir: Optional[str]) -> str:
|
|
726
|
+
"""Downloads and caches historical data as CSV files."""
|
|
727
|
+
_cache_dir = cache_dir or BBSTRADER_DIR / "data" / "fmp" / self.period
|
|
728
|
+
os.makedirs(_cache_dir, exist_ok=True)
|
|
729
|
+
for symbol in self.symbol_list:
|
|
730
|
+
filepath = os.path.join(_cache_dir, f"{symbol}.csv")
|
|
731
|
+
try:
|
|
732
|
+
data = self._get_data(symbol, self.period)
|
|
733
|
+
data = self._format_data(data, self.period)
|
|
734
|
+
data.to_csv(filepath)
|
|
735
|
+
except Exception as e:
|
|
736
|
+
raise ValueError(f"Error downloading {symbol}: {e}")
|
|
737
|
+
return str(_cache_dir)
|