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.
Files changed (45) hide show
  1. bbstrader/__init__.py +27 -0
  2. bbstrader/__main__.py +92 -0
  3. bbstrader/api/__init__.py +96 -0
  4. bbstrader/api/handlers.py +245 -0
  5. bbstrader/api/metatrader_client.cpython-312-darwin.so +0 -0
  6. bbstrader/api/metatrader_client.pyi +624 -0
  7. bbstrader/assets/bbs_.png +0 -0
  8. bbstrader/assets/bbstrader.ico +0 -0
  9. bbstrader/assets/bbstrader.png +0 -0
  10. bbstrader/assets/qs_metrics_1.png +0 -0
  11. bbstrader/btengine/__init__.py +54 -0
  12. bbstrader/btengine/backtest.py +358 -0
  13. bbstrader/btengine/data.py +737 -0
  14. bbstrader/btengine/event.py +229 -0
  15. bbstrader/btengine/execution.py +287 -0
  16. bbstrader/btengine/performance.py +408 -0
  17. bbstrader/btengine/portfolio.py +393 -0
  18. bbstrader/btengine/strategy.py +588 -0
  19. bbstrader/compat.py +28 -0
  20. bbstrader/config.py +100 -0
  21. bbstrader/core/__init__.py +27 -0
  22. bbstrader/core/data.py +628 -0
  23. bbstrader/core/strategy.py +466 -0
  24. bbstrader/metatrader/__init__.py +48 -0
  25. bbstrader/metatrader/_copier.py +720 -0
  26. bbstrader/metatrader/account.py +865 -0
  27. bbstrader/metatrader/broker.py +418 -0
  28. bbstrader/metatrader/copier.py +1487 -0
  29. bbstrader/metatrader/rates.py +495 -0
  30. bbstrader/metatrader/risk.py +667 -0
  31. bbstrader/metatrader/trade.py +1692 -0
  32. bbstrader/metatrader/utils.py +402 -0
  33. bbstrader/models/__init__.py +39 -0
  34. bbstrader/models/nlp.py +932 -0
  35. bbstrader/models/optimization.py +182 -0
  36. bbstrader/scripts.py +665 -0
  37. bbstrader/trading/__init__.py +33 -0
  38. bbstrader/trading/execution.py +1159 -0
  39. bbstrader/trading/strategy.py +362 -0
  40. bbstrader/trading/utils.py +69 -0
  41. bbstrader-2.0.3.dist-info/METADATA +396 -0
  42. bbstrader-2.0.3.dist-info/RECORD +45 -0
  43. bbstrader-2.0.3.dist-info/WHEEL +5 -0
  44. bbstrader-2.0.3.dist-info/entry_points.txt +3 -0
  45. bbstrader-2.0.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,418 @@
