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,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
|