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,1692 @@
1
+ import os
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+ from datetime import datetime
4
+ from logging import Logger
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple
7
+
8
+ import pandas as pd
9
+ import quantstats as qs
10
+ from loguru import logger as log
11
+ from tabulate import tabulate
12
+
13
+ from bbstrader.api import Mt5client as client
14
+ from bbstrader.api.metatrader_client import TradePosition # type: ignore
15
+ from bbstrader.config import BBSTRADER_DIR, config_logger
16
+ from bbstrader.metatrader.account import Account
17
+ from bbstrader.metatrader.broker import check_mt5_connection
18
+ from bbstrader.metatrader.risk import RiskManagement
19
+ from bbstrader.metatrader.utils import INIT_MSG, raise_mt5_error, trade_retcode_message
20
+
21
+ try:
22
+ import MetaTrader5 as Mt5
23
+ except ImportError:
24
+ import bbstrader.compat # noqa: F401
25
+
26
+ __all__ = [
27
+ "Trade",
28
+ "create_trade_instance",
29
+ ]
30
+
31
+ log.add(
32
+ f"{BBSTRADER_DIR}/logs/trade.log",
33
+ enqueue=True,
34
+ level="INFO",
35
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
36
+ )
37
+
38
+ global LOGGER
39
+ LOGGER = log
40
+
41
+
42
+ FILLING_TYPE = [
43
+ Mt5.ORDER_FILLING_IOC,
44
+ Mt5.ORDER_FILLING_RETURN,
45
+ Mt5.ORDER_FILLING_BOC,
46
+ ]
47
+
48
+
49
+ Buys = Literal["BMKT", "BLMT", "BSTP", "BSTPLMT"]
50
+ Sells = Literal["SMKT", "SLMT", "SSTP", "SSTPLMT"]
51
+ Positions = Literal["all", "buy", "sell", "profitable", "losing"]
52
+ Orders = Literal[
53
+ "all",
54
+ "buy_stops",
55
+ "sell_stops",
56
+ "buy_limits",
57
+ "sell_limits",
58
+ "buy_stop_limits",
59
+ "sell_stop_limits",
60
+ ]
61
+
62
+ EXPERT_ID = 181051
63
+
64
+
65
+ class Trade:
66
+ """
67
+ Extends the `RiskManagement` class to include specific trading operations,
68
+ incorporating risk management strategies directly into trade executions.
69
+ It offers functionalities to execute trades while managing risks.
70
+
71
+ Exemple:
72
+ >>> import time
73
+ >>> # Initialize the Trade class with parameters
74
+ >>> trade = Trade(
75
+ ... symbol="EURUSD", # Symbol to trade
76
+ ... expert_name="bbstrader", # Name of the expert advisor
77
+ ... expert_id=12345, # Unique ID for the expert advisor
78
+ ... version="1.0", # Version of the expert advisor
79
+ ... target=5.0, # Daily profit target in percentage
80
+ ... start_time="09:00", # Start time for trading
81
+ ... finishing_time="17:00", # Time to stop opening new positions
82
+ ... ending_time="17:30", # Time to close any open positions
83
+ ... max_risk=2.0, # Maximum risk allowed on the account in percentage
84
+ ... daily_risk=1.0, # Daily risk allowed in percentage
85
+ ... max_trades=5, # Maximum number of trades per session
86
+ ... rr=2.0, # Risk-reward ratio
87
+ ... account_leverage=True, # Use account leverage in calculations
88
+ ... std_stop=True, # Use standard deviation for stop loss calculation
89
+ ... sl=20, # Stop loss in points (optional)
90
+ ... tp=30, # Take profit in points (optional)
91
+ ... be=10 # Break-even in points (optional)
92
+ ... )
93
+
94
+ >>> # Example to open a buy position
95
+ >>> trade.open_buy_position(mm=True, comment="Opening Buy Position")
96
+
97
+ >>> # Example to open a sell position
98
+ >>> trade.open_sell_position(mm=True, comment="Opening Sell Position")
99
+
100
+ >>> # Check current open positions
101
+ >>> opened_positions = trade.get_opened_positions
102
+ >>> if opened_positions is not None:
103
+ ... print(f"Current open positions: {opened_positions}")
104
+
105
+ >>> # Close all open positions at the end of the trading session
106
+ >>> if trade.days_end():
107
+ ... trade.close_all_positions(comment="Closing all positions at day's end")
108
+
109
+ >>> # Print trading session statistics
110
+ >>> trade.statistics(save=True, dir="my_trading_stats")
111
+
112
+ >>> # Sleep until the next trading session if needed (example usage)
113
+ >>> sleep_time = trade.sleep_time()
114
+ >>> print(f"Sleeping for {sleep_time} minutes until the next trading session.")
115
+ >>> time.sleep(sleep_time * 60)
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ symbol: str = "EURUSD",
121
+ expert_name: str = "bbstrader",
122
+ expert_id: int = EXPERT_ID,
123
+ version: str = "3.0",
124
+ target: float = 5.0,
125
+ start_time: str = "1:00",
126
+ finishing_time: str = "23:00",
127
+ ending_time: str = "23:30",
128
+ time_frame: str = "D1",
129
+ broker_tz=False,
130
+ verbose: bool = False,
131
+ console_log: bool = False,
132
+ logger: Logger | str = "bbstrader.log",
133
+ **kwargs,
134
+ ):
135
+ """
136
+ Initializes the Trade class with the specified parameters.
137
+
138
+ Args:
139
+ symbol (str): The `symbol` that the expert advisor will trade.
140
+ expert_name (str): The name of the `expert advisor`.
141
+ expert_id (int): The `unique ID` used to identify the expert advisor
142
+ or the strategy used on the symbol.
143
+ version (str): The `version` of the expert advisor.
144
+ target (float): `Trading period (day, week, month) profit target` in percentage.
145
+ start_time (str): The` hour and minutes` that the expert advisor is able to start to run.
146
+ finishing_time (str): The time after which no new position can be opened.
147
+ ending_time (str): The time after which any open position will be closed.
148
+ verbose (bool | None): If set to None (default), account summary and risk managment
149
+ parameters are printed in the terminal.
150
+ console_log (bool): If set to True, log messages are displayed in the console.
151
+ logger (Logger | str): The logger object to use for logging messages could be a string or a logger object.
152
+ **kwargs: Params for the RiskManagement and Account
153
+ See the ``bbstrader.metatrader.risk.RiskManagement`` class for more details on these parameters.
154
+ See `bbstrader.metatrader.broker.check_mt5_connection()` for more details on how to connect to MT5 terminal.
155
+ """
156
+
157
+ self.symbol = symbol
158
+ self.expert_name = expert_name
159
+ self.expert_id = expert_id
160
+ self.version = version
161
+ self.target = target
162
+ self.verbose = verbose
163
+ self.start = start_time
164
+ self.end = ending_time
165
+ self.finishing = finishing_time
166
+ self.broker_tz = broker_tz
167
+ self.console_log = console_log
168
+ self.timeframe = time_frame
169
+ self.kwargs = kwargs
170
+
171
+ self.account = Account(**kwargs)
172
+ self.rm = RiskManagement(
173
+ symbol=symbol,
174
+ start_time=start_time,
175
+ finishing_time=finishing_time,
176
+ time_frame=time_frame,
177
+ broker_tz=broker_tz,
178
+ **kwargs,
179
+ )
180
+
181
+ self.buy_positions = []
182
+ self.sell_positions = []
183
+ self.opened_positions = []
184
+ self.opened_orders = []
185
+ self.break_even_status = []
186
+ self.break_even_points = {}
187
+ self.trail_after_points = []
188
+ self._retcodes = []
189
+
190
+ self._get_logger(logger, console_log)
191
+ self.initialize(**kwargs)
192
+ self.select_symbol(**kwargs)
193
+ self.prepare_symbol()
194
+
195
+ if self.verbose:
196
+ self.summary()
197
+ print()
198
+ self.risk_managment()
199
+ print(f">>> Everything is OK, @{self.expert_name} is Running ...>>>\n")
200
+
201
+ @property
202
+ def retcodes(self) -> List[int]:
203
+ """Return all the retcodes"""
204
+ return self._retcodes
205
+
206
+ @property
207
+ def logger(self):
208
+ return LOGGER
209
+
210
+ @property
211
+ def orders(self):
212
+ """Return all opened order's tickets"""
213
+ current_orders = self.get_current_orders() or []
214
+ opened_orders = set(current_orders + self.opened_orders)
215
+ return list(opened_orders) if len(opened_orders) != 0 else None
216
+
217
+ @property
218
+ def positions(self):
219
+ """Return all opened position's tickets"""
220
+ current_positions = self.get_current_positions() or []
221
+ opened_positions = set(current_positions + self.opened_positions)
222
+ return list(opened_positions) if len(opened_positions) != 0 else None
223
+
224
+ @property
225
+ def buypos(self):
226
+ """Return all buy opened position's tickets"""
227
+ buy_positions = self.get_current_buys() or []
228
+ buy_positions = set(buy_positions + self.buy_positions)
229
+ return list(buy_positions) if len(buy_positions) != 0 else None
230
+
231
+ @property
232
+ def sellpos(self):
233
+ """Return all sell opened position's tickets"""
234
+ sell_positions = self.get_current_sells() or []
235
+ sell_positions = set(sell_positions + self.sell_positions)
236
+ return list(sell_positions) if len(sell_positions) != 0 else None
237
+
238
+ @property
239
+ def bepos(self):
240
+ """Return All positon's tickets
241
+ for which a break even has been set"""
242
+ if len(self.break_even_status) != 0:
243
+ return self.break_even_status
244
+ return None
245
+
246
+ def _get_logger(self, loger: Any, consol_log: bool):
247
+ """Get the logger object"""
248
+ global LOGGER
249
+ if loger is None:
250
+ ... # Do nothing
251
+ elif isinstance(loger, (str, Path)):
252
+ LOGGER = config_logger(f"{BBSTRADER_DIR}/logs/{loger}", consol_log)
253
+ elif isinstance(loger, (Logger, type(log))):
254
+ LOGGER = loger
255
+
256
+ def initialize(self, **kwargs):
257
+ """
258
+ Initializes the MetaTrader 5 (MT5) terminal for trading operations.
259
+ This method attempts to establish a connection with the MT5 terminal.
260
+ If the initial connection attempt fails due to a timeout, it retries after a specified delay.
261
+ Successful initialization is crucial for the execution of trading operations.
262
+
263
+ Raises:
264
+ MT5TerminalError: If initialization fails.
265
+ """
266
+ try:
267
+ if self.verbose:
268
+ print("\nInitializing the basics.")
269
+ check_mt5_connection(**kwargs)
270
+ if self.verbose:
271
+ print(
272
+ f"You are running the @{self.expert_name} Expert advisor,"
273
+ f" Version @{self.version}, on {self.symbol}."
274
+ )
275
+ except Exception as e:
276
+ LOGGER.error(f"During initialization: {e}")
277
+
278
+ def select_symbol(self, **kwargs):
279
+ """
280
+ Selects the trading symbol in the MetaTrader 5 (MT5) terminal.
281
+ This method ensures that the specified trading
282
+ symbol is selected and visible in the MT5 terminal,
283
+ allowing subsequent trading operations such as opening and
284
+ closing positions on this symbol.
285
+
286
+ Raises:
287
+ MT5TerminalError: If symbole selection fails.
288
+ """
289
+ try:
290
+ check_mt5_connection(**kwargs)
291
+ if not client.symbol_select(self.symbol, True):
292
+ raise_mt5_error(message=INIT_MSG)
293
+ except Exception as e:
294
+ LOGGER.error(f"Selecting symbol '{self.symbol}': {e}")
295
+
296
+ def prepare_symbol(self):
297
+ """
298
+ Prepares the selected symbol for trading.
299
+ This method checks if the symbol is available and visible in the
300
+ MT5 terminal. If the symbol is not visible, it attempts to select the symbol again.
301
+ This step ensures that trading operations can be performed on the selected symbol without issues.
302
+
303
+ Raises:
304
+ MT5TerminalError: If the symbol cannot be made visible for trading operations.
305
+ """
306
+ try:
307
+ symbol_info = client.symbol_info(self.symbol)
308
+ if symbol_info is None:
309
+ raise_mt5_error(message=INIT_MSG)
310
+
311
+ if not symbol_info.visible:
312
+ raise_mt5_error(message=INIT_MSG)
313
+ if self.verbose:
314
+ print("Initialization successfully completed.")
315
+ except Exception as e:
316
+ LOGGER.error(f"Preparing symbol '{self.symbol}': {e}")
317
+
318
+ def summary(self):
319
+ """Show a brief description about the trading program"""
320
+ fmt = "%H:%M"
321
+ start = datetime.strptime(self.start, fmt).time()
322
+ finish = datetime.strptime(self.finishing, fmt).time()
323
+ end = datetime.strptime(self.end, fmt).time()
324
+ if self.broker_tz:
325
+ start = self.account.broker.get_broker_time(self.start, fmt).time()
326
+ finish = self.account.broker.get_broker_time(self.finishing, fmt).time()
327
+ end = self.account.broker.get_broker_time(self.end, fmt).time()
328
+ summary_data = [
329
+ ["Expert Advisor Name", f"@{self.expert_name}"],
330
+ ["Expert Advisor Version", f"@{self.version}"],
331
+ ["Expert | Strategy ID", self.expert_id],
332
+ ["Trading Symbol", self.symbol],
333
+ ["Trading Time Frame", self.timeframe],
334
+ ["Start Trading Time", f"{start}"],
335
+ ["Finishing Trading Time", f"{finish}"],
336
+ ["Closing Position After", f"{end}"],
337
+ ]
338
+ # Custom table format
339
+ summary_table = tabulate(
340
+ summary_data, headers=["Summary", "Values"], tablefmt="outline"
341
+ )
342
+
343
+ # Print the table
344
+ print("\n[============ Trade Account Summary ==============]")
345
+ print(summary_table)
346
+
347
+ def risk_managment(self):
348
+ """Show the risk management parameters"""
349
+
350
+ loss = self.rm.currency_risk()["trade_loss"]
351
+ trade_profit = self.rm.currency_risk()["trade_profit"]
352
+ ok = "OK" if self.rm.is_risk_ok() else "Not OK"
353
+ account_info = self.account.get_account_info()
354
+ total_profit = round(self.get_stats()[1]["total_profit"], 2)
355
+ currency = account_info.currency
356
+ rates = self.account.get_currency_rates(self.symbol)
357
+
358
+ account_data = [
359
+ ["Account Name", account_info.name],
360
+ ["Account Number", account_info.login],
361
+ ["Account Server", account_info.server],
362
+ ["Account Balance", f"{account_info.balance} {currency}"],
363
+ ["Account Profit", f"{total_profit} {currency}"],
364
+ ["Account Equity", f"{account_info.equity} {currency}"],
365
+ ["Account Leverage", account_info.leverage],
366
+ ["Account Margin", f"{round(account_info.margin, 2)} {currency}"],
367
+ ["Account Free Margin", f"{account_info.margin_free} {currency}"],
368
+ ["Maximum Drawdown", f"{self.rm.max_risk}%"],
369
+ ["Risk Allowed", f"{round((self.rm.max_risk - self.rm.risk_level()), 2)}%"],
370
+ ["Volume", f"{self.rm.volume()} {rates.get('pc')}"],
371
+ ["Risk Per trade", f"{-self.rm.get_currency_risk()} {currency}"],
372
+ ["Profit Expected Per trade", f"{self.rm.expected_profit()} {currency}"],
373
+ ["Lot Size", f"{self.rm.get_lot()} Lots"],
374
+ ["Stop Loss", f"{self.rm.get_stop_loss()} Points"],
375
+ ["Loss Value Per Tick", f"{round(loss, 5)} {currency}"],
376
+ ["Take Profit", f"{self.rm.get_take_profit()} Points"],
377
+ ["Profit Value Per Tick", f"{round(trade_profit, 5)} {currency}"],
378
+ ["Break Even", f"{self.rm.get_break_even()} Points"],
379
+ ["Deviation", f"{self.rm.get_deviation()} Points"],
380
+ ["Trading Time Interval", f"{self.rm.get_minutes()} Minutes"],
381
+ ["Risk Level", ok],
382
+ ["Maximum Trades", self.rm.max_trade()],
383
+ ]
384
+ # Custom table format
385
+ print("\n[======= Account Risk Management Overview =======]")
386
+ table = tabulate(
387
+ account_data, headers=["Risk Metrics", "Values"], tablefmt="outline"
388
+ )
389
+
390
+ # Print the table
391
+ print(table)
392
+
393
+ def statistics(self, save=True, dir=None):
394
+ """
395
+ Print some statistics for the trading session and save to CSV if specified.
396
+
397
+ Args:
398
+ save (bool, optional): Whether to save the statistics to a CSV file.
399
+ dir (str, optional): The directory to save the CSV file.
400
+ """
401
+ stats, additional_stats = self.get_stats()
402
+
403
+ profit = round(stats["profit"], 2)
404
+ win_rate = stats["win_rate"]
405
+ total_fees = round(stats["total_fees"], 3)
406
+ average_fee = round(stats["average_fee"], 3)
407
+ currency = self.account.info.currency
408
+ net_profit = round((profit + total_fees), 2)
409
+ trade_risk = round(self.rm.get_currency_risk() * -1, 2)
410
+
411
+ # Formatting the statistics output
412
+ session_data = [
413
+ ["Total Trades", stats["deals"]],
414
+ ["Winning Trades", stats["win_trades"]],
415
+ ["Losing Trades", stats["loss_trades"]],
416
+ ["Session Profit", f"{profit} {currency}"],
417
+ ["Total Fees", f"{total_fees} {currency}"],
418
+ ["Average Fees", f"{average_fee} {currency}"],
419
+ ["Net Profit", f"{net_profit} {currency}"],
420
+ ["Risk per Trade", f"{trade_risk} {currency}"],
421
+ ["Expected Profit per Trade", f"{self.rm.expected_profit()} {currency}"],
422
+ ["Risk Reward Ratio", self.rm.rr],
423
+ ["Win Rate", f"{win_rate}%"],
424
+ ["Sharpe Ratio", self.sharpe()],
425
+ ["Trade Profitability", additional_stats["profitability"]],
426
+ ]
427
+ session_table = tabulate(
428
+ session_data, headers=["Statistics", "Values"], tablefmt="outline"
429
+ )
430
+
431
+ if self.verbose:
432
+ print("\n[========== Trading Session Statistics ===========]")
433
+ print(session_table)
434
+
435
+ if save and stats["deals"] > 0:
436
+ today_date = datetime.now().strftime("%Y%m%d%H%M%S")
437
+ statistics_dict = {item[0]: item[1] for item in session_data}
438
+ stats_df = pd.DataFrame(statistics_dict, index=[0])
439
+
440
+ dir = dir or ".sessions"
441
+ os.makedirs(dir, exist_ok=True)
442
+ symbol = self.symbol.split(".")[0] if "." in self.symbol else self.symbol
443
+
444
+ filename = f"{symbol}_{today_date}@{self.expert_id}.csv"
445
+ filepath = os.path.join(dir, filename)
446
+ stats_df.to_csv(filepath, index=False)
447
+ LOGGER.info(f"Session statistics saved to {filepath}")
448
+
449
+ def _order_type(self):
450
+ return {
451
+ "BMKT": (Mt5.ORDER_TYPE_BUY, "BUY"),
452
+ "SMKT": (Mt5.ORDER_TYPE_SELL, "SELL"),
453
+ "BLMT": (Mt5.ORDER_TYPE_BUY_LIMIT, "BUY_LIMIT"),
454
+ "SLMT": (Mt5.ORDER_TYPE_SELL_LIMIT, "SELL_LIMIT"),
455
+ "BSTP": (Mt5.ORDER_TYPE_BUY_STOP, "BUY_STOP"),
456
+ "SSTP": (Mt5.ORDER_TYPE_SELL_STOP, "SELL_STOP"),
457
+ "BSTPLMT": (Mt5.ORDER_TYPE_BUY_STOP_LIMIT, "BUY_STOP_LIMIT"),
458
+ "SSTPLMT": (Mt5.ORDER_TYPE_SELL_STOP_LIMIT, "SELL_STOP_LIMIT"),
459
+ }
460
+
461
+ def open_position(
462
+ self,
463
+ action: Buys | Sells,
464
+ price: Optional[float] = None,
465
+ stoplimit: Optional[float] = None,
466
+ id: Optional[int] = None,
467
+ mm: bool = True,
468
+ trail: bool = True,
469
+ comment: Optional[str] = None,
470
+ symbol: Optional[str] = None,
471
+ volume: Optional[float] = None,
472
+ sl: Optional[float] = None,
473
+ tp: Optional[float] = None,
474
+ ) -> bool:
475
+ """Opens a Buy or Sell position (Market or Pending).
476
+
477
+ Args:
478
+ action (str): (`'BMKT'`, `'SMKT'`) for Market orders
479
+ or (`'BLMT', 'SLMT', 'BSTP', 'SSTP', 'BSTPLMT', 'SSTPLMT'`) for pending orders
480
+ price (float): The price at which to open an order
481
+ stoplimit (float): A price a pending Limit order is set at
482
+ when the price reaches the 'price' value (this condition is mandatory).
483
+ The pending order is not passed to the trading system until that moment
484
+ id (int): The strategy id or expert Id
485
+ mm (bool): Weither to put stop loss and tp or not
486
+ trail (bool): Weither to trail the stop loss or not
487
+ comment (str): The comment for the closing position
488
+ symbol (str): The symbol to trade
489
+ volume (float): The volume (lot) to trade
490
+ sl (float): The stop loss price
491
+ tp (float): The take profit price
492
+ """
493
+ is_buy = action.startswith("B")
494
+ symbol = symbol or self.symbol
495
+ expert_id = id if id is not None else self.expert_id
496
+ point = client.symbol_info(symbol).point
497
+ tick = client.symbol_info_tick(symbol)
498
+
499
+ req_price = None
500
+ if "MKT" in action:
501
+ req_price = tick.bid if is_buy else tick.ask
502
+ else:
503
+ if price is None:
504
+ raise ValueError(f"Price is required for pending order: {action}")
505
+ req_price = price
506
+
507
+ mm_price = req_price
508
+ if "TPLMT" in action:
509
+ if stoplimit is None:
510
+ raise ValueError(f"StopLimit price required for {action}")
511
+ if (is_buy and stoplimit > req_price) or (
512
+ not is_buy and stoplimit < req_price
513
+ ):
514
+ raise ValueError("Invalid StopLimit relationship to Price.")
515
+ mm_price = stoplimit
516
+
517
+ order_type, _ = self._order_type()[action]
518
+ trade_action = (
519
+ Mt5.TRADE_ACTION_DEAL if "MKT" in action else Mt5.TRADE_ACTION_PENDING
520
+ )
521
+ request = {
522
+ "action": trade_action,
523
+ "symbol": symbol,
524
+ "volume": float(volume or self.rm.get_lot()),
525
+ "type": order_type,
526
+ "price": req_price,
527
+ "deviation": self.rm.get_deviation(),
528
+ "magic": expert_id,
529
+ "comment": comment or f"@{self.expert_name}",
530
+ "type_time": Mt5.ORDER_TIME_GTC,
531
+ "type_filling": Mt5.ORDER_FILLING_FOK,
532
+ }
533
+
534
+ if "TPLMT" in action:
535
+ request["stoplimit"] = stoplimit
536
+
537
+ if mm:
538
+ direction = 1 if is_buy else -1
539
+ request["sl"] = sl or (
540
+ mm_price - (direction * self.rm.get_stop_loss() * point)
541
+ )
542
+ request["tp"] = tp or (
543
+ mm_price + (direction * self.rm.get_take_profit() * point)
544
+ )
545
+
546
+ self.break_even(mm=mm, id=expert_id, trail=trail)
547
+
548
+ if self.check(comment):
549
+ final_price = stoplimit if "TPLMT" in action else req_price
550
+ return self.request_result(final_price, request, action)
551
+
552
+ return False
553
+
554
+ def open_buy_position(self, **kwargs):
555
+ """
556
+ Open a buy position or order.
557
+
558
+ See Trade.open_position for the ``kwargs`` parameters.
559
+ """
560
+ return self.open_position(action=kwargs.pop("action", "BMKT"), **kwargs)
561
+
562
+
563
+ def open_sell_position(self, **kwargs):
564
+ """
565
+ Open a sell position or order.
566
+
567
+ See Trade.open_position for the ``kwargs`` parameters.
568
+ """
569
+ return self.open_position(action=kwargs.pop("action", "SMKT"), **kwargs)
570
+
571
+ def check(self, comment):
572
+ """
573
+ Verify if all conditions for taking a position are valide,
574
+ These conditions are based on the Maximum risk ,daily risk,
575
+ the starting, the finishing, and ending trading time.
576
+
577
+ Args:
578
+ comment (str): The comment for the closing position
579
+ """
580
+
581
+ def _check(txt: str = ""):
582
+ if (
583
+ self.positive_profit(id=self.expert_id)
584
+ or self.get_current_positions() is None
585
+ ):
586
+ self.close_positions(position_type="all")
587
+ LOGGER.info(txt)
588
+ self.statistics(save=True)
589
+
590
+ if self.days_end():
591
+ LOGGER.warning(f"End of the trading Day, SYMBOL={self.symbol}")
592
+ return False
593
+ elif not self.trading_time():
594
+ LOGGER.warning(f"Not Trading time, SYMBOL={self.symbol}")
595
+ return False
596
+ elif not self.rm.is_risk_ok():
597
+ LOGGER.warning(f"Account Risk not allowed, SYMBOL={self.symbol}")
598
+ _check(comment)
599
+ return False
600
+ elif self.is_max_trades_reached():
601
+ LOGGER.warning(f"Maximum trades reached for Today, SYMBOL={self.symbol}")
602
+ return False
603
+ elif self.profit_target():
604
+ _check(f"Profit target Reached !!! SYMBOL={self.symbol}")
605
+ return True
606
+
607
+ def request_result(self, price: float, request: Dict[str, Any], type: Buys | Sells):
608
+ """
609
+ Check if a trading order has been sent correctly
610
+
611
+ Args:
612
+ price (float): Price for opening the position
613
+ request (Dict[str, Any]): A trade request to sent to Mt5.order_sent()
614
+ all detail in request can be found here https://www.mql5.com/en/docs/python_metatrader5/mt5ordersend_py
615
+
616
+ type (str): The type of the order `(BMKT, SMKT, BLMT, SLMT, BSTP, SSTP, BSTPLMT, SSTPLMT)`
617
+ """
618
+ # Send a trading request
619
+ # Check the execution result
620
+ pos = self._order_type()[type][1]
621
+ addtionnal = f", SYMBOL={self.symbol}"
622
+ result = None
623
+ try:
624
+ client.order_check(request)
625
+ result = client.order_send(request)
626
+ except Exception as e:
627
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
628
+ LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
629
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
630
+ if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
631
+ for fill in FILLING_TYPE:
632
+ request["type_filling"] = fill
633
+ result = client.order_send(request)
634
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
635
+ break
636
+ elif result.retcode == Mt5.TRADE_RETCODE_INVALID_VOLUME: # 10014
637
+ new_volume = int(request["volume"])
638
+ if new_volume >= 1:
639
+ request["volume"] = new_volume
640
+ result = client.order_send(request)
641
+ elif result.retcode not in self._retcodes:
642
+ self._retcodes.append(result.retcode)
643
+ msg = trade_retcode_message(result.retcode)
644
+ LOGGER.error(
645
+ f"Trade Order Request, RETCODE={result.retcode}: {msg}{addtionnal}"
646
+ )
647
+ elif result.retcode in [
648
+ Mt5.TRADE_RETCODE_CONNECTION,
649
+ Mt5.TRADE_RETCODE_TIMEOUT,
650
+ ]:
651
+ tries = 0
652
+ while result.retcode != Mt5.TRADE_RETCODE_DONE and tries < 5:
653
+ try:
654
+ client.order_check(request)
655
+ result = client.order_send(request)
656
+ except Exception as e:
657
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
658
+ LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
659
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
660
+ break
661
+ tries += 1
662
+ # Print the result
663
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
664
+ msg = trade_retcode_message(result.retcode)
665
+ LOGGER.info(f"Trade Order {msg}{addtionnal}")
666
+ if type != "BMKT" or type != "SMKT":
667
+ self.opened_orders.append(result.order)
668
+ long_msg = (
669
+ f"1. {pos} Order #{result.order} Sent, Symbol: {self.symbol}, Price: @{round(price, 5)}, "
670
+ f"Lot(s): {result.volume}, Sl: {self.rm.get_stop_loss()}, "
671
+ f"Tp: {self.rm.get_take_profit()}"
672
+ )
673
+ LOGGER.info(long_msg)
674
+ if type == "BMKT" or type == "SMKT":
675
+ self.opened_positions.append(result.order)
676
+ positions = self.account.get_positions(symbol=self.symbol)
677
+ if positions is not None:
678
+ for position in positions:
679
+ if position.ticket == result.order:
680
+ if position.type == 0:
681
+ order_type = "BUY"
682
+ self.buy_positions.append(position.ticket)
683
+ else:
684
+ order_type = "SELL"
685
+ self.sell_positions.append(position.ticket)
686
+ profit = round(client.account_info().profit, 5)
687
+ order_info = (
688
+ f"2. {order_type} Position Opened, Symbol: {self.symbol}, Price: @{round(position.price_open, 5)}, "
689
+ f"Sl: @{round(position.sl, 5)} Tp: @{round(position.tp, 5)}"
690
+ )
691
+ LOGGER.info(order_info)
692
+ pos_info = (
693
+ f"3. [OPEN POSITIONS ON {self.symbol} = {len(positions)}, ACCOUNT OPEN PnL = {profit} "
694
+ f"{client.account_info().currency}]\n"
695
+ )
696
+ LOGGER.info(pos_info)
697
+ return True
698
+ else:
699
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
700
+ LOGGER.error(
701
+ f"Unable to Open Position, RETCODE={result.retcode}: {msg}{addtionnal}"
702
+ )
703
+ return False
704
+
705
+ def get_filtered_tickets(
706
+ self, id: Optional[int] = None, filter_type: Optional[str] = None, th=None
707
+ ) -> List[int] | None:
708
+ """
709
+ Get tickets for positions or orders based on filters.
710
+
711
+ Args:
712
+ id (int): The strategy id or expert Id
713
+ filter_type (str): Filter type to apply on the tickets,
714
+ - `orders` are current open orders
715
+ - `buy_stops` are current buy stop orders
716
+ - `sell_stops` are current sell stop orders
717
+ - `buy_limits` are current buy limit orders
718
+ - `sell_limits` are current sell limit orders
719
+ - `buy_stop_limits` are current buy stop limit orders
720
+ - `sell_stop_limits` are current sell stop limit orders
721
+ - `positions` are all current open positions
722
+ - `buys` and `sells` are current buy or sell open positions
723
+ - `profitables` are current open position that have a profit greater than a threshold
724
+ - `losings` are current open position that have a negative profit
725
+ th (bool): the minimum treshold for winning position
726
+ (only relevant when filter_type is 'profitables')
727
+
728
+ Returns:
729
+ List[int] | None: A list of filtered tickets
730
+ or None if no tickets match the criteria.
731
+ """
732
+ Id = id if id is not None else self.expert_id
733
+ POSITIONS = ["positions", "buys", "sells", "profitables", "losings"]
734
+
735
+ if filter_type not in POSITIONS:
736
+ items = self.account.get_orders(symbol=self.symbol)
737
+ else:
738
+ items = self.account.get_positions(symbol=self.symbol)
739
+
740
+ filtered_tickets = []
741
+
742
+ if items is None:
743
+ return []
744
+ for item in items:
745
+ if item.magic == Id:
746
+ if filter_type == "buys" and item.type != 0:
747
+ continue
748
+ if filter_type == "sells" and item.type != 1:
749
+ continue
750
+ if filter_type == "losings" and item.profit > 0:
751
+ continue
752
+ if filter_type == "profitables" and not self.win_trade(item, th=th):
753
+ continue
754
+ if (
755
+ filter_type == "buy_stops"
756
+ and item.type != self._order_type()["BSTP"][0]
757
+ ):
758
+ continue
759
+ if (
760
+ filter_type == "sell_stops"
761
+ and item.type != self._order_type()["SSTP"][0]
762
+ ):
763
+ continue
764
+ if (
765
+ filter_type == "buy_limits"
766
+ and item.type != self._order_type()["BLMT"][0]
767
+ ):
768
+ continue
769
+ if (
770
+ filter_type == "sell_limits"
771
+ and item.type != self._order_type()["SLMT"][0]
772
+ ):
773
+ continue
774
+ if (
775
+ filter_type == "buy_stop_limits"
776
+ and item.type != self._order_type()["BSTPLMT"][0]
777
+ ):
778
+ continue
779
+ if (
780
+ filter_type == "sell_stop_limits"
781
+ and item.type != self._order_type()["SSTPLMT"][0]
782
+ ):
783
+ continue
784
+ filtered_tickets.append(item.ticket)
785
+ return filtered_tickets
786
+
787
+ def get_current_orders(self, id: Optional[int] = None) -> List[int] | None:
788
+ return self.get_filtered_tickets(id=id, filter_type="orders")
789
+
790
+ def get_current_buy_stops(self, id: Optional[int] = None) -> List[int] | None:
791
+ return self.get_filtered_tickets(id=id, filter_type="buy_stops")
792
+
793
+ def get_current_sell_stops(self, id: Optional[int] = None) -> List[int] | None:
794
+ return self.get_filtered_tickets(id=id, filter_type="sell_stops")
795
+
796
+ def get_current_buy_limits(self, id: Optional[int] = None) -> List[int] | None:
797
+ return self.get_filtered_tickets(id=id, filter_type="buy_limits")
798
+
799
+ def get_current_sell_limits(self, id: Optional[int] = None) -> List[int] | None:
800
+ return self.get_filtered_tickets(id=id, filter_type="sell_limits")
801
+
802
+ def get_current_buy_stop_limits(self, id: Optional[int] = None) -> List[int] | None:
803
+ return self.get_filtered_tickets(id=id, filter_type="buy_stop_limits")
804
+
805
+ def get_current_sell_stop_limits(
806
+ self, id: Optional[int] = None
807
+ ) -> List[int] | None:
808
+ return self.get_filtered_tickets(id=id, filter_type="sell_stop_limits")
809
+
810
+ def get_current_positions(self, id: Optional[int] = None) -> List[int] | None:
811
+ return self.get_filtered_tickets(id=id, filter_type="positions")
812
+
813
+ def get_current_profitables(
814
+ self, id: Optional[int] = None, th=None
815
+ ) -> List[int] | None:
816
+ return self.get_filtered_tickets(id=id, filter_type="profitables", th=th)
817
+
818
+ def get_current_losings(self, id: Optional[int] = None) -> List[int] | None:
819
+ return self.get_filtered_tickets(id=id, filter_type="losings")
820
+
821
+ def get_current_buys(self, id: Optional[int] = None) -> List[int] | None:
822
+ return self.get_filtered_tickets(id=id, filter_type="buys")
823
+
824
+ def get_current_sells(self, id: Optional[int] = None) -> List[int] | None:
825
+ return self.get_filtered_tickets(id=id, filter_type="sells")
826
+
827
+ def positive_profit(
828
+ self, th: Optional[float] = None, id: Optional[int] = None, account: bool = True
829
+ ) -> bool:
830
+ """
831
+ Check is the total profit on current open positions
832
+ Is greater than a minimum profit express as percentage
833
+ of the profit target.
834
+
835
+ Args:
836
+ th (float): The minimum profit target on current positions
837
+ id (int): The strategy id or expert Id
838
+ account (bool): Weither to check positions on the account or on the symbol
839
+ """
840
+ if account and id is None:
841
+ # All open positions no matter the symbol or strategy or expert
842
+ positions = self.account.get_positions()
843
+ elif account and id is not None:
844
+ # All open positions for a specific strategy or expert no matter the symbol
845
+ positions = self.account.get_positions()
846
+ if positions is not None:
847
+ positions = [position for position in positions if position.magic == id]
848
+ elif not account and id is None:
849
+ # All open positions for the current symbol no matter the strategy or expert
850
+ positions = self.account.get_positions(symbol=self.symbol)
851
+ elif not account and id is not None:
852
+ # All open positions for the current symbol and a specific strategy or expert
853
+ positions = self.account.get_positions(symbol=self.symbol)
854
+ if positions is not None:
855
+ positions = [position for position in positions if position.magic == id]
856
+
857
+ if positions is not None:
858
+ profit = 0.0
859
+ balance = client.account_info().balance
860
+ target = round((balance * self.target) / 100, 2)
861
+ for position in positions:
862
+ profit += position.profit
863
+ fees = self.get_average_fees()
864
+ current_profit = profit + fees
865
+ th_profit = (target * th) / 100 if th is not None else (target * 0.01)
866
+ return current_profit >= th_profit
867
+ return False
868
+
869
+ def _get_trail_after_points(self, trail_after_points: int | str) -> int:
870
+ if isinstance(trail_after_points, str):
871
+ if trail_after_points == "SL":
872
+ return self.rm.get_stop_loss()
873
+ elif trail_after_points == "TP":
874
+ return self.rm.get_take_profit()
875
+ elif trail_after_points == "BE":
876
+ return self.rm.get_break_even()
877
+ # TODO: Add other combinations (e.g. "SL+TP", "SL+BE", "TP+BE", "SL*N", etc.)
878
+ return trail_after_points
879
+
880
+ def break_even(
881
+ self,
882
+ mm=True,
883
+ id: Optional[int] = None,
884
+ trail: Optional[bool] = True,
885
+ stop_trail: int | str = None,
886
+ trail_after_points: int | str = None,
887
+ be_plus_points: Optional[int] = None,
888
+ ):
889
+ """
890
+ Manages the break-even level of a trading position.
891
+
892
+ This function checks whether it is time to set a break-even stop loss for an open position.
893
+ If the break-even level is already set, it monitors price movement and updates the stop loss
894
+ accordingly if the `trail` parameter is enabled.
895
+
896
+ When `trail` is enabled, the function dynamically adjusts the break-even level based on the
897
+ `trail_after_points` and `stop_trail` parameters.
898
+
899
+ Args:
900
+ id (int): The strategy ID or expert ID.
901
+ mm (bool): Whether to manage the position or not.
902
+ trail (bool): Whether to trail the stop loss or not.
903
+ stop_trail (int): Number of points to trail the stop loss by.
904
+ It represent the distance from the current price to the stop loss.
905
+ trail_after_points (int, str): Number of points in profit
906
+ from where the strategy will start to trail the stop loss.
907
+ If set to str, it must be one of the following values:
908
+ - 'SL' to trail the stop loss after the profit reaches the stop loss level in points.
909
+ - 'TP' to trail the stop loss after the profit reaches the take profit level in points.
910
+ - 'BE' to trail the stop loss after the profit reaches the break-even level in points.
911
+ be_plus_points (int): Number of points to add to the break-even level.
912
+ Represents the minimum profit to secure.
913
+ """
914
+
915
+ if not mm:
916
+ return False
917
+
918
+ Id = id if id is not None else self.expert_id
919
+ positions = self.account.get_positions(symbol=self.symbol)
920
+ be = self.rm.get_break_even()
921
+ if trail_after_points is not None:
922
+ if isinstance(trail_after_points, int):
923
+ assert trail_after_points > be, (
924
+ "trail_after_points must be greater than break even or set to None"
925
+ )
926
+ trail_after_points = self._get_trail_after_points(trail_after_points)
927
+
928
+ if not positions:
929
+ return False
930
+
931
+ for position in positions:
932
+ if position.magic == Id:
933
+ symbol_info = client.symbol_info(self.symbol)
934
+
935
+ point = symbol_info.point
936
+ digits = symbol_info.digits
937
+
938
+ points = position.profit * (
939
+ symbol_info.trade_tick_size
940
+ / symbol_info.trade_tick_value
941
+ / position.volume
942
+ )
943
+ break_even = float(points / point) >= be
944
+ if not break_even:
945
+ continue
946
+ # Check if break-even has already been set for this position
947
+ if position.ticket not in self.break_even_status:
948
+ price = None
949
+ if be_plus_points is not None:
950
+ price = position.price_open + (be_plus_points * point)
951
+ self.set_break_even(position, be, price=price)
952
+ self.break_even_status.append(position.ticket)
953
+ self.break_even_points[position.ticket] = be
954
+ else:
955
+ # Skip this if the trail is not set to True
956
+ if not trail:
957
+ continue
958
+ # Check if the price has moved favorably
959
+ new_be = (
960
+ round(be * 0.10) if be_plus_points is None else be_plus_points
961
+ )
962
+ if trail_after_points is not None:
963
+ if position.ticket not in self.trail_after_points:
964
+ # This ensures that the position rich the minimum points required
965
+ # before the trail can be set
966
+ new_be = trail_after_points - be
967
+ self.trail_after_points.append(position.ticket)
968
+ new_be_points = self.break_even_points[position.ticket] + new_be
969
+ favorable_move = float(points / point) >= new_be_points
970
+ if not favorable_move:
971
+ continue
972
+ # This allows the position to go to take profit in case of a swing trade
973
+ # If is a scalping position, we can set the stop_trail close to the current price.
974
+ trail_points = (
975
+ round(be * 0.50) if stop_trail is None else stop_trail
976
+ )
977
+ # Calculate the new break-even level and price
978
+ if position.type == 0:
979
+ # This level validate the favorable move of the price
980
+ new_level = round(
981
+ position.price_open + (new_be_points * point),
982
+ digits,
983
+ )
984
+ # This price is set away from the current price by the trail_points
985
+ new_price = round(
986
+ position.price_current - (trail_points * point),
987
+ digits,
988
+ )
989
+ if new_price < position.sl:
990
+ new_price = position.sl
991
+ elif position.type == 1:
992
+ new_level = round(
993
+ position.price_open - (new_be_points * point),
994
+ digits,
995
+ )
996
+ new_price = round(
997
+ position.price_current + (trail_points * point),
998
+ digits,
999
+ )
1000
+ if new_price > position.sl:
1001
+ new_price = position.sl
1002
+ return self.set_break_even(
1003
+ position, be, price=new_price, level=new_level
1004
+ )
1005
+ return False
1006
+
1007
+ def set_break_even(
1008
+ self,
1009
+ position: TradePosition,
1010
+ be: int,
1011
+ price: Optional[float] = None,
1012
+ level: Optional[float] = None,
1013
+ ):
1014
+ """
1015
+ Sets the break-even level for a given trading position.
1016
+
1017
+ Args:
1018
+ position (TradePosition): The trading position for which the break-even is to be set.
1019
+ This is the value return by `mt5.positions_get()`.
1020
+ be (int): The break-even level in points.
1021
+ level (float): The break-even level in price, if set to None , it will be calated automaticaly.
1022
+ price (float): The break-even price, if set to None , it will be calated automaticaly.
1023
+ """
1024
+
1025
+ symbol_info = client.symbol_info(self.symbol)
1026
+ average_fee = abs(self.get_average_fees())
1027
+ point_value = self.rm.currency_risk().get("trade_profit", 1)
1028
+ fees_points = round((average_fee / point_value), 3) if point_value != 0 else 0
1029
+
1030
+ is_buy = position.type == 0
1031
+ direction = 1 if is_buy else -1
1032
+ if not position.profit > 0:
1033
+ return False
1034
+ calc_be_level = position.price_open + (direction * be * symbol_info.point)
1035
+ calc_be_price = position.price_open + (
1036
+ direction * (fees_points + symbol_info.spread) * symbol_info.point
1037
+ )
1038
+ if price is None:
1039
+ be_price = calc_be_price
1040
+ else:
1041
+ be_price = (
1042
+ max(price, calc_be_price) if is_buy else min(price, calc_be_price)
1043
+ )
1044
+ be_level = calc_be_level if level is None else level
1045
+ tick = client.symbol_info_tick(self.symbol)
1046
+ send_request = (tick.ask > be_level) if is_buy else (tick.bid < be_level)
1047
+
1048
+ if send_request:
1049
+ request = {
1050
+ "action": Mt5.TRADE_ACTION_SLTP,
1051
+ "position": position.ticket,
1052
+ "sl": round(be_price, symbol_info.digits),
1053
+ "tp": position.tp,
1054
+ }
1055
+ return self.break_even_request(
1056
+ position.ticket, round(be_price, symbol_info.digits), request
1057
+ )
1058
+ return False
1059
+
1060
+ def break_even_request(self, tiket, price, request):
1061
+ """
1062
+ Send a request to set the stop loss to break even for a given trading position.
1063
+
1064
+ Args:
1065
+ tiket (int): The ticket number of the trading position.
1066
+ price (float): The price at which the stop loss is to be set.
1067
+ request (dict): The request to set the stop loss to break even.
1068
+ """
1069
+ addtionnal = f", SYMBOL={self.symbol}"
1070
+ result = None
1071
+ try:
1072
+ client.order_check(request)
1073
+ result = client.order_send(request)
1074
+ except Exception as e:
1075
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1076
+ LOGGER.error(f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}")
1077
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
1078
+ msg = trade_retcode_message(result.retcode)
1079
+ if result.retcode != Mt5.TRADE_RETCODE_NO_CHANGES:
1080
+ LOGGER.error(
1081
+ f"Break-Even Order Request, Position: #{tiket}, RETCODE={result.retcode}: {msg}{addtionnal}"
1082
+ )
1083
+ tries = 0
1084
+ while result.retcode != Mt5.TRADE_RETCODE_DONE and tries < 10:
1085
+ if result.retcode == Mt5.TRADE_RETCODE_NO_CHANGES:
1086
+ break
1087
+ else:
1088
+ try:
1089
+ client.order_check(request)
1090
+ result = client.order_send(request)
1091
+ except Exception as e:
1092
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1093
+ LOGGER.error(
1094
+ f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}"
1095
+ )
1096
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1097
+ break
1098
+ tries += 1
1099
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1100
+ msg = trade_retcode_message(result.retcode)
1101
+ LOGGER.info(f"Break-Even Order {msg}{addtionnal}")
1102
+ info = f"Stop loss set to Break-even, Position: #{tiket}, Symbol: {self.symbol}, Price: @{round(price, 5)}"
1103
+ LOGGER.info(info)
1104
+ self.break_even_status.append(tiket)
1105
+ return True
1106
+ return False
1107
+
1108
+ def win_trade(self, position: TradePosition, th: Optional[int] = None) -> bool:
1109
+ """
1110
+ Determines if a position has met the minimum 'win' threshold in points.
1111
+ """
1112
+ points = self._convert_profit_to_points(position)
1113
+ if th is not None:
1114
+ win_threshold = th
1115
+ else:
1116
+ win_threshold = self._calculate_dynamic_threshold()
1117
+
1118
+ is_profitable = points >= win_threshold
1119
+ not_processed = position.ticket not in self.break_even_status
1120
+
1121
+ return is_profitable and not_processed
1122
+
1123
+ def _convert_profit_to_points(self, position: TradePosition) -> float:
1124
+ info = client.symbol_info(self.symbol)
1125
+ if position.volume == 0 or info.trade_tick_value == 0:
1126
+ return 0.0
1127
+
1128
+ raw_points = position.profit * (
1129
+ info.trade_tick_size / info.trade_tick_value / position.volume
1130
+ )
1131
+ return raw_points / info.point
1132
+
1133
+ def _calculate_dynamic_threshold(self) -> int:
1134
+ try:
1135
+ avg_fee = abs(self.get_average_fees())
1136
+ profit_per_tick = self.rm.currency_risk().get("trade_profit", 1)
1137
+
1138
+ min_be = round(avg_fee / profit_per_tick) + 2
1139
+ except (ZeroDivisionError, KeyError):
1140
+ min_be = client.symbol_info(self.symbol).spread
1141
+
1142
+ be_level = self.rm.get_break_even()
1143
+ return max(min_be, round(0.1 * be_level))
1144
+
1145
+ def profit_target(self) -> bool:
1146
+ """Checks if the net profit for today's deals has reached the percentage target."""
1147
+ from bbstrader.api import trade_object_to_df
1148
+
1149
+ balance = client.account_info().balance
1150
+ target_amount = (balance * self.target) / 100
1151
+
1152
+ opened_positions = self.get_today_deals(group=self.symbol)
1153
+ history_df = trade_object_to_df(opened_positions)
1154
+
1155
+ if history_df.empty:
1156
+ return False
1157
+ net_profit = history_df[["profit", "commission", "swap", "fee"]].sum().sum()
1158
+
1159
+ return net_profit >= target_amount
1160
+
1161
+ def close_request(self, request: dict, type: str):
1162
+ """
1163
+ Close a trading order or position
1164
+
1165
+ Args:
1166
+ request (dict): The request to close a trading order or position
1167
+ type (str): Type of the request ('order', 'position')
1168
+ """
1169
+ ticket = request[type]
1170
+ addtionnal = f", SYMBOL={self.symbol}"
1171
+ result = None
1172
+ try:
1173
+ client.order_check(request)
1174
+ result = client.order_send(request)
1175
+ except Exception as e:
1176
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1177
+ LOGGER.error(
1178
+ f"Closing {type.capitalize()} Request, RETCODE={msg}{addtionnal}, Error: {e}"
1179
+ )
1180
+
1181
+ if result and result.retcode != Mt5.TRADE_RETCODE_DONE:
1182
+ if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
1183
+ for fill in FILLING_TYPE:
1184
+ request["type_filling"] = fill
1185
+ result = client.order_send(request)
1186
+ if result.retcode == Mt5.TRADE_RETCODE_DONE:
1187
+ break
1188
+ elif result.retcode not in self._retcodes:
1189
+ self._retcodes.append(result.retcode)
1190
+ msg = trade_retcode_message(result.retcode)
1191
+ LOGGER.error(
1192
+ f"Closing Order Request, {type.capitalize()}: #{ticket}, "
1193
+ f"RETCODE={result.retcode}: {msg}{addtionnal}"
1194
+ )
1195
+ else:
1196
+ tries = 0
1197
+ while result.retcode != Mt5.TRADE_RETCODE_DONE and tries < 5:
1198
+ try:
1199
+ client.order_check(request)
1200
+ result = client.order_send(request)
1201
+ except Exception as e:
1202
+ msg = trade_retcode_message(result.retcode) if result else "N/A"
1203
+ LOGGER.error(
1204
+ f"Closing {type.capitalize()} Request, {msg}{addtionnal}, Error: {e}"
1205
+ )
1206
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1207
+ break
1208
+ tries += 1
1209
+ if result and result.retcode == Mt5.TRADE_RETCODE_DONE:
1210
+ msg = trade_retcode_message(result.retcode)
1211
+ LOGGER.info(f"Closing Order {msg}{addtionnal}")
1212
+ info = (
1213
+ f"{type.capitalize()} #{ticket} closed, Symbol: {self.symbol},"
1214
+ f"Price: @{round(request.get('price', 0.0), 5)}"
1215
+ )
1216
+ LOGGER.info(info)
1217
+ return True
1218
+ else:
1219
+ return False
1220
+
1221
+ def modify_order(
1222
+ self,
1223
+ ticket: int,
1224
+ price: Optional[float] = None,
1225
+ stoplimit: Optional[float] = None,
1226
+ sl: Optional[float] = None,
1227
+ tp: Optional[float] = None,
1228
+ ):
1229
+ """
1230
+ Modify an open order by it ticket
1231
+
1232
+ Args:
1233
+ ticket (int): Order ticket to modify (e.g TradeOrder.ticket)
1234
+ price (float): The price at which to modify the order
1235
+ stoplimit (float): A price a pending Limit order is set at
1236
+ when the price reaches the 'price' value (this condition is mandatory).
1237
+ The pending order is not passed to the trading system until that moment
1238
+ sl (float): The stop loss in points
1239
+ tp (float): The take profit in points
1240
+ """
1241
+ orders = self.account.get_orders(ticket=ticket) or []
1242
+ if len(orders) == 0:
1243
+ LOGGER.error(
1244
+ f"Order #{ticket} not found, SYMBOL={self.symbol}, PRICE={round(price, 5) if price else 'N/A'}"
1245
+ )
1246
+ return False
1247
+ order = orders[0]
1248
+ request = {
1249
+ "action": Mt5.TRADE_ACTION_MODIFY,
1250
+ "order": ticket,
1251
+ "price": price or order.price_open,
1252
+ "sl": sl or order.sl,
1253
+ "tp": tp or order.tp,
1254
+ "stoplimit": stoplimit or order.price_stoplimit,
1255
+ }
1256
+ client.order_check(request)
1257
+ result = client.order_send(request)
1258
+ if result.retcode == Mt5.TRADE_RETCODE_DONE:
1259
+ LOGGER.info(
1260
+ f"Order #{ticket} modified, SYMBOL={self.symbol}, PRICE={round(request['price'], 5)},"
1261
+ f"SL={round(request['sl'], 5)}, TP={round(request['tp'], 5)}, STOP_LIMIT={round(request['stoplimit'], 5)}"
1262
+ )
1263
+ return True
1264
+ else:
1265
+ msg = trade_retcode_message(result.retcode)
1266
+ LOGGER.error(
1267
+ f"Unable to modify Order #{ticket}, RETCODE={result.retcode}: {msg}, SYMBOL={self.symbol}"
1268
+ )
1269
+ return False
1270
+
1271
+ def close_order(
1272
+ self, ticket: int, id: Optional[int] = None, comment: Optional[str] = None
1273
+ ):
1274
+ """
1275
+ Close an open order by it ticket
1276
+
1277
+ Args:
1278
+ ticket (int): Order ticket to close (e.g TradeOrder.ticket)
1279
+ id (int): The unique ID of the Expert or Strategy
1280
+ comment (str): Comment for the closing position
1281
+
1282
+ Returns:
1283
+ - True if order closed, False otherwise
1284
+ """
1285
+ request = {
1286
+ "action": Mt5.TRADE_ACTION_REMOVE,
1287
+ "symbol": self.symbol,
1288
+ "order": ticket,
1289
+ "magic": id if id is not None else self.expert_id,
1290
+ "comment": f"@{self.expert_name}" if comment is None else comment,
1291
+ }
1292
+ return self.close_request(request, type="order")
1293
+
1294
+ def close_position(
1295
+ self,
1296
+ ticket: int,
1297
+ id: Optional[int] = None,
1298
+ pct: Optional[float] = 1.0,
1299
+ comment: Optional[str] = None,
1300
+ symbol: Optional[str] = None,
1301
+ ) -> bool:
1302
+ """
1303
+ Close an open position by it ticket
1304
+
1305
+ Args:
1306
+ ticket (int): Positon ticket to close (e.g TradePosition.ticket)
1307
+ id (int): The unique ID of the Expert or Strategy
1308
+ pct (float): Percentage of the position to close
1309
+ comment (str): Comment for the closing position
1310
+
1311
+ Returns:
1312
+ - True if position closed, False otherwise
1313
+ """
1314
+ symbol = symbol or self.symbol
1315
+ Id = id if id is not None else self.expert_id
1316
+ positions = self.account.get_positions(ticket=ticket)
1317
+ deviation = self.rm.get_deviation()
1318
+ if positions is not None and len(positions) == 1:
1319
+ position = positions[0]
1320
+ if position.ticket == ticket and position.magic == Id:
1321
+ buy = position.type == 0
1322
+ request = {
1323
+ "action": Mt5.TRADE_ACTION_DEAL,
1324
+ "symbol": symbol,
1325
+ "volume": (position.volume * pct),
1326
+ "type": Mt5.ORDER_TYPE_SELL if buy else Mt5.ORDER_TYPE_BUY,
1327
+ "position": ticket,
1328
+ "price": position.price_current,
1329
+ "deviation": deviation,
1330
+ "comment": f"@{self.expert_name}" if comment is None else comment,
1331
+ "type_time": Mt5.ORDER_TIME_GTC,
1332
+ "type_filling": Mt5.ORDER_FILLING_FOK,
1333
+ }
1334
+ return self.close_request(request, type="position")
1335
+ return False
1336
+
1337
+ def bulk_close(
1338
+ self,
1339
+ tickets: List,
1340
+ tikets_type: Literal["positions", "orders"],
1341
+ close_func: Callable,
1342
+ order_type: str,
1343
+ id: Optional[int] = None,
1344
+ comment: Optional[str] = None,
1345
+ ):
1346
+ """
1347
+ Close multiple orders or positions at once.
1348
+
1349
+ Args:
1350
+ tickets (List): List of tickets to close
1351
+ tikets_type (str): Type of tickets to close ('positions', 'orders')
1352
+ close_func (Callable): The function to close the tickets
1353
+ order_type (str): Type of orders or positions to close
1354
+ id (int): The unique ID of the Expert or Strategy
1355
+ comment (str): Comment for the closing position
1356
+ """
1357
+ if order_type == "all":
1358
+ order_type = "open"
1359
+
1360
+ if not tickets:
1361
+ LOGGER.info(
1362
+ f"No {order_type.upper()} {tikets_type.upper()} to close, SYMBOL={self.symbol}."
1363
+ )
1364
+ return
1365
+ failed_tickets = []
1366
+ with ThreadPoolExecutor(max_workers=min(len(tickets), 20)) as executor:
1367
+ future_to_ticket = {
1368
+ executor.submit(close_func, ticket, id=id, comment=comment): ticket
1369
+ for ticket in tickets
1370
+ }
1371
+ for future in as_completed(future_to_ticket):
1372
+ ticket = future_to_ticket[future]
1373
+ try:
1374
+ success = future.result()
1375
+ if not success:
1376
+ failed_tickets.append(ticket)
1377
+ except Exception as exc:
1378
+ LOGGER.error(f"Ticket {ticket} generated an exception: {exc}")
1379
+ failed_tickets.append(ticket)
1380
+ if not failed_tickets:
1381
+ LOGGER.info(
1382
+ f"ALL {order_type.upper()} {tikets_type.upper()} closed, SYMBOL={self.symbol}."
1383
+ )
1384
+ else:
1385
+ LOGGER.info(
1386
+ f"{len(failed_tickets)}/{len(tickets)} {order_type.upper()} {tikets_type.upper()} NOT closed, SYMBOL={self.symbol}"
1387
+ )
1388
+
1389
+ def close_orders(
1390
+ self,
1391
+ order_type: Orders,
1392
+ id: Optional[int] = None,
1393
+ comment: Optional[str] = None,
1394
+ ):
1395
+ """
1396
+ Args:
1397
+ order_type (str): Type of orders to close
1398
+ ('all', 'buy_stops', 'sell_stops', 'buy_limits', 'sell_limits', 'buy_stop_limits', 'sell_stop_limits')
1399
+ id (int): The unique ID of the Expert or Strategy
1400
+ comment (str): Comment for the closing position
1401
+ """
1402
+ id = id if id is not None else self.expert_id
1403
+ if order_type == "all":
1404
+ orders = self.get_current_orders(id=id)
1405
+ elif order_type == "buy_stops":
1406
+ orders = self.get_current_buy_stops(id=id)
1407
+ elif order_type == "sell_stops":
1408
+ orders = self.get_current_sell_stops(id=id)
1409
+ elif order_type == "buy_limits":
1410
+ orders = self.get_current_buy_limits(id=id)
1411
+ elif order_type == "sell_limits":
1412
+ orders = self.get_current_sell_limits(id=id)
1413
+ elif order_type == "buy_stop_limits":
1414
+ orders = self.get_current_buy_stop_limits(id=id)
1415
+ elif order_type == "sell_stop_limits":
1416
+ orders = self.get_current_sell_stop_limits(id=id)
1417
+ else:
1418
+ LOGGER.error(f"Invalid order type: {order_type}")
1419
+ return
1420
+ self.bulk_close(
1421
+ orders, "orders", self.close_order, order_type, id=id, comment=comment
1422
+ )
1423
+
1424
+ def close_positions(
1425
+ self,
1426
+ position_type: Positions,
1427
+ id: Optional[int] = None,
1428
+ comment: Optional[str] = None,
1429
+ ):
1430
+ """
1431
+ Args:
1432
+ position_type (str): Type of positions to close ('all', 'buy', 'sell', 'profitable', 'losing')
1433
+ id (int): The unique ID of the Expert or Strategy
1434
+ comment (str): Comment for the closing position
1435
+ """
1436
+ id = id if id is not None else self.expert_id
1437
+ if position_type == "all":
1438
+ positions = self.get_current_positions(id=id)
1439
+ elif position_type == "buy":
1440
+ positions = self.get_current_buys(id=id)
1441
+ elif position_type == "sell":
1442
+ positions = self.get_current_sells(id=id)
1443
+ elif position_type == "profitable":
1444
+ positions = self.get_current_profitables(id=id)
1445
+ elif position_type == "losing":
1446
+ positions = self.get_current_losings(id=id)
1447
+ else:
1448
+ LOGGER.error(f"Invalid position type: {position_type}")
1449
+ return
1450
+ self.bulk_close(
1451
+ positions,
1452
+ "positions",
1453
+ self.close_position,
1454
+ position_type,
1455
+ id=id,
1456
+ comment=comment,
1457
+ )
1458
+
1459
+ def get_today_deals(self, group=None):
1460
+ return self.account.get_today_deals(self.expert_id, group=group)
1461
+
1462
+ def is_max_trades_reached(self) -> bool:
1463
+ """
1464
+ Check if the maximum number of trades for the day has been reached.
1465
+
1466
+ :return: bool
1467
+ """
1468
+ negative_deals = 0
1469
+ max_trades = self.rm.max_trade()
1470
+ today_deals = self.get_today_deals(group=self.symbol)
1471
+ for deal in today_deals:
1472
+ if deal.profit < 0:
1473
+ negative_deals += 1
1474
+ if negative_deals >= max_trades:
1475
+ return True
1476
+ return False
1477
+
1478
+ def get_stats(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
1479
+ """Retrieves aggregated session and historical trading performance."""
1480
+ today_deals = self.get_today_deals(group=self.symbol)
1481
+ stats1 = self._calculate_session_stats(today_deals)
1482
+ stats2 = self._calculate_historical_stats()
1483
+
1484
+ return stats1, stats2
1485
+
1486
+ def _calculate_session_stats(self, deals_list) -> Dict[str, Any]:
1487
+ stats = {
1488
+ "deals": len(deals_list),
1489
+ "profit": 0.0,
1490
+ "win_trades": 0,
1491
+ "loss_trades": 0,
1492
+ "total_fees": 0.0,
1493
+ "average_fee": 0.0,
1494
+ "win_rate": 0.0,
1495
+ }
1496
+
1497
+ if not deals_list:
1498
+ return stats
1499
+
1500
+ for pos in deals_list:
1501
+ history = self.account.get_trades_history(
1502
+ position=pos.position_id, to_df=False
1503
+ )
1504
+
1505
+ if not history or len(history) < 2:
1506
+ continue
1507
+
1508
+ fee_sum = sum([history[0].commission, history[0].swap, history[0].fee])
1509
+ net_result = history[1].profit + fee_sum
1510
+
1511
+ stats["profit"] += history[1].profit
1512
+ stats["total_fees"] += fee_sum
1513
+
1514
+ if net_result <= 0:
1515
+ stats["loss_trades"] += 1
1516
+ else:
1517
+ stats["win_trades"] += 1
1518
+
1519
+ if stats["deals"] > 0:
1520
+ stats["average_fee"] = stats["total_fees"] / stats["deals"]
1521
+ stats["win_rate"] = round((stats["win_trades"] / stats["deals"]) * 100, 2)
1522
+ return stats
1523
+
1524
+ def get_average_fees(self) -> float:
1525
+ positions = self.account.get_trades_history(to_df=False)
1526
+ if not positions:
1527
+ return 0.0
1528
+ fees = sum([p.swap + p.fee + p.commission for p in positions])
1529
+ return fees / len(positions) if len(positions) > 0 else 0.0
1530
+
1531
+ def _calculate_historical_stats(self) -> Dict[str, Any]:
1532
+ history = self.account.get_trades_history()
1533
+
1534
+ if history is None or len(history) <= 1:
1535
+ return {"total_profit": 0, "profitability": "No"}
1536
+
1537
+ df = history.iloc[1:]
1538
+ total_net = df[["profit", "commission", "fee", "swap"]].sum().sum()
1539
+ return {
1540
+ "total_profit": total_net,
1541
+ "profitability": "Yes" if total_net > 0 else "No",
1542
+ }
1543
+
1544
+ def sharpe(self):
1545
+ """
1546
+ Calculate the Sharpe ratio of a returns stream
1547
+ based on a number of trading periods.
1548
+ The function assumes that the returns are the excess of
1549
+ those compared to a benchmark.
1550
+ """
1551
+ import warnings
1552
+
1553
+ warnings.filterwarnings("ignore")
1554
+ history = self.account.get_trades_history()
1555
+ if history is None or len(history) < 2:
1556
+ return 0.0
1557
+ df = history.iloc[1:]
1558
+ profit = df[["profit", "commission", "fee", "swap"]].sum(axis=1)
1559
+ returns = profit.pct_change(fill_method=None)
1560
+ periods = self.rm.max_trade() * 252
1561
+ sharpe = qs.stats.sharpe(returns, periods=periods)
1562
+
1563
+ return round(sharpe, 3)
1564
+
1565
+ def days_end(self) -> bool:
1566
+ """Check if it is the end of the trading day."""
1567
+ fmt = "%H:%M"
1568
+ now = datetime.now().time()
1569
+ end = datetime.strptime(self.end, fmt).time()
1570
+ if self.broker_tz:
1571
+ now = self.account.broker.get_broker_time(self.current_time(), fmt).time()
1572
+ end = self.account.broker.get_broker_time(self.end, fmt).time()
1573
+ if now >= end:
1574
+ return True
1575
+ return False
1576
+
1577
+ def trading_time(self):
1578
+ """Check if it is time to trade."""
1579
+ fmt = "%H:%M"
1580
+ now = datetime.now()
1581
+ start = datetime.strptime(self.start, fmt).time()
1582
+ end = datetime.strptime(self.finishing, fmt).time()
1583
+ if self.broker_tz:
1584
+ now = self.account.broker.get_broker_time(self.current_time(), fmt).time()
1585
+ start = self.account.broker.get_broker_time(self.start, fmt).time()
1586
+ now = self.account.broker.get_broker_time(self.finishing, fmt).time()
1587
+ if start <= now.time() <= end:
1588
+ return True
1589
+ return False
1590
+
1591
+ def sleep_time(self, weekend=False):
1592
+ fmt = "%H:%M"
1593
+ now = datetime.now()
1594
+ if weekend:
1595
+ # calculate number of minute from now and monday start
1596
+ multiplyer = {"friday": 3, "saturday": 2, "sunday": 1}
1597
+ current_time = datetime.strptime(self.current_time(), fmt)
1598
+ monday_time = datetime.strptime(self.start, fmt)
1599
+ if self.broker_tz:
1600
+ now = self.account.broker.get_broker_time(self.current_time(), fmt)
1601
+ current_time = self.account.broker.get_broker_time(
1602
+ self.current_time(), fmt
1603
+ )
1604
+ monday_time = self.account.broker.get_broker_time(self.start, fmt)
1605
+ intra_day_diff = (monday_time - current_time).total_seconds() // 60
1606
+ inter_day_diff = multiplyer[now.strftime("%A").lower()] * 24 * 60
1607
+ total_minutes = inter_day_diff + intra_day_diff
1608
+ return total_minutes
1609
+ else:
1610
+ # calculate number of minute from the end to the start
1611
+ start = datetime.strptime(self.start, fmt)
1612
+ end = datetime.strptime(self.current_time(), fmt)
1613
+ if self.broker_tz:
1614
+ start = self.account.broker.get_broker_time(self.start, fmt)
1615
+ end = self.account.broker.get_broker_time(self.current_time(), fmt)
1616
+ minutes = (end - start).total_seconds() // 60
1617
+ sleep_time = (24 * 60) - minutes
1618
+ return sleep_time
1619
+
1620
+ def current_datetime(self):
1621
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1622
+
1623
+ def current_time(self, seconds=False):
1624
+ if seconds:
1625
+ return datetime.now().strftime("%H:%M:%S")
1626
+ return datetime.now().strftime("%H:%M")
1627
+
1628
+
1629
+ def create_trade_instance(
1630
+ symbols: List[str],
1631
+ params: Dict[str, Any],
1632
+ daily_risk: Optional[Dict[str, float]] = None,
1633
+ max_risk: Optional[Dict[str, float]] = None,
1634
+ pchange_sl: Optional[Dict[str, float] | float] = None,
1635
+ **kwargs,
1636
+ ) -> Dict[str, Trade]:
1637
+ """
1638
+ Creates Trade instances for each symbol provided.
1639
+
1640
+ Args:
1641
+ symbols: A list of trading symbols (e.g., ['AAPL', 'MSFT']).
1642
+ params: A dictionary containing parameters for the Trade instance.
1643
+ daily_risk: A dictionary containing daily risk weight for each symbol.
1644
+ max_risk: A dictionary containing maximum risk weight for each symbol.
1645
+
1646
+ Returns:
1647
+ A dictionary where keys are symbols and values are corresponding Trade instances.
1648
+
1649
+ Raises:
1650
+ ValueError: If the 'symbols' list is empty or the 'params' dictionary is missing required keys.
1651
+
1652
+ Note:
1653
+ `daily_risk` and `max_risk` can be used to manage the risk of each symbol
1654
+ based on the importance of the symbol in the portfolio or strategy.
1655
+ See bbstrader.metatrader.risk.RiskManagement for more details.
1656
+ """
1657
+ if not symbols or not params:
1658
+ raise ValueError("Symbols and params are required.")
1659
+
1660
+ logger = params.get("logger") if isinstance(params.get("logger"), Logger) else log
1661
+ base_id = params.get("expert_id", EXPERT_ID)
1662
+
1663
+ def get_val(source, symbol, default=None):
1664
+ if isinstance(source, dict):
1665
+ if symbol not in source:
1666
+ raise ValueError(f"Missing key '{symbol}' in configuration.")
1667
+ return source[symbol]
1668
+ return source if source is not None else default
1669
+
1670
+ trades = {}
1671
+ for sym in symbols:
1672
+ try:
1673
+ conf = {
1674
+ **params,
1675
+ "symbol": sym,
1676
+ "expert_id": get_val(base_id, sym),
1677
+ "daily_risk": get_val(daily_risk, sym, params.get("daily_risk")),
1678
+ "max_risk": get_val(max_risk, sym, params.get("max_risk", 10.0)),
1679
+ "pchange_sl": get_val(pchange_sl, sym, params.get("pchange_sl")),
1680
+ }
1681
+ trades[sym] = Trade(**conf)
1682
+
1683
+ except Exception as e:
1684
+ logger.error(f"Failed trade init: SYMBOL={sym} | ERR={e}")
1685
+
1686
+ # Final Audit
1687
+ if len(trades) < len(symbols):
1688
+ missing = set(symbols) - set(trades.keys())
1689
+ logger.warning(f"Partial success. Missing symbols: {missing}")
1690
+
1691
+ logger.info(f"Initialized {len(trades)} trade instances.")
1692
+ return trades