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,362 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import IntEnum
|
|
3
|
+
from queue import Queue
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from bbstrader.api.metatrader_client import TradeOrder # type: ignore
|
|
12
|
+
from bbstrader.btengine.event import FillEvent, SignalEvent
|
|
13
|
+
from bbstrader.config import BBSTRADER_DIR
|
|
14
|
+
from bbstrader.core.strategy import (
|
|
15
|
+
BaseStrategy,
|
|
16
|
+
TradeAction,
|
|
17
|
+
TradeSignal,
|
|
18
|
+
TradingMode,
|
|
19
|
+
generate_signal,
|
|
20
|
+
)
|
|
21
|
+
from bbstrader.metatrader import Account, Rates
|
|
22
|
+
|
|
23
|
+
logger.add(
|
|
24
|
+
f"{BBSTRADER_DIR}/logs/strategy.log",
|
|
25
|
+
enqueue=True,
|
|
26
|
+
level="INFO",
|
|
27
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"LiveStrategy",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SignalType(IntEnum):
|
|
36
|
+
BUY = 0
|
|
37
|
+
SELL = 1
|
|
38
|
+
EXIT_LONG = 2
|
|
39
|
+
EXIT_SHORT = 3
|
|
40
|
+
EXIT_ALL_POSITIONS = 4
|
|
41
|
+
EXIT_ALL_ORDERS = 5
|
|
42
|
+
EXIT_STOP = 6
|
|
43
|
+
EXIT_LIMIT = 7
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LiveStrategy(BaseStrategy):
|
|
47
|
+
"""
|
|
48
|
+
Strategy implementation for Live Trading.
|
|
49
|
+
Relies on the `Account` class for state (orders, positions, cash)
|
|
50
|
+
and `Rates` for data.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
events: "Queue[Union[SignalEvent, FillEvent]]"
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
symbol_list: List[str],
|
|
58
|
+
**kwargs: Any,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Initialize the `LiveStrategy` object.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
symbol_list : The list of symbols for the strategy.
|
|
65
|
+
**kwargs : Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler).
|
|
66
|
+
- max_trades : The maximum number of trades allowed per symbol.
|
|
67
|
+
- time_frame : The time frame for the strategy.
|
|
68
|
+
- logger : The logger object for the strategy.
|
|
69
|
+
"""
|
|
70
|
+
super().__init__(symbol_list, **kwargs)
|
|
71
|
+
self.mode = TradingMode.LIVE
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def account(self) -> Account:
|
|
75
|
+
"""Create or access the MT5 Account."""
|
|
76
|
+
return Account(**self.kwargs)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def cash(self) -> float:
|
|
80
|
+
return self.account.equity
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def orders(self) -> List[TradeOrder]:
|
|
84
|
+
"""Returns active orders from the Broker."""
|
|
85
|
+
return self.account.get_orders() or []
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def positions(self) -> List[Any]:
|
|
89
|
+
"""Returns open positions from the Broker."""
|
|
90
|
+
return self.account.get_positions() or []
|
|
91
|
+
|
|
92
|
+
def get_asset_values(
|
|
93
|
+
self,
|
|
94
|
+
symbol_list: List[str],
|
|
95
|
+
window: int,
|
|
96
|
+
value_type: str = "returns",
|
|
97
|
+
array: bool = True,
|
|
98
|
+
**kwargs,
|
|
99
|
+
) -> Optional[Dict[str, Union[np.ndarray, pd.Series]]]:
|
|
100
|
+
asset_values: Dict[str, Union[np.ndarray, pd.Series]] = {}
|
|
101
|
+
for asset in symbol_list:
|
|
102
|
+
rates = Rates(asset, timeframe=self.tf, count=window + 1, **self.kwargs)
|
|
103
|
+
if array:
|
|
104
|
+
values = getattr(rates, value_type).to_numpy()
|
|
105
|
+
asset_values[asset] = values[~np.isnan(values)]
|
|
106
|
+
else:
|
|
107
|
+
values = getattr(rates, value_type)
|
|
108
|
+
asset_values[asset] = values
|
|
109
|
+
|
|
110
|
+
if all(len(values) >= window for values in asset_values.values()):
|
|
111
|
+
return {a: v[-window:] for a, v in asset_values.items()}
|
|
112
|
+
|
|
113
|
+
if kwargs.get("error") == "raise":
|
|
114
|
+
raise ValueError("Not enough data to calculate the values.")
|
|
115
|
+
elif kwargs.get("error") == "ignore":
|
|
116
|
+
return asset_values
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def signal(
|
|
120
|
+
self, signal: int, symbol: str, sl: float = None, tp: float = None
|
|
121
|
+
) -> TradeSignal:
|
|
122
|
+
"""
|
|
123
|
+
Generate a ``TradeSignal`` object based on the signal value.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
signal : int
|
|
128
|
+
An integer value representing the signal type:
|
|
129
|
+
* 0: BUY
|
|
130
|
+
* 1: SELL
|
|
131
|
+
* 2: EXIT_LONG
|
|
132
|
+
* 3: EXIT_SHORT
|
|
133
|
+
* 4: EXIT_ALL_POSITIONS
|
|
134
|
+
* 5: EXIT_ALL_ORDERS
|
|
135
|
+
* 6: EXIT_STOP
|
|
136
|
+
* 7: EXIT_LIMIT
|
|
137
|
+
symbol : str
|
|
138
|
+
The symbol for the trade.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
TradeSignal
|
|
143
|
+
A ``TradeSignal`` object representing the trade signal.
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
If the signal value is not between 0 and 7.
|
|
149
|
+
|
|
150
|
+
Notes
|
|
151
|
+
-----
|
|
152
|
+
This generates only common signals. For more complex signals, use
|
|
153
|
+
``generate_signal`` directly.
|
|
154
|
+
"""
|
|
155
|
+
signal_id = getattr(self, "id", getattr(self, "ID", None))
|
|
156
|
+
if signal_id is None:
|
|
157
|
+
raise ValueError("Strategy ID not set")
|
|
158
|
+
|
|
159
|
+
action_map = {
|
|
160
|
+
SignalType.BUY: TradeAction.BUY,
|
|
161
|
+
SignalType.SELL: TradeAction.SELL,
|
|
162
|
+
SignalType.EXIT_LONG: TradeAction.EXIT_LONG,
|
|
163
|
+
SignalType.EXIT_SHORT: TradeAction.EXIT_SHORT,
|
|
164
|
+
SignalType.EXIT_ALL_POSITIONS: TradeAction.EXIT_ALL_POSITIONS,
|
|
165
|
+
SignalType.EXIT_ALL_ORDERS: TradeAction.EXIT_ALL_ORDERS,
|
|
166
|
+
SignalType.EXIT_STOP: TradeAction.EXIT_STOP,
|
|
167
|
+
SignalType.EXIT_LIMIT: TradeAction.EXIT_LIMIT,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
action = action_map[SignalType(signal)]
|
|
172
|
+
except (ValueError, KeyError):
|
|
173
|
+
raise ValueError(f"Invalid signal value: {signal}")
|
|
174
|
+
kwargs = (
|
|
175
|
+
{"sl": sl, "tp": tp}
|
|
176
|
+
if action in (TradeAction.BUY, TradeAction.SELL)
|
|
177
|
+
else {}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return generate_signal(signal_id, symbol, action, **kwargs)
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def calculate_signals(self, *args: Any, **kwargs: Any) -> List[TradeSignal]: ...
|
|
184
|
+
|
|
185
|
+
def ispositions(
|
|
186
|
+
self,
|
|
187
|
+
symbol: str,
|
|
188
|
+
strategy_id: int,
|
|
189
|
+
position: int,
|
|
190
|
+
max_trades: int,
|
|
191
|
+
one_true: bool = False,
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""
|
|
194
|
+
This function is use for live trading to check if there are open positions
|
|
195
|
+
for a given symbol and strategy. It is used to prevent opening more trades
|
|
196
|
+
than the maximum allowed trades per symbol.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
symbol : The symbol for the trade.
|
|
200
|
+
strategy_id : The unique identifier for the strategy.
|
|
201
|
+
position : The position type (1: short, 0: long).
|
|
202
|
+
max_trades : The maximum number of trades allowed per symbol.
|
|
203
|
+
one_true : If True, return True if there is at least one open position.
|
|
204
|
+
account : The `bbstrader.metatrader.Account` object for the strategy.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
bool : True if there are open positions, False otherwise
|
|
208
|
+
"""
|
|
209
|
+
positions = self.account.get_positions(symbol=symbol)
|
|
210
|
+
if positions is not None:
|
|
211
|
+
open_positions = [
|
|
212
|
+
pos.ticket
|
|
213
|
+
for pos in positions
|
|
214
|
+
if pos.type == position and pos.magic == strategy_id
|
|
215
|
+
]
|
|
216
|
+
if one_true:
|
|
217
|
+
return len(open_positions) in range(1, max_trades + 1)
|
|
218
|
+
return len(open_positions) >= max_trades
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def get_positions_prices(
|
|
222
|
+
self,
|
|
223
|
+
symbol: str,
|
|
224
|
+
strategy_id: int,
|
|
225
|
+
position: int,
|
|
226
|
+
) -> np.ndarray:
|
|
227
|
+
"""
|
|
228
|
+
Get the buy or sell prices for open positions of a given symbol and strategy.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
symbol : The symbol for the trade.
|
|
232
|
+
strategy_id : The unique identifier for the strategy.
|
|
233
|
+
position : The position type (1: short, 0: long).
|
|
234
|
+
account : The `bbstrader.metatrader.Account` object for the strategy.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
prices : numpy array of buy or sell prices for open positions if any or an empty array.
|
|
238
|
+
"""
|
|
239
|
+
positions = self.account.get_positions(symbol=symbol)
|
|
240
|
+
if positions is not None:
|
|
241
|
+
prices = np.array(
|
|
242
|
+
[
|
|
243
|
+
pos.price_open
|
|
244
|
+
for pos in positions
|
|
245
|
+
if pos.type == position and pos.magic == strategy_id
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
return prices
|
|
249
|
+
return np.array([])
|
|
250
|
+
|
|
251
|
+
def get_active_orders(
|
|
252
|
+
self, symbol: str, strategy_id: int, order_type: Optional[int] = None
|
|
253
|
+
) -> List[TradeOrder]:
|
|
254
|
+
"""
|
|
255
|
+
Get the active orders for a given symbol and strategy.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
symbol : The symbol for the trade.
|
|
259
|
+
strategy_id : The unique identifier for the strategy.
|
|
260
|
+
order_type : The type of order to filter by (optional):
|
|
261
|
+
"BUY_LIMIT": 2
|
|
262
|
+
"SELL_LIMIT": 3
|
|
263
|
+
"BUY_STOP": 4
|
|
264
|
+
"SELL_STOP": 5
|
|
265
|
+
"BUY_STOP_LIMIT": 6
|
|
266
|
+
"SELL_STOP_LIMIT": 7
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List[TradeOrder] : A list of active orders for the given symbol and strategy.
|
|
270
|
+
"""
|
|
271
|
+
all_orders = self.orders
|
|
272
|
+
orders = [
|
|
273
|
+
o
|
|
274
|
+
for o in all_orders
|
|
275
|
+
if isinstance(o, TradeOrder)
|
|
276
|
+
and o.symbol == symbol
|
|
277
|
+
and o.magic == strategy_id
|
|
278
|
+
]
|
|
279
|
+
if order_type is not None and len(orders) > 0:
|
|
280
|
+
orders = [o for o in orders if o.type == order_type]
|
|
281
|
+
return orders
|
|
282
|
+
|
|
283
|
+
def exit_positions(
|
|
284
|
+
self, position: int, prices: np.ndarray, asset: str, th: float = 0.01
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""Logic to determine if positions should be exited based on threshold."""
|
|
287
|
+
if len(prices) == 0:
|
|
288
|
+
return False
|
|
289
|
+
tick_info = self.account.get_tick_info(asset)
|
|
290
|
+
if tick_info is None:
|
|
291
|
+
return False
|
|
292
|
+
bid, ask = tick_info.bid, tick_info.ask
|
|
293
|
+
price = None
|
|
294
|
+
if len(prices) == 1:
|
|
295
|
+
price = prices[0]
|
|
296
|
+
elif len(prices) in range(2, self.max_trades[asset] + 1):
|
|
297
|
+
price = np.mean(prices)
|
|
298
|
+
|
|
299
|
+
if price is not None:
|
|
300
|
+
if position == 0: # Long exit check
|
|
301
|
+
return self.calculate_pct_change(ask, price) >= th
|
|
302
|
+
elif position == 1: # Short exit check
|
|
303
|
+
return self.calculate_pct_change(bid, price) <= -th
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
def send_trade_report(self, perf_analyzer: Callable, **kwargs: Any) -> None:
|
|
307
|
+
"""
|
|
308
|
+
Generates and sends a trade report message containing performance metrics for the current strategy.
|
|
309
|
+
This method retrieves the trade history for the current account, filters it by the strategy's ID,
|
|
310
|
+
computes performance metrics using the provided `perf_analyzer` callable, and formats the results
|
|
311
|
+
into a message. The message includes account information, strategy details, a timestamp, and
|
|
312
|
+
performance metrics. The message is then sent via Telegram using the specified bot token and chat ID.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
perf_analyzer (Callable): A function or callable object that takes the filtered trade history
|
|
316
|
+
(as a DataFrame) and additional keyword arguments, and returns a DataFrame of performance metrics.
|
|
317
|
+
**kwargs: Additional keyword arguments, which may include
|
|
318
|
+
- Any other param requires by ``perf_analyzer``
|
|
319
|
+
"""
|
|
320
|
+
from bbstrader.trading.utils import send_message
|
|
321
|
+
|
|
322
|
+
history = self.account.get_trades_history()
|
|
323
|
+
if history is None or history.empty:
|
|
324
|
+
self.logger.warning("No trades found on this account.")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
ID = getattr(self, "id", None) or getattr(self, "ID")
|
|
328
|
+
history = history[history["magic"] == ID]
|
|
329
|
+
performance = perf_analyzer(history, **kwargs)
|
|
330
|
+
if performance.empty:
|
|
331
|
+
self.logger.warning("No trades found for the current strategy.")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
account_name = self.kwargs.get("account", "MT5 Account")
|
|
335
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
336
|
+
|
|
337
|
+
header = (
|
|
338
|
+
f"TRADE REPORT\n\n"
|
|
339
|
+
f"ACCOUNT: {account_name}\n"
|
|
340
|
+
f"STRATEGY: {self.NAME}\n"
|
|
341
|
+
f"ID: {ID}\n"
|
|
342
|
+
f"DESCRIPTION: {self.DESCRIPTION}\n"
|
|
343
|
+
f"TIMESTAMP: {timestamp}\n\n"
|
|
344
|
+
f"📊 PERFORMANCE:\n"
|
|
345
|
+
)
|
|
346
|
+
metrics = performance.iloc[0].to_dict()
|
|
347
|
+
|
|
348
|
+
lines = []
|
|
349
|
+
for key, value in metrics.items():
|
|
350
|
+
if isinstance(value, float):
|
|
351
|
+
value = round(value, 4)
|
|
352
|
+
lines.append(f"{key:<15}: {value}")
|
|
353
|
+
|
|
354
|
+
performance_str = "\n".join(lines)
|
|
355
|
+
message = f"{header}{performance_str}"
|
|
356
|
+
|
|
357
|
+
send_message(
|
|
358
|
+
message=message,
|
|
359
|
+
telegram=True,
|
|
360
|
+
token=self.kwargs.get("bot_token"),
|
|
361
|
+
chat_id=self.kwargs.get("chat_id"),
|
|
362
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from notifypy import Notify
|
|
4
|
+
from telegram import Bot
|
|
5
|
+
from telegram.error import TelegramError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ["send_telegram_message", "send_notification", "send_message"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def send_telegram_message(token, chat_id, text=""):
|
|
12
|
+
"""
|
|
13
|
+
Send a message to a telegram chat
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
token: str: Telegram bot token
|
|
17
|
+
chat_id: int or str or list: Chat id or list of chat ids
|
|
18
|
+
text: str: Message to send
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
bot = Bot(token=token)
|
|
22
|
+
if isinstance(chat_id, (int, str)):
|
|
23
|
+
chat_id = [chat_id]
|
|
24
|
+
for id in chat_id:
|
|
25
|
+
await bot.send_message(chat_id=id, text=text)
|
|
26
|
+
except TelegramError as e:
|
|
27
|
+
print(f"Error sending message: {e}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send_notification(title, message=""):
|
|
31
|
+
"""
|
|
32
|
+
Send a desktop notification
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
title: str: Title of the notification
|
|
36
|
+
message: str: Message of the notification
|
|
37
|
+
"""
|
|
38
|
+
notification = Notify(default_notification_application_name="bbstrading")
|
|
39
|
+
notification.title = title
|
|
40
|
+
notification.message = message
|
|
41
|
+
notification.send()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def send_message(
|
|
45
|
+
title="SIGNAL",
|
|
46
|
+
message="New signal",
|
|
47
|
+
notify_me=False,
|
|
48
|
+
telegram=False,
|
|
49
|
+
token=None,
|
|
50
|
+
chat_id=None,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Send a message to the user
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
title: str: Title of the message
|
|
57
|
+
message: str: Message of the message
|
|
58
|
+
notify_me: bool: Send a desktop notification
|
|
59
|
+
telegram: bool: Send a telegram message
|
|
60
|
+
token: str: Telegram bot token
|
|
61
|
+
chat_id: int or str or list: Chat id or list of chat ids
|
|
62
|
+
"""
|
|
63
|
+
if notify_me:
|
|
64
|
+
send_notification(title, message=message)
|
|
65
|
+
if telegram:
|
|
66
|
+
if token is None or chat_id is None:
|
|
67
|
+
raise ValueError("Token and chat_id must be provided")
|
|
68
|
+
asyncio.run(send_telegram_message(token, chat_id, text=message))
|
|
69
|
+
|