1
+ import random
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+ from zoneinfo import ZoneInfo
6
+
7
+ from bbstrader.api import Mt5client as client
8
+ from bbstrader.metatrader.utils import INIT_MSG, SymbolType, raise_mt5_error
9
+
10
+ try:
11
+ import MetaTrader5 as mt5
12
+ except ImportError:
13
+ import bbstrader.compat # noqa: F401
14
+
15
+ COUNTRIES_STOCKS = {
16
+ "USA": r"\b(US|USA)\b",
17
+ "AUS": r"\b(Australia)\b",
18
+ "BEL": r"\b(Belgium)\b",
19
+ "DNK": r"\b(Denmark)\b",
20
+ "FIN": r"\b(Finland)\b",
21
+ "FRA": r"\b(France)\b",
22
+ "DEU": r"\b(Germany)\b",
23
+ "NLD": r"\b(Netherlands)\b",
24
+ "NOR": r"\b(Norway)\b",
25
+ "PRT": r"\b(Portugal)\b",
26
+ "ESP": r"\b(Spain)\b",
27
+ "SWE": r"\b(Sweden)\b",
28
+ "GBR": r"\b(UK)\b",
29
+ "CHE": r"\b(Switzerland)\b",
30
+ "HKG": r"\b(Hong Kong)\b",
31
+ "IRL": r"\b(Ireland)\b",
32
+ "AUT": r"\b(Austria)\b",
33
+ }
34
+
35
+ EXCHANGES = {
36
+ "XASX": r"Australia.*\(ASX\)",
37
+ "XBRU": r"Belgium.*\(Euronext\)",
38
+ "XCSE": r"Denmark.*\(CSE\)",
39
+ "XHEL": r"Finland.*\(NASDAQ\)",
40
+ "XPAR": r"France.*\(Euronext\)",
41
+ "XETR": r"Germany.*\(Xetra\)",
42
+ "XAMS": r"Netherlands.*\(Euronext\)",
43
+ "XOSL": r"Norway.*\(NASDAQ\)",
44
+ "XLIS": r"Portugal.*\(Euronext\)",
45
+ "XMAD": r"Spain.*\(BME\)",
46
+ "XSTO": r"Sweden.*\(NASDAQ\)",
47
+ "XLON": r"UK.*\(LSE\)",
48
+ "XNYS": r"US.*\((NYSE|ARCA|AMEX)\)",
49
+ "NYSE": r"US.*\(NYSE\)",
50
+ "ARCA": r"US.*\(ARCA\)",
51
+ "AMEX": r"US.*\(AMEX\)",
52
+ "NASDAQ": r"US.*\(NASDAQ\)",
53
+ "BATS": r"US.*\(BATS\)",
54
+ "XSWX": r"Switzerland.*\(SWX\)",
55
+ }
56
+
57
+ SYMBOLS_TYPE = {
58
+ SymbolType.ETFs: r"\b(ETFs?|Exchange\s?Traded\s?Funds?|Trackers?)\b",
59
+ SymbolType.BONDS: r"\b(Treasuries|Bonds|Bunds|Gilts|T-Notes|Fixed\s?Income)\b",
60
+ SymbolType.FOREX: r"\b(Forex|FX|Currencies|Exotics?|Majors?|Minors?)\b",
61
+ SymbolType.FUTURES: r"\b(Futures?|Forwards|Expiring|Front\s?Month)\b",
62
+ SymbolType.STOCKS: r"\b(Stocks?|Equities?|Shares?|Blue\s?Chips?|Large\s?Cap)\b",
63
+ SymbolType.INDICES: r"\b(Indices?|Index|Cash|Spot\s?Indices|Benchmarks)\b(?![^$]*(UKOIL|USOIL|WTI|BRENT))",
64
+ SymbolType.COMMODITIES: r"\b(Commodit(ies|y)|Metals?|Precious|Bullion|Agricultures?|Energies?|Oil|Crude|WTI|BRENT|UKOIL|USOIL|Gas|NATGAS)\b",
65
+ SymbolType.CRYPTO: r"\b(Cryptos?|Cryptocurrencies?|Digital\s?Assets?|DeFi|Altcoins)\b",
66
+ }
67
+
68
+
69
+ def check_mt5_connection(
70
+ *,
71
+ path=None,
72
+ login=None,
73
+ password=None,
74
+ server=None,
75
+ timeout=60_000,
76
+ portable=False,
77
+ **kwargs,
78
+ ) -> bool:
79
+ """
80
+ Initialize the connection to the MetaTrader 5 terminal.
81
+
82
+ Parameters
83
+ ----------
84
+ path : str, optional
85
+ Path to the MetaTrader 5 terminal executable file.
86
+ Defaults to ``None`` (e.g., ``"C:/Program Files/MetaTrader 5/terminal64.exe"``).
87
+ login : int, optional
88
+ The login ID of the trading account. Defaults to ``None``.
89
+ password : str, optional
90
+ The password of the trading account. Defaults to ``None``.
91
+ server : str, optional
92
+ The name of the trade server to which the client terminal is connected.
93
+ Defaults to ``None``.
94
+ timeout : int, optional
95
+ Connection timeout in milliseconds. Defaults to ``60_000``.
96
+ portable : bool, optional
97
+ If ``True``, the portable mode of the terminal is used.
98
+ Defaults to ``False``.
99
+ See: https://www.metatrader5.com/en/terminal/help/start_advanced/start#portable
100
+
101
+ Returns
102
+ -------
103
+ bool
104
+ ``True`` if the connection is successfully established, otherwise ``False``.
105
+
106
+ Notes
107
+ -----
108
+ If you want to launch multiple terminal instances:
109
+
110
+ * First, launch each terminal in **portable mode**.
111
+ * See instructions: https://www.metatrader5.com/en/terminal/help/start_advanced/start#configuration_file
112
+ """
113
+
114
+ if login is not None and server is not None:
115
+ account_info = mt5.account_info()
116
+ if account_info is not None:
117
+ if account_info.login == login and account_info.server == server:
118
+ return True
119
+
120
+ init = False
121
+ if path is None and (login or password or server):
122
+ raise ValueError(
123
+ "You must provide a path to the terminal executable file"
124
+ "when providing login, password or server"
125
+ )
126
+ try:
127
+ if path is not None:
128
+ if login is not None and password is not None and server is not None:
129
+ init = mt5.initialize(
130
+ path=path,
131
+ login=login,
132
+ password=password,
133
+ server=server,
134
+ timeout=timeout,
135
+ portable=portable,
136
+ )
137
+ else:
138
+ init = mt5.initialize(path=path)
139
+ else:
140
+ init = mt5.initialize()
141
+ if not init:
142
+ raise_mt5_error(str(mt5.last_error()) + INIT_MSG)
143
+ except Exception:
144
+ raise_mt5_error(str(mt5.last_error()) + INIT_MSG)
145
+ return init
146
+
147
+
148
+ class Broker(object):
149
+ def __init__(
150
+ self,
151
+ name: str,
152
+ timezone: Optional[str] = None,
153
+ custom_patterns: Optional[Dict[SymbolType, str]] = None,
154
+ custom_countries_stocks: Optional[Dict[str, str]] = None,
155
+ custom_exchanges: Optional[Dict[str, str]] = None,
156
+ ):
157
+ self._name = name
158
+ self._timezone = timezone
159
+ self._patterns = {**SYMBOLS_TYPE, **(custom_patterns or {})}
160
+ self._countries_stocks = {**COUNTRIES_STOCKS, **(custom_countries_stocks or {})}
161
+ self._exchanges = {**EXCHANGES, **(custom_exchanges or {})}
162
+
163
+ @property
164
+ def name(self):
165
+ return self._name
166
+
167
+ @property
168
+ def timezone(self):
169
+ return self._timezone
170
+
171
+ @property
172
+ def countries_stocks(self):
173
+ return self._countries_stocks
174
+
175
+ @property
176
+ def exchanges(self):
177
+ return self._exchanges
178
+
179
+ def __str__(self):
180
+ return self.name
181
+
182
+ def __eq__(self, other) -> bool:
183
+ return self.name == other.name
184
+
185
+ def __ne__(self, other) -> bool:
186
+ return self.name != other.name
187
+
188
+ def __repr__(self):
189
+ return f"{self.__class__.__name__}({self.name})"
190
+
191
+ def __hash__(self):
192
+ return hash(self.name)
193
+
194
+ def to_dict(self) -> Dict[str, Any]:
195
+ return {
196
+ "name": self.name,
197
+ "timezone": self.timezone,
198
+ "patterns": self._patterns,
199
+ "countries_stocks": self._countries_stocks,
200
+ "exchanges": self._exchanges,
201
+ }
202
+
203
+ def initialize_connection(self, **kwargs) -> bool:
204
+ """Broker-specific connection initialization."""
205
+ return check_mt5_connection(**kwargs)
206
+
207
+ def get_terminal_timezone(self) -> str:
208
+ """Fetch or override terminal timezone."""
209
+ if self._timezone is not None:
210
+ return self._timezone
211
+
212
+ symbol = self.get_symbols()[0]
213
+ tick = client.symbol_info_tick(symbol)
214
+
215
+ if tick is None:
216
+ return "Unknown (Market might be closed)"
217
+
218
+ server_time = tick.time
219
+ utc_now = datetime.now(timezone.utc).timestamp()
220
+
221
+ # Check if the tick is stale (e.g., older than 10 hours).
222
+ # This prevents calculating offsets based on weekend gaps.
223
+ if abs(server_time - utc_now) > 3600 * 10:
224
+ # Most Forex/CFD brokers use PLT/EEST (UTC+2 or UTC+3)
225
+ # which maps to Europe/Nicosia or Europe/Athens.
226
+ return "Europe/Nicosia"
227
+
228
+ offset_hours = round((server_time - utc_now) / 3600)
229
+
230
+ if offset_hours == 0:
231
+ return "UTC"
232
+ elif offset_hours in [2, 3]:
233
+ return "Europe/Nicosia"
234
+ elif offset_hours == 7:
235
+ return "Asia/Bangkok"
236
+ else:
237
+ if -12 <= offset_hours <= 14:
238
+ # Note: Etc/GMT signs are inverted.
239
+ # If offset is +2 (server is ahead), we need Etc/GMT-2
240
+ return f"Etc/GMT{-offset_hours:+d}"
241
+ else:
242
+ return "UTC"
243
+
244
+ def get_broker_time(self, time: str, format: str):
245
+ broker_time = datetime.strptime(time, format)
246
+ broker_tz = self.get_terminal_timezone()
247
+ broker_tz = ZoneInfo(broker_tz)
248
+ broker_time = broker_time.replace(tzinfo=ZoneInfo("UTC"))
249
+ return broker_time.astimezone(broker_tz)
250
+
251
+ def get_symbol_type(self, symbol: str) -> SymbolType:
252
+ info = client.symbol_info(symbol)
253
+ if info is None:
254
+ return SymbolType.unknown
255
+ for sym_type, pattern in self._patterns.items():
256
+ if re.search(re.compile(pattern, re.IGNORECASE), info.path):
257
+ return sym_type
258
+ return SymbolType.unknown
259
+
260
+ def get_symbols(
261
+ self,
262
+ symbol_type: SymbolType | str = "ALL",
263
+ check_etf: bool = False,
264
+ save: bool = False,
265
+ file_name: str = "symbols",
266
+ include_desc: bool = False,
267
+ display_total: bool = False,
268
+ ) -> List[str]:
269
+ symbols = client.symbols_get()
270
+ if not symbols:
271
+ raise_mt5_error("Failed to get symbols")
272
+
273
+ symbol_list = []
274
+ if symbol_type != "ALL":
275
+ if (
276
+ not isinstance(symbol_type, SymbolType)
277
+ or symbol_type not in self._patterns
278
+ ):
279
+ raise ValueError(f"Unsupported symbol type: {symbol_type}")
280
+
281
+ def check_etfs(info):
282
+ if (
283
+ symbol_type == SymbolType.ETFs
284
+ and check_etf
285
+ and "ETF" not in info.description
286
+ ):
287
+ raise ValueError(
288
+ f"{info.name} doesn't have 'ETF' in its description. "
289
+ "If this is intended, set check_etf=False."
290
+ )
291
+
292
+ if save:
293
+ max_length = max(len(s.name) for s in symbols)
294
+ file_path = f"{file_name}.txt"
295
+ with open(file_path, mode="w", encoding="utf-8") as file:
296
+ for s in symbols:
297
+ info = client.symbol_info(s.name)
298
+ if symbol_type == "ALL":
299
+ self._write_symbol(file, info, include_desc, max_length)
300
+ symbol_list.append(s.name)
301
+ else:
302
+ pattern = re.compile(self._patterns[symbol_type], re.IGNORECASE)
303
+ if re.search(pattern, info.path):
304
+ check_etfs(info)
305
+ self._write_symbol(file, info, include_desc, max_length)
306
+ symbol_list.append(s.name)
307
+ else:
308
+ for s in symbols:
309
+ info = client.symbol_info(s.name)
310
+ if symbol_type == "ALL":
311
+ symbol_list.append(s.name)
312
+ else:
313
+ pattern = re.compile(self._patterns[symbol_type], re.IGNORECASE)
314
+ if re.search(pattern, info.path):
315
+ check_etfs(info)
316
+ symbol_list.append(s.name)
317
+
318
+ if display_total:
319
+ name = symbol_type if isinstance(symbol_type, str) else symbol_type.name
320
+ print(
321
+ f"Total number of {name} symbols: {len(symbol_list)}"
322
+ if symbol_type != "ALL"
323
+ else f"Total symbols: {len(symbol_list)}"
324
+ )
325
+
326
+ return symbol_list
327
+
328
+ def _write_symbol(self, file, info, include_desc, max_length):
329
+ if include_desc:
330
+ space = " " * (max_length - len(info.name))
331
+ file.write(info.name + space + "|" + info.description + "\n")
332
+ else:
333
+ file.write(info.name + "\n")
334
+
335
+ def get_symbols_by_category(
336
+ self, symbol_type: SymbolType | str, category: str, category_map: Dict[str, str]
337
+ ) -> List[str]:
338
+ if category not in category_map:
339
+ raise ValueError(
340
+ f"Unsupported category: {category}. Choose from: {', '.join(category_map)}"
341
+ )
342
+
343
+ symbols = self.get_symbols(symbol_type=symbol_type)
344
+ pattern = re.compile(category_map[category], re.IGNORECASE)
345
+ symbol_list = []
346
+ for s in symbols:
347
+ info = client.symbol_info(s)
348
+ if re.search(pattern, info.path):
349
+ symbol_list.append(s)
350
+ return symbol_list
351
+
352
+ def get_leverage_for_symbol(
353
+ self, symbol: str, account_leverage: bool = True
354
+ ) -> int:
355
+ if account_leverage:
356
+ return client.account_info().leverage
357
+ s_info = client.symbol_info(symbol)
358
+ if not s_info:
359
+ raise ValueError(f"Symbol {symbol} not found")
360
+ volume_min = s_info.volume_min
361
+ contract_size = s_info.trade_contract_size
362
+ av_price = (s_info.bid + s_info.ask) / 2
363
+ action = random.choice([mt5.ORDER_TYPE_BUY, mt5.ORDER_TYPE_SELL])
364
+ margin = client.order_calc_margin(action, symbol, volume_min, av_price)
365
+ if margin is None or margin == 0:
366
+ return client.account_info().leverage # Fallback
367
+ return round((volume_min * contract_size * av_price) / margin)
368
+
369
+ def adjust_tick_values(
370
+ self,
371
+ symbol: str,
372
+ tick_value_loss: float,
373
+ tick_value_profit: float,
374
+ contract_size: float,
375
+ ) -> Tuple[float, float]:
376
+ symbol_type = self.get_symbol_type(symbol)
377
+ if (
378
+ symbol_type == SymbolType.COMMODITIES
379
+ or symbol_type == SymbolType.FUTURES
380
+ or symbol_type == SymbolType.CRYPTO
381
+ and contract_size > 1
382
+ ):
383
+ tick_value_loss = tick_value_loss / contract_size
384
+ tick_value_profit = tick_value_profit / contract_size
385
+ return tick_value_loss, tick_value_profit
386
+
387
+ def get_min_stop_level(self, symbol: str) -> int:
388
+ s_info = client.symbol_info(symbol)
389
+ return s_info.trade_stops_level if s_info else 0
390
+
391
+ def validate_lot_size(self, symbol: str, lot: float) -> float:
392
+ s_info = client.symbol_info(symbol)
393
+ if not s_info:
394
+ raise ValueError(f"Symbol {symbol} not found")
395
+ if lot > s_info.volume_max:
396
+ return s_info.volume_max / 2
397
+ if lot < s_info.volume_min:
398
+ return s_info.volume_min
399
+ steps = self._volume_step(s_info.volume_step)
400
+ return round(lot, steps) if steps > 0 else round(lot)
401
+
402
+ def _volume_step(self, value: float) -> int:
403
+ value_str = str(value)
404
+ if "." in value_str and value_str != "1.0":
405
+ decimal_index = value_str.index(".")
406
+ return len(value_str) - decimal_index - 1
407
+ return 0
408
+
409
+ def get_currency_conversion_factor(
410
+ self, symbol: str, base_currency: str, account_currency: str
411
+ ) -> float:
412
+ if base_currency == account_currency:
413
+ return 1.0
414
+ conversion_symbol = f"{base_currency}{account_currency}"
415
+ info = client.symbol_info_tick(conversion_symbol)
416
+ if info:
417
+ return (info.ask + info.bid) / 2
418
+ return 1.0