bbstrader 0.2.4__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,579 @@
1
+ from datetime import datetime
2
+ from typing import Optional, Union
3
+
4
+ import MetaTrader5 as Mt5
5
+ import pandas as pd
6
+ from exchange_calendars import get_calendar, get_calendar_names
7
+ from pandas.tseries.holiday import USFederalHolidayCalendar
8
+ from pandas.tseries.offsets import CustomBusinessDay
9
+
10
+ from bbstrader.metatrader.account import AMG_EXCHANGES, Account, check_mt5_connection
11
+ from bbstrader.metatrader.utils import TIMEFRAMES, TimeFrame, raise_mt5_error
12
+
13
+ __all__ = [
14
+ "Rates",
15
+ "download_historical_data",
16
+ "get_data_from_pos",
17
+ "get_data_from_date",
18
+ ]
19
+
20
+ MAX_BARS = 10_000_000
21
+
22
+ IDX_CALENDARS = {
23
+ "CAD": "XTSE",
24
+ "AUD": "XASX",
25
+ "GBP": "XLON",
26
+ "HKD": "XSHG",
27
+ "ZAR": "XJSE",
28
+ "CHF": "XSWX",
29
+ "NOK": "XOSL",
30
+ "EUR": "XETR",
31
+ "SGD": "XSES",
32
+ "USD": "us_futures",
33
+ "JPY": "us_futures",
34
+ }
35
+
36
+ COMD_CALENDARS = {
37
+ "Energies": "us_futures",
38
+ "Metals": "us_futures",
39
+ "Agricultures": "CBOT",
40
+ "Bonds": {"USD": "CBOT", "EUR": "EUREX"},
41
+ }
42
+
43
+ CALENDARS = {
44
+ "FX": "us_futures",
45
+ "STK": AMG_EXCHANGES,
46
+ "ETF": AMG_EXCHANGES,
47
+ "IDX": IDX_CALENDARS,
48
+ "COMD": COMD_CALENDARS,
49
+ "CRYPTO": "24/7",
50
+ "FUT": None,
51
+ }
52
+
53
+ SESSION_TIMEFRAMES = [
54
+ Mt5.TIMEFRAME_D1,
55
+ Mt5.TIMEFRAME_W1,
56
+ Mt5.TIMEFRAME_H12,
57
+ Mt5.TIMEFRAME_MN1,
58
+ ]
59
+
60
+
61
+ class Rates(object):
62
+ """
63
+ Provides methods to retrieve historical financial data from MetaTrader 5.
64
+
65
+ This class encapsulates interactions with the MetaTrader 5 (MT5) terminal
66
+ to fetch historical price data for a given symbol and timeframe. It offers
67
+ flexibility in retrieving data either by specifying a starting position
68
+ and count of bars or by providing a specific date range.
69
+
70
+ Notes:
71
+ 1. Befor using this class, ensure that the `Max bars in chart` in you terminal
72
+ is set to a value that is greater than the number of bars you want to retrieve
73
+ or just set it to Unlimited.
74
+ In your MT5 terminal, go to `Tools` -> `Options` -> `Charts` -> `Max bars in chart`.
75
+
76
+ 2. The `open, high, low, close, adjclose, returns,
77
+ volume` properties returns data in Broker's timezone by default.
78
+
79
+ See `bbstrader.metatrader.account.check_mt5_connection()` for more details on how to connect to MT5 terminal.
80
+
81
+ Example:
82
+ >>> rates = Rates("EURUSD", "1h")
83
+ >>> df = rates.get_historical_data(
84
+ ... date_from=datetime(2023, 1, 1),
85
+ ... date_to=datetime(2023, 1, 10),
86
+ ... )
87
+ >>> print(df.head())
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ symbol: str,
93
+ timeframe: TimeFrame = "D1",
94
+ start_pos: Union[int, str] = 0,
95
+ count: Optional[int] = MAX_BARS,
96
+ session_duration: Optional[float] = None,
97
+ **kwargs,
98
+ ):
99
+ """
100
+ Initializes a new Rates instance.
101
+
102
+ Args:
103
+ symbol (str): Financial instrument symbol (e.g., "EURUSD").
104
+ timeframe (str): Timeframe string (e.g., "D1", "1h", "5m").
105
+ start_pos (int, | str): Starting index (int) or date (str) for data retrieval.
106
+ count (int, optional): Number of bars to retrieve default is
107
+ the maximum bars availble in the MT5 terminal.
108
+ session_duration (float): Number of trading hours per day.
109
+
110
+ Raises:
111
+ ValueError: If the provided timeframe is invalid.
112
+
113
+ Notes:
114
+ If `start_pos` is an str, it must be in 'YYYY-MM-DD' format.
115
+ For `session_duration` check your broker symbols details
116
+ """
117
+ self.symbol = symbol
118
+ self.time_frame = self._validate_time_frame(timeframe)
119
+ self.sd = session_duration
120
+ self.start_pos = self._get_start_pos(start_pos, timeframe)
121
+ self.count = count
122
+ self._mt5_initialized(**kwargs)
123
+ self.__account = Account(**kwargs)
124
+ self.__data = self.get_rates_from_pos()
125
+
126
+ def _mt5_initialized(self, **kwargs):
127
+ check_mt5_connection(**kwargs)
128
+
129
+ def _get_start_pos(self, index, time_frame):
130
+ if isinstance(index, int):
131
+ start_pos = index
132
+ elif isinstance(index, str):
133
+ assert self.sd is not None, ValueError(
134
+ "Please provide the session_duration in hour"
135
+ )
136
+ start_pos = self._get_pos_index(index, time_frame, self.sd)
137
+ return start_pos
138
+
139
+ def _get_pos_index(self, start_date, time_frame, sd):
140
+ # Create a custom business day calendar
141
+ us_business_day = CustomBusinessDay(calendar=USFederalHolidayCalendar())
142
+
143
+ start_date = pd.to_datetime(start_date)
144
+ end_date = pd.to_datetime(datetime.now())
145
+
146
+ # Generate a range of business days
147
+ trading_days = pd.date_range(
148
+ start=start_date, end=end_date, freq=us_business_day
149
+ )
150
+
151
+ # Calculate the number of trading days
152
+ trading_days = len(trading_days)
153
+ td = trading_days
154
+ time_frame_mapping = {}
155
+ for minutes in [
156
+ 1,
157
+ 2,
158
+ 3,
159
+ 4,
160
+ 5,
161
+ 6,
162
+ 10,
163
+ 12,
164
+ 15,
165
+ 20,
166
+ 30,
167
+ 60,
168
+ 120,
169
+ 180,
170
+ 240,
171
+ 360,
172
+ 480,
173
+ 720,
174
+ ]:
175
+ key = f"{minutes//60}h" if minutes >= 60 else f"{minutes}m"
176
+ time_frame_mapping[key] = int(td * (60 / minutes) * sd)
177
+ time_frame_mapping["D1"] = int(td)
178
+
179
+ if time_frame not in time_frame_mapping:
180
+ pv = list(time_frame_mapping.keys())
181
+ raise ValueError(f"Unsupported time frame, Possible Values are {pv}")
182
+
183
+ index = time_frame_mapping.get(time_frame, 0) - 1
184
+ return max(index, 0)
185
+
186
+ def _validate_time_frame(self, time_frame: str) -> int:
187
+ """Validates and returns the MT5 timeframe code."""
188
+ if time_frame not in TIMEFRAMES:
189
+ raise ValueError(
190
+ f"Unsupported time frame '{time_frame}'. "
191
+ f"Possible values are: {list(TIMEFRAMES.keys())}"
192
+ )
193
+ return TIMEFRAMES[time_frame]
194
+
195
+ def _fetch_data(
196
+ self,
197
+ start: Union[int, datetime, pd.Timestamp],
198
+ count: Union[int, datetime, pd.Timestamp],
199
+ lower_colnames=False,
200
+ utc=False,
201
+ ) -> Union[pd.DataFrame, None]:
202
+ """Fetches data from MT5 and returns a DataFrame or None."""
203
+ try:
204
+ if isinstance(start, int) and isinstance(count, int):
205
+ rates = Mt5.copy_rates_from_pos(
206
+ self.symbol, self.time_frame, start, count
207
+ )
208
+ elif isinstance(start, (datetime, pd.Timestamp)) and isinstance(count, int):
209
+ rates = Mt5.copy_rates_from(self.symbol, self.time_frame, start, count)
210
+ elif isinstance(start, (datetime, pd.Timestamp)) and isinstance(
211
+ count, (datetime, pd.Timestamp)
212
+ ):
213
+ rates = Mt5.copy_rates_range(self.symbol, self.time_frame, start, count)
214
+ if rates is None:
215
+ return None
216
+
217
+ df = pd.DataFrame(rates)
218
+ return self._format_dataframe(df, lower_colnames=lower_colnames, utc=utc)
219
+ except Exception as e:
220
+ raise_mt5_error(e)
221
+
222
+ def _format_dataframe(
223
+ self, df: pd.DataFrame, lower_colnames=False, utc=False
224
+ ) -> pd.DataFrame:
225
+ """Formats the raw MT5 data into a standardized DataFrame."""
226
+ df = df.copy()
227
+ df = df[["time", "open", "high", "low", "close", "tick_volume"]]
228
+ df.columns = ["Date", "Open", "High", "Low", "Close", "Volume"]
229
+ df["Adj Close"] = df["Close"]
230
+ df = df[["Date", "Open", "High", "Low", "Close", "Adj Close", "Volume"]]
231
+ df["Date"] = pd.to_datetime(df["Date"], unit="s", utc=utc)
232
+ df.set_index("Date", inplace=True)
233
+ if lower_colnames:
234
+ df.columns = df.columns.str.lower().str.replace(" ", "_")
235
+ df.index.name = df.index.name.lower().replace(" ", "_")
236
+ return df
237
+
238
+ def _filter_data(
239
+ self, df: pd.DataFrame, date_from=None, date_to=None, fill_na=False
240
+ ) -> pd.DataFrame:
241
+ df = df.copy()
242
+ symbol_type = self.__account.get_symbol_type(self.symbol)
243
+ currencies = self.__account.get_currency_rates(self.symbol)
244
+ s_info = self.__account.get_symbol_info(self.symbol)
245
+ if symbol_type in CALENDARS:
246
+ if symbol_type == "STK" or symbol_type == "ETF":
247
+ for exchange in CALENDARS[symbol_type]:
248
+ if exchange in get_calendar_names():
249
+ symbols = self.__account.get_stocks_from_exchange(
250
+ exchange_code=exchange
251
+ )
252
+ if self.symbol in symbols:
253
+ calendar = get_calendar(exchange, side="right")
254
+ break
255
+ elif symbol_type == "IDX":
256
+ calendar = get_calendar(
257
+ CALENDARS[symbol_type][currencies["mc"]], side="right"
258
+ )
259
+ elif symbol_type == "COMD":
260
+ for commodity in CALENDARS[symbol_type]:
261
+ if commodity in s_info.path:
262
+ calendar = get_calendar(
263
+ CALENDARS[symbol_type][commodity], side="right"
264
+ )
265
+ elif symbol_type == "FUT":
266
+ if "Index" in s_info.path:
267
+ calendar = get_calendar(
268
+ CALENDARS["IDX"][currencies["mc"]], side="right"
269
+ )
270
+ else:
271
+ for commodity, cal in COMD_CALENDARS.items():
272
+ if self.symbol in self.__account.get_future_symbols(
273
+ category=commodity
274
+ ):
275
+ if commodity == "Bonds":
276
+ calendar = get_calendar(
277
+ cal[currencies["mc"]], side="right"
278
+ )
279
+ else:
280
+ calendar = get_calendar(cal, side="right")
281
+ else:
282
+ calendar = get_calendar(CALENDARS[symbol_type], side="right")
283
+ date_from = date_from or df.index[0]
284
+ date_to = date_to or df.index[-1]
285
+ if self.time_frame in SESSION_TIMEFRAMES:
286
+ valid_sessions = calendar.sessions_in_range(date_from, date_to)
287
+ else:
288
+ valid_sessions = calendar.minutes_in_range(date_from, date_to)
289
+ if self.time_frame in [Mt5.TIMEFRAME_M1, Mt5.TIMEFRAME_D1]:
290
+ # save the index name of the dataframe
291
+ index_name = df.index.name
292
+ if fill_na:
293
+ if isinstance(fill_na, bool):
294
+ method = "nearest"
295
+ if isinstance(fill_na, str):
296
+ method = fill_na
297
+ df = df.reindex(valid_sessions, method=method)
298
+ else:
299
+ df.reindex(valid_sessions, method=None)
300
+ df.index = df.index.rename(index_name)
301
+ else:
302
+ df = df[df.index.isin(valid_sessions)]
303
+ return df
304
+
305
+ def _check_filter(self, filter, utc):
306
+ if filter and self.time_frame not in SESSION_TIMEFRAMES and not utc:
307
+ utc = True
308
+ elif filter and self.time_frame in SESSION_TIMEFRAMES and utc:
309
+ utc = False
310
+ return utc
311
+
312
+ def get_rates_from_pos(
313
+ self, filter=False, fill_na=False, lower_colnames=False, utc=False
314
+ ) -> Union[pd.DataFrame, None]:
315
+ """
316
+ Retrieves historical data starting from a specific position.
317
+
318
+ Uses the `start_pos` and `count` attributes specified during
319
+ initialization to fetch data.
320
+
321
+ Args:
322
+ filter : See `Rates.get_historical_data` for more details.
323
+ fill_na : See `Rates.get_historical_data` for more details.
324
+ lower_colnames : If True, the column names will be converted to lowercase.
325
+ utc (bool, optional): If True, the data will be in UTC timezone.
326
+ Defaults to False.
327
+
328
+ Returns:
329
+ Union[pd.DataFrame, None]: A DataFrame containing historical
330
+ data if successful, otherwise None.
331
+
332
+ Raises:
333
+ ValueError: If `start_pos` or `count` is not provided during
334
+ initialization.
335
+
336
+ Notes:
337
+ The Datetime for this method is in Broker's timezone.
338
+ """
339
+ if self.start_pos is None or self.count is None:
340
+ raise ValueError(
341
+ "Both 'start_pos' and 'count' must be provided "
342
+ "when calling 'get_rates_from_pos'."
343
+ )
344
+ utc = self._check_filter(filter, utc)
345
+ df = self._fetch_data(
346
+ self.start_pos, self.count, lower_colnames=lower_colnames, utc=utc
347
+ )
348
+ if df is None:
349
+ return None
350
+ if filter:
351
+ return self._filter_data(df, fill_na=fill_na)
352
+ return df
353
+
354
+ def get_rates_from(
355
+ self,
356
+ date_from: datetime | pd.Timestamp,
357
+ count: int = MAX_BARS,
358
+ filter=False,
359
+ fill_na=False,
360
+ lower_colnames=False,
361
+ utc=False,
362
+ ) -> Union[pd.DataFrame, None]:
363
+ """
364
+ Retrieves historical data within a specified date range.
365
+
366
+ Args:
367
+ date_from : Starting date for data retrieval.
368
+ The data will be retrieved from this date going to the past.
369
+
370
+ count : Number of bars to retrieve.
371
+
372
+ filter : See `Rates.get_historical_data` for more details.
373
+ fill_na : See `Rates.get_historical_data` for more details.
374
+ lower_colnames : If True, the column names will be converted to lowercase.
375
+ utc (bool, optional): If True, the data will be in UTC timezone.
376
+ Defaults to False.
377
+
378
+ Returns:
379
+ Union[pd.DataFrame, None]: A DataFrame containing historical
380
+ data if successful, otherwise None.
381
+ """
382
+ utc = self._check_filter(filter, utc)
383
+ df = self._fetch_data(date_from, count, lower_colnames=lower_colnames, utc=utc)
384
+ if df is None:
385
+ return None
386
+ if filter:
387
+ return self._filter_data(df, fill_na=fill_na)
388
+ return df
389
+
390
+ @property
391
+ def open(self):
392
+ return self.__data["Open"]
393
+
394
+ @property
395
+ def high(self):
396
+ return self.__data["High"]
397
+
398
+ @property
399
+ def low(self):
400
+ return self.__data["Low"]
401
+
402
+ @property
403
+ def close(self):
404
+ return self.__data["Close"]
405
+
406
+ @property
407
+ def adjclose(self):
408
+ return self.__data["Adj Close"]
409
+
410
+ @property
411
+ def returns(self):
412
+ """
413
+ Fractional change between the current and a prior element.
414
+
415
+ Computes the fractional change from the immediately previous row by default.
416
+ This is useful in comparing the fraction of change in a time series of elements.
417
+
418
+ Note
419
+ ----
420
+ It calculates fractional change (also known as `per unit change or relative change`)
421
+ and `not percentage change`. If you need the percentage change, multiply these values by 100.
422
+ """
423
+ data = self.__data.copy()
424
+ data["Returns"] = data["Adj Close"].pct_change()
425
+ data = data.dropna()
426
+ return data["Returns"]
427
+
428
+ @property
429
+ def volume(self):
430
+ return self.__data["Volume"]
431
+
432
+ def get_historical_data(
433
+ self,
434
+ date_from: datetime | pd.Timestamp,
435
+ date_to: datetime | pd.Timestamp = pd.Timestamp.now(),
436
+ utc: bool = False,
437
+ filter: Optional[bool] = False,
438
+ fill_na: Optional[bool | str] = False,
439
+ lower_colnames: Optional[bool] = True,
440
+ save_csv: Optional[bool] = False,
441
+ ) -> Union[pd.DataFrame, None]:
442
+ """
443
+ Retrieves historical data within a specified date range.
444
+
445
+ Args:
446
+ date_from : Starting date for data retrieval.
447
+
448
+ date_to : Ending date for data retrieval.
449
+ Defaults to the current time.
450
+
451
+ utc : If True, the data will be in UTC timezone.
452
+ Defaults to False.
453
+
454
+ filter : If True, the data will be filtered based
455
+ on the trading sessions for the symbol.
456
+ This is use when we want to use the data for backtesting using Zipline.
457
+
458
+ fill_na : If True, the data will be filled with the nearest value.
459
+ This is use only when `filter` is True and time frame is "1m" or "D1",
460
+ this is because we use ``calendar.minutes_in_range`` or ``calendar.sessions_in_range``
461
+ where calendar is the ``ExchangeCalendar`` from `exchange_calendars` package.
462
+ So, for "1m" or "D1" time frame, the data will be filled with the nearest value
463
+ because the data from MT5 will have approximately the same number of rows as the
464
+ number of trading days or minute in the exchange calendar, so we can fill the missing
465
+ data with the nearest value.
466
+
467
+ But for other time frames, the data will be reindexed with the exchange calendar
468
+ because the data from MT5 will have more rows than the number of trading days or minute
469
+ in the exchange calendar. So we only take the data that is in the range of the exchange
470
+ calendar sessions or minutes.
471
+
472
+ lower_colnames : If True, the column names will be converted to lowercase.
473
+
474
+ save_csv : File path to save the data as a CSV.
475
+ If None, the data won't be saved.
476
+
477
+ Returns:
478
+ Union[pd.DataFrame, None]: A DataFrame containing historical data
479
+ if successful, otherwise None.
480
+
481
+ Raises:
482
+ ValueError: If the starting date is greater than the ending date.
483
+
484
+ Notes:
485
+ The `filter` for this method can be use only for Admira Markets Group (AMG) symbols.
486
+ The Datetime for this method is in Local timezone by default.
487
+ All STK symbols are filtered based on the the exchange calendar.
488
+ All FX symbols are filtered based on the ``us_futures`` calendar.
489
+ All IDX symbols are filtered based on the exchange calendar of margin currency.
490
+ All COMD symbols are filtered based on the exchange calendar of the commodity.
491
+ """
492
+ utc = self._check_filter(filter, utc)
493
+ df = self._fetch_data(
494
+ date_from, date_to, lower_colnames=lower_colnames, utc=utc
495
+ )
496
+ if df is None:
497
+ return None
498
+ if filter:
499
+ df = self._filter_data(
500
+ df, date_from=date_from, date_to=date_to, fill_na=fill_na
501
+ )
502
+ if save_csv:
503
+ df.to_csv(f"{self.symbol}.csv")
504
+ return df
505
+
506
+
507
+ def download_historical_data(
508
+ symbol,
509
+ timeframe,
510
+ date_from,
511
+ date_to=pd.Timestamp.now(),
512
+ lower_colnames=True,
513
+ utc=False,
514
+ filter=False,
515
+ fill_na=False,
516
+ save_csv=False,
517
+ **kwargs,
518
+ ):
519
+ """Download historical data from MetaTrader 5 terminal.
520
+ See `Rates.get_historical_data` for more details.
521
+ """
522
+ rates = Rates(symbol, timeframe, **kwargs)
523
+ data = rates.get_historical_data(
524
+ date_from=date_from,
525
+ date_to=date_to,
526
+ save_csv=save_csv,
527
+ utc=utc,
528
+ filter=filter,
529
+ lower_colnames=lower_colnames,
530
+ )
531
+ return data
532
+
533
+
534
+ def get_data_from_pos(
535
+ symbol,
536
+ timeframe,
537
+ start_pos=0,
538
+ fill_na=False,
539
+ count=MAX_BARS,
540
+ lower_colnames=False,
541
+ utc=False,
542
+ filter=False,
543
+ session_duration=23.0,
544
+ **kwargs,
545
+ ):
546
+ """Get historical data from a specific position.
547
+ See `Rates.get_rates_from_pos` for more details.
548
+ """
549
+ rates = Rates(symbol, timeframe, start_pos, count, session_duration, **kwargs)
550
+ data = rates.get_rates_from_pos(
551
+ filter=filter, fill_na=fill_na, lower_colnames=lower_colnames, utc=utc
552
+ )
553
+ return data
554
+
555
+
556
+ def get_data_from_date(
557
+ symbol,
558
+ timeframe,
559
+ date_from,
560
+ count=MAX_BARS,
561
+ fill_na=False,
562
+ lower_colnames=False,
563
+ utc=False,
564
+ filter=False,
565
+ **kwargs,
566
+ ):
567
+ """Get historical data from a specific date.
568
+ See `Rates.get_rates_from` for more details.
569
+ """
570
+ rates = Rates(symbol, timeframe, **kwargs)
571
+ data = rates.get_rates_from(
572
+ date_from,
573
+ count,
574
+ filter=filter,
575
+ fill_na=fill_na,
576
+ lower_colnames=lower_colnames,
577
+ utc=utc,
578
+ )
579
+ return data