bbstrader 0.1.92__py3-none-any.whl → 0.1.93__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bbstrader might be problematic. Click here for more details.
- bbstrader/btengine/backtest.py +20 -14
- bbstrader/btengine/execution.py +3 -2
- bbstrader/btengine/performance.py +2 -0
- bbstrader/btengine/portfolio.py +12 -16
- bbstrader/btengine/strategy.py +140 -45
- bbstrader/metatrader/account.py +1 -1
- bbstrader/metatrader/risk.py +28 -3
- bbstrader/models/__init__.py +5 -1
- bbstrader/models/optimization.py +12 -5
- bbstrader/models/portfolios.py +3 -0
- bbstrader/trading/execution.py +20 -7
- {bbstrader-0.1.92.dist-info → bbstrader-0.1.93.dist-info}/METADATA +1 -1
- {bbstrader-0.1.92.dist-info → bbstrader-0.1.93.dist-info}/RECORD +16 -16
- {bbstrader-0.1.92.dist-info → bbstrader-0.1.93.dist-info}/WHEEL +1 -1
- {bbstrader-0.1.92.dist-info → bbstrader-0.1.93.dist-info}/LICENSE +0 -0
- {bbstrader-0.1.92.dist-info → bbstrader-0.1.93.dist-info}/top_level.txt +0 -0
bbstrader/btengine/backtest.py
CHANGED
|
@@ -85,6 +85,8 @@ class BacktestEngine(Backtest):
|
|
|
85
85
|
strategy (Strategy): Generates signals based on market data.
|
|
86
86
|
kwargs : Additional parameters based on the `ExecutionHandler`,
|
|
87
87
|
the `DataHandler`, the `Strategy` used and the `Portfolio`.
|
|
88
|
+
- show_equity (bool): Show the equity curve of the portfolio.
|
|
89
|
+
- stats_file (str): File to save the summary stats.
|
|
88
90
|
"""
|
|
89
91
|
self.symbol_list = symbol_list
|
|
90
92
|
self.initial_capital = initial_capital
|
|
@@ -104,6 +106,7 @@ class BacktestEngine(Backtest):
|
|
|
104
106
|
|
|
105
107
|
self._generate_trading_instances()
|
|
106
108
|
self.show_equity = kwargs.get("show_equity", False)
|
|
109
|
+
self.stats_file = kwargs.get("stats_file", None)
|
|
107
110
|
|
|
108
111
|
def _generate_trading_instances(self):
|
|
109
112
|
"""
|
|
@@ -137,14 +140,21 @@ class BacktestEngine(Backtest):
|
|
|
137
140
|
i = 0
|
|
138
141
|
while True:
|
|
139
142
|
i += 1
|
|
140
|
-
|
|
143
|
+
value = self.portfolio.all_holdings[-1]['Total']
|
|
141
144
|
if self.data_handler.continue_backtest == True:
|
|
145
|
+
# Update the market bars
|
|
142
146
|
self.data_handler.update_bars()
|
|
143
147
|
self.strategy.check_pending_orders()
|
|
148
|
+
self.strategy.get_update_from_portfolio(
|
|
149
|
+
self.portfolio.current_positions,
|
|
150
|
+
self.portfolio.current_holdings
|
|
151
|
+
)
|
|
152
|
+
self.strategy.cash = value
|
|
144
153
|
else:
|
|
145
154
|
print("\n[======= BACKTEST COMPLETED =======]")
|
|
146
|
-
print(f"END DATE: {self.data_handler.get_latest_bar_datetime()}")
|
|
155
|
+
print(f"END DATE: {self.data_handler.get_latest_bar_datetime(self.symbol_list[0])}")
|
|
147
156
|
print(f"TOTAL BARS: {i} ")
|
|
157
|
+
print(f"PORFOLIO VALUE: {round(value, 2)}")
|
|
148
158
|
break
|
|
149
159
|
|
|
150
160
|
# Handle the events
|
|
@@ -171,10 +181,6 @@ class BacktestEngine(Backtest):
|
|
|
171
181
|
self.fills += 1
|
|
172
182
|
self.portfolio.update_fill(event)
|
|
173
183
|
self.strategy.update_trades_from_fill(event)
|
|
174
|
-
self.strategy.get_update_from_portfolio(
|
|
175
|
-
self.portfolio.current_positions,
|
|
176
|
-
self.portfolio.current_holdings
|
|
177
|
-
)
|
|
178
184
|
|
|
179
185
|
time.sleep(self.heartbeat)
|
|
180
186
|
|
|
@@ -192,13 +198,13 @@ class BacktestEngine(Backtest):
|
|
|
192
198
|
stat2['Orders'] = self.orders
|
|
193
199
|
stat2['Fills'] = self.fills
|
|
194
200
|
stats.extend(stat2.items())
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
tab_stats = tabulate(stats, headers=["Metric", "Value"], tablefmt="outline")
|
|
202
|
+
print(tab_stats, "\n")
|
|
203
|
+
if self.stats_file:
|
|
204
|
+
with open(self.stats_file, 'a') as f:
|
|
205
|
+
f.write("\n[======= Summary Stats =======]\n")
|
|
206
|
+
f.write(tab_stats)
|
|
207
|
+
f.write("\n")
|
|
202
208
|
|
|
203
209
|
if self.show_equity:
|
|
204
210
|
print("\nCreating equity curve...")
|
|
@@ -336,7 +342,7 @@ def run_backtest_with(engine: Literal["bbstrader", "cerebro", "zipline"], **kwar
|
|
|
336
342
|
data_handler=kwargs.get("data_handler"),
|
|
337
343
|
strategy=kwargs.get("strategy"),
|
|
338
344
|
exc_handler=kwargs.get("exc_handler"),
|
|
339
|
-
initial_capital=kwargs.get("initial_capital"),
|
|
345
|
+
initial_capital=kwargs.get("initial_capital", 100000.0),
|
|
340
346
|
heartbeat=kwargs.get("heartbeat", 0.0),
|
|
341
347
|
**kwargs
|
|
342
348
|
)
|
bbstrader/btengine/execution.py
CHANGED
|
@@ -77,8 +77,9 @@ class SimExecutionHandler(ExecutionHandler):
|
|
|
77
77
|
if event.type == 'ORDER':
|
|
78
78
|
dtime = self.bardata.get_latest_bar_datetime(event.symbol)
|
|
79
79
|
fill_event = FillEvent(
|
|
80
|
-
dtime, event.symbol,
|
|
81
|
-
'ARCA', event.quantity, event.direction,
|
|
80
|
+
timeindex=dtime, symbol=event.symbol,
|
|
81
|
+
exchange='ARCA', quantity=event.quantity, direction=event.direction,
|
|
82
|
+
fill_cost=None, commission=None, order=event.signal
|
|
82
83
|
)
|
|
83
84
|
self.events.put(fill_event)
|
|
84
85
|
self.logger.info(
|
bbstrader/btengine/portfolio.py
CHANGED
|
@@ -93,16 +93,20 @@ class Portfolio(object):
|
|
|
93
93
|
initial_capital (float): The starting capital in USD.
|
|
94
94
|
|
|
95
95
|
kwargs (dict): Additional arguments
|
|
96
|
+
- `leverage`: The leverage to apply to the portfolio.
|
|
96
97
|
- `time_frame`: The time frame of the bars.
|
|
97
|
-
- `
|
|
98
|
+
- `session_duration`: The number of trading hours in a day.
|
|
98
99
|
- `benchmark`: The benchmark symbol to compare the portfolio.
|
|
99
|
-
- `
|
|
100
|
+
- `output_dir`: The directory to save the backtest results.
|
|
101
|
+
- `strategy_name`: The name of the strategy (the name must not include 'Strategy' in it).
|
|
102
|
+
- `print_stats`: Whether to print the backtest statistics.
|
|
100
103
|
"""
|
|
101
104
|
self.bars = bars
|
|
102
105
|
self.events = events
|
|
103
106
|
self.symbol_list = self.bars.symbol_list
|
|
104
107
|
self.start_date = start_date
|
|
105
108
|
self.initial_capital = initial_capital
|
|
109
|
+
self._leverage = kwargs.get('leverage', 1)
|
|
106
110
|
|
|
107
111
|
self.timeframe = kwargs.get("time_frame", "D1")
|
|
108
112
|
self.trading_hours = kwargs.get("session_duration", 23)
|
|
@@ -277,8 +281,7 @@ class Portfolio(object):
|
|
|
277
281
|
|
|
278
282
|
def generate_order(self, signal: SignalEvent):
|
|
279
283
|
"""
|
|
280
|
-
|
|
281
|
-
and generate an OrderEvent, else return None.
|
|
284
|
+
Turns a SignalEvent into an OrderEvent.
|
|
282
285
|
|
|
283
286
|
Args:
|
|
284
287
|
signal (SignalEvent): The tuple containing Signal information.
|
|
@@ -294,25 +297,17 @@ class Portfolio(object):
|
|
|
294
297
|
strength = signal.strength
|
|
295
298
|
price = signal.price or self._get_price(symbol)
|
|
296
299
|
cur_quantity = self.current_positions[symbol]
|
|
297
|
-
|
|
300
|
+
mkt_quantity = round(quantity * strength, 2)
|
|
301
|
+
new_quantity = mkt_quantity * self._leverage
|
|
298
302
|
|
|
299
303
|
if direction in ['LONG', 'SHORT', 'EXIT']:
|
|
300
304
|
order_type = 'MKT'
|
|
301
305
|
else:
|
|
302
306
|
order_type = direction
|
|
303
|
-
mkt_quantity = round(quantity * strength)
|
|
304
|
-
cost = mkt_quantity * price
|
|
305
307
|
|
|
306
|
-
if
|
|
307
|
-
new_quantity = mkt_quantity
|
|
308
|
-
elif cash < cost and cash > 0:
|
|
309
|
-
new_quantity = round(cash // price)
|
|
310
|
-
else:
|
|
311
|
-
new_quantity = 0
|
|
312
|
-
|
|
313
|
-
if new_quantity > 0 and direction == 'LONG':
|
|
308
|
+
if direction == 'LONG' and new_quantity > 0:
|
|
314
309
|
order = OrderEvent(symbol, order_type, new_quantity, 'BUY', price, direction)
|
|
315
|
-
if
|
|
310
|
+
if direction == 'SHORT' and new_quantity > 0:
|
|
316
311
|
order = OrderEvent(symbol, order_type, new_quantity, 'SELL', price, direction)
|
|
317
312
|
|
|
318
313
|
if direction == 'EXIT' and cur_quantity > 0:
|
|
@@ -322,6 +317,7 @@ class Portfolio(object):
|
|
|
322
317
|
|
|
323
318
|
return order
|
|
324
319
|
|
|
320
|
+
|
|
325
321
|
def update_signal(self, event: SignalEvent):
|
|
326
322
|
"""
|
|
327
323
|
Acts on a SignalEvent to generate new orders
|
bbstrader/btengine/strategy.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from abc import ABCMeta, abstractmethod
|
|
2
2
|
import pytz
|
|
3
|
-
import math
|
|
4
3
|
import pandas as pd
|
|
5
4
|
import numpy as np
|
|
6
5
|
from queue import Queue
|
|
@@ -18,6 +17,7 @@ from typing import (
|
|
|
18
17
|
List,
|
|
19
18
|
Literal
|
|
20
19
|
)
|
|
20
|
+
from bbstrader.models.optimization import optimized_weights
|
|
21
21
|
|
|
22
22
|
__all__ = ['Strategy', 'MT5Strategy']
|
|
23
23
|
|
|
@@ -72,17 +72,29 @@ class MT5Strategy(Strategy):
|
|
|
72
72
|
bars : The data handler object.
|
|
73
73
|
mode : The mode of operation for the strategy (backtest or live).
|
|
74
74
|
**kwargs : Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler).
|
|
75
|
+
- max_trades : The maximum number of trades allowed per symbol.
|
|
76
|
+
- time_frame : The time frame for the strategy.
|
|
77
|
+
- logger : The logger object for the strategy.
|
|
75
78
|
"""
|
|
76
79
|
self.events = events
|
|
77
80
|
self.data = bars
|
|
78
81
|
self.symbols = symbol_list
|
|
79
82
|
self.mode = mode
|
|
80
|
-
self.
|
|
81
|
-
self.
|
|
82
|
-
|
|
83
|
+
self._porfolio_value = None
|
|
84
|
+
self.risk_budget = self._check_risk_budget(**kwargs)
|
|
85
|
+
self.max_trades = kwargs.get("max_trades", {s: 1 for s in self.symbols})
|
|
86
|
+
self.tf = kwargs.get("time_frame", 'D1')
|
|
83
87
|
self.logger = kwargs.get("logger")
|
|
84
88
|
self._initialize_portfolio()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def cash(self) -> float:
|
|
92
|
+
return self._porfolio_value
|
|
85
93
|
|
|
94
|
+
@cash.setter
|
|
95
|
+
def cash(self, value):
|
|
96
|
+
self._porfolio_value = value
|
|
97
|
+
|
|
86
98
|
@property
|
|
87
99
|
def orders(self) -> Dict[str, Dict[str, List[SignalEvent]]]:
|
|
88
100
|
return self._orders
|
|
@@ -98,11 +110,24 @@ class MT5Strategy(Strategy):
|
|
|
98
110
|
@property
|
|
99
111
|
def holdings(self) -> Dict[str, float]:
|
|
100
112
|
return self._holdings
|
|
113
|
+
|
|
114
|
+
def _check_risk_budget(self, **kwargs):
|
|
115
|
+
weights = kwargs.get('risk_weights')
|
|
116
|
+
if weights is not None and isinstance(weights, dict):
|
|
117
|
+
for asset in self.symbols:
|
|
118
|
+
if asset not in weights:
|
|
119
|
+
raise ValueError(f"Risk budget for asset {asset} is missing.")
|
|
120
|
+
total_risk = sum(weights.values())
|
|
121
|
+
if not np.isclose(total_risk, 1.0):
|
|
122
|
+
raise ValueError(f'Risk budget weights must sum to 1. got {total_risk}')
|
|
123
|
+
return weights
|
|
124
|
+
elif isinstance(weights, str):
|
|
125
|
+
return weights
|
|
101
126
|
|
|
102
127
|
def _initialize_portfolio(self):
|
|
103
128
|
positions = ['LONG', 'SHORT']
|
|
104
129
|
orders = ['BLMT', 'BSTP', 'BSTPLMT', 'SLMT', 'SSTP', 'SSTPLMT']
|
|
105
|
-
self._positions: Dict[str, Dict[str, int]] = {}
|
|
130
|
+
self._positions: Dict[str, Dict[str, int | float]] = {}
|
|
106
131
|
self._trades: Dict[str, Dict[str, int]] = {}
|
|
107
132
|
self._orders: Dict[str, Dict[str, List[SignalEvent]]] = {}
|
|
108
133
|
for symbol in self.symbols:
|
|
@@ -111,7 +136,7 @@ class MT5Strategy(Strategy):
|
|
|
111
136
|
self._trades[symbol] = {}
|
|
112
137
|
for position in positions:
|
|
113
138
|
self._trades[symbol][position] = 0
|
|
114
|
-
self._positions[symbol][position] = 0
|
|
139
|
+
self._positions[symbol][position] = 0.0
|
|
115
140
|
for order in orders:
|
|
116
141
|
self._orders[symbol][order] = []
|
|
117
142
|
self._holdings = {s: 0.0 for s in self.symbols}
|
|
@@ -133,6 +158,9 @@ class MT5Strategy(Strategy):
|
|
|
133
158
|
self._positions[symbol]['LONG'] = positions[symbol]
|
|
134
159
|
elif positions[symbol] < 0:
|
|
135
160
|
self._positions[symbol]['SHORT'] = positions[symbol]
|
|
161
|
+
else:
|
|
162
|
+
self._positions[symbol]['LONG'] = 0
|
|
163
|
+
self._positions[symbol]['SHORT'] = 0
|
|
136
164
|
if symbol in holdings:
|
|
137
165
|
self._holdings[symbol] = holdings[symbol]
|
|
138
166
|
|
|
@@ -142,7 +170,12 @@ class MT5Strategy(Strategy):
|
|
|
142
170
|
It is used to keep track of the number of trades executed for each order.
|
|
143
171
|
"""
|
|
144
172
|
if event.type == 'FILL':
|
|
145
|
-
|
|
173
|
+
if event.order != 'EXIT':
|
|
174
|
+
self._trades[event.symbol][event.order] += 1
|
|
175
|
+
elif event.order == 'EXIT' and event.direction == 'BUY':
|
|
176
|
+
self._trades[event.symbol]['SHORT'] = 0
|
|
177
|
+
elif event.order == 'EXIT' and event.direction == 'SELL':
|
|
178
|
+
self._trades[event.symbol]['LONG'] = 0
|
|
146
179
|
|
|
147
180
|
def calculate_signals(self, *args, **kwargs
|
|
148
181
|
) -> Dict[str, Union[str, dict, None]] | None:
|
|
@@ -173,7 +206,25 @@ class MT5Strategy(Strategy):
|
|
|
173
206
|
"""
|
|
174
207
|
pass
|
|
175
208
|
|
|
176
|
-
def
|
|
209
|
+
def apply_risk_management(self, optimer, freq=252) -> Dict[str, float] | None:
|
|
210
|
+
"""
|
|
211
|
+
Apply risk management rules to the strategy.
|
|
212
|
+
"""
|
|
213
|
+
if optimer is None:
|
|
214
|
+
return None
|
|
215
|
+
prices = self.get_asset_values(
|
|
216
|
+
symbol_list=self.symbols, bars=self.data, mode=self.mode,
|
|
217
|
+
window=freq, value_type='close', array=False, tf=self.tf
|
|
218
|
+
)
|
|
219
|
+
prices = pd.DataFrame(prices)
|
|
220
|
+
prices = prices.dropna(axis=0, how='any')
|
|
221
|
+
try:
|
|
222
|
+
weights = optimized_weights(prices=prices, freq=freq, method=optimer)
|
|
223
|
+
return {symbol: weight for symbol, weight in weights.items()}
|
|
224
|
+
except Exception:
|
|
225
|
+
return {symbol: 0.0 for symbol in self.symbols}
|
|
226
|
+
|
|
227
|
+
def get_quantity(self, symbol, weight, price=None, volume=None, maxqty=None) -> int:
|
|
177
228
|
"""
|
|
178
229
|
Calculate the quantity to buy or sell for a given symbol based on the dollar value provided.
|
|
179
230
|
The quantity calculated can be used to evalute a strategy's performance for each symbol
|
|
@@ -185,15 +236,26 @@ class MT5Strategy(Strategy):
|
|
|
185
236
|
Returns:
|
|
186
237
|
qty : The quantity to buy or sell for the symbol.
|
|
187
238
|
"""
|
|
188
|
-
if self.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
239
|
+
if (self._porfolio_value is None or weight == 0 or
|
|
240
|
+
self._porfolio_value == 0 or np.isnan(self._porfolio_value)):
|
|
241
|
+
return 0
|
|
242
|
+
if volume is None:
|
|
243
|
+
volume = round(self._porfolio_value * weight)
|
|
244
|
+
if price is None:
|
|
245
|
+
price = self.data.get_latest_bar_value(symbol, 'close')
|
|
246
|
+
if (price is None or not isinstance(price, (int, float, np.number))
|
|
247
|
+
or volume is None or not isinstance(volume, (int, float, np.number))
|
|
248
|
+
or np.isnan(float(price))
|
|
249
|
+
or np.isnan(float(volume))
|
|
250
|
+
):
|
|
251
|
+
if weight != 0:
|
|
252
|
+
return 1
|
|
253
|
+
return 0
|
|
254
|
+
qty = round(volume / price, 2)
|
|
255
|
+
qty = max(qty, 0) / self.max_trades[symbol]
|
|
256
|
+
if maxqty is not None:
|
|
257
|
+
qty = min(qty, maxqty)
|
|
258
|
+
return max(round(qty, 2), 0)
|
|
197
259
|
|
|
198
260
|
def get_quantities(self, quantities: Union[None, dict, int]) -> dict:
|
|
199
261
|
"""
|
|
@@ -215,9 +277,18 @@ class MT5Strategy(Strategy):
|
|
|
215
277
|
|
|
216
278
|
position = SignalEvent(id, symbol, dtime, signal,
|
|
217
279
|
quantity=quantity, strength=strength, price=price)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
280
|
+
log = False
|
|
281
|
+
if signal in ['LONG', 'SHORT']:
|
|
282
|
+
if self._trades[symbol][signal] < self.max_trades[symbol] and quantity > 0:
|
|
283
|
+
self.events.put(position)
|
|
284
|
+
log = True
|
|
285
|
+
elif signal == 'EXIT':
|
|
286
|
+
if self._positions[symbol]['LONG'] > 0 or self._positions[symbol]['SHORT'] < 0:
|
|
287
|
+
self.events.put(position)
|
|
288
|
+
log = True
|
|
289
|
+
if log:
|
|
290
|
+
self.logger.info(
|
|
291
|
+
f"{signal} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={quantity}, PRICE @{price}", custom_time=dtime)
|
|
221
292
|
|
|
222
293
|
def buy_mkt(self, id: int, symbol: str, price: float, quantity: int,
|
|
223
294
|
strength: float=1.0, dtime: datetime | pd.Timestamp=None):
|
|
@@ -341,55 +412,79 @@ class MT5Strategy(Strategy):
|
|
|
341
412
|
"""
|
|
342
413
|
for symbol in self.symbols:
|
|
343
414
|
dtime = self.data.get_latest_bar_datetime(symbol)
|
|
415
|
+
logmsg = lambda order, type: self.logger.info(
|
|
416
|
+
f"{type} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
|
|
417
|
+
f"PRICE @ {order.price}", custom_time=dtime)
|
|
344
418
|
for order in self._orders[symbol]['BLMT'].copy():
|
|
345
419
|
if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
|
|
346
420
|
self.buy_mkt(order.strategy_id, symbol,
|
|
347
421
|
order.price, order.quantity, dtime)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
422
|
+
try:
|
|
423
|
+
self._orders[symbol]['BLMT'].remove(order)
|
|
424
|
+
assert order not in self._orders[symbol]['BLMT']
|
|
425
|
+
logmsg(order, 'BUY LIMIT')
|
|
426
|
+
except AssertionError:
|
|
427
|
+
self._orders[symbol]['BLMT'] = [o for o in self._orders[symbol]['BLMT'] if o != order]
|
|
428
|
+
logmsg(order, 'BUY LIMIT')
|
|
352
429
|
for order in self._orders[symbol]['SLMT'].copy():
|
|
353
430
|
if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
|
|
354
431
|
self.sell_mkt(order.strategy_id, symbol,
|
|
355
432
|
order.price, order.quantity, dtime)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
433
|
+
try:
|
|
434
|
+
self._orders[symbol]['SLMT'].remove(order)
|
|
435
|
+
assert order not in self._orders[symbol]['SLMT']
|
|
436
|
+
logmsg(order, 'SELL LIMIT')
|
|
437
|
+
except AssertionError:
|
|
438
|
+
self._orders[symbol]['SLMT'] = [o for o in self._orders[symbol]['SLMT'] if o != order]
|
|
439
|
+
logmsg(order, 'SELL LIMIT')
|
|
360
440
|
for order in self._orders[symbol]['BSTP'].copy():
|
|
361
441
|
if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
|
|
362
442
|
self.buy_mkt(order.strategy_id, symbol,
|
|
363
443
|
order.price, order.quantity, dtime)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
444
|
+
try:
|
|
445
|
+
self._orders[symbol]['BSTP'].remove(order)
|
|
446
|
+
assert order not in self._orders[symbol]['BSTP']
|
|
447
|
+
logmsg(order, 'BUY STOP')
|
|
448
|
+
except AssertionError:
|
|
449
|
+
self._orders[symbol]['BSTP'] = [o for o in self._orders[symbol]['BSTP'] if o != order]
|
|
450
|
+
logmsg(order, 'BUY STOP')
|
|
368
451
|
for order in self._orders[symbol]['SSTP'].copy():
|
|
369
452
|
if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
|
|
370
453
|
self.sell_mkt(order.strategy_id, symbol,
|
|
371
454
|
order.price, order.quantity, dtime)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
455
|
+
try:
|
|
456
|
+
self._orders[symbol]['SSTP'].remove(order)
|
|
457
|
+
assert order not in self._orders[symbol]['SSTP']
|
|
458
|
+
logmsg(order, 'SELL STOP')
|
|
459
|
+
except AssertionError:
|
|
460
|
+
self._orders[symbol]['SSTP'] = [o for o in self._orders[symbol]['SSTP'] if o != order]
|
|
461
|
+
logmsg(order, 'SELL STOP')
|
|
376
462
|
for order in self._orders[symbol]['BSTPLMT'].copy():
|
|
377
463
|
if self.data.get_latest_bar_value(symbol, 'close') >= order.price:
|
|
378
464
|
self.buy_limit(order.strategy_id, symbol,
|
|
379
465
|
order.stoplimit, order.quantity, dtime)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
466
|
+
try:
|
|
467
|
+
self._orders[symbol]['BSTPLMT'].remove(order)
|
|
468
|
+
assert order not in self._orders[symbol]['BSTPLMT']
|
|
469
|
+
logmsg(order, 'BUY STOP LIMIT')
|
|
470
|
+
except AssertionError:
|
|
471
|
+
self._orders[symbol]['BSTPLMT'] = [o for o in self._orders[symbol]['BSTPLMT'] if o != order]
|
|
472
|
+
logmsg(order, 'BUY STOP LIMIT')
|
|
384
473
|
for order in self._orders[symbol]['SSTPLMT'].copy():
|
|
385
474
|
if self.data.get_latest_bar_value(symbol, 'close') <= order.price:
|
|
386
475
|
self.sell_limit(order.strategy_id, symbol,
|
|
387
476
|
order.stoplimit, order.quantity, dtime)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
477
|
+
try:
|
|
478
|
+
self._orders[symbol]['SSTPLMT'].remove(order)
|
|
479
|
+
assert order not in self._orders[symbol]['SSTPLMT']
|
|
480
|
+
logmsg(order, 'SELL STOP LIMIT')
|
|
481
|
+
except AssertionError:
|
|
482
|
+
self._orders[symbol]['SSTPLMT'] = [o for o in self._orders[symbol]['SSTPLMT'] if o != order]
|
|
483
|
+
logmsg(order, 'SELL STOP LIMIT')
|
|
484
|
+
|
|
485
|
+
def calculate_pct_change(self, current_price, lh_price):
|
|
486
|
+
return ((current_price - lh_price) / lh_price) * 100
|
|
487
|
+
|
|
393
488
|
def get_asset_values(self,
|
|
394
489
|
symbol_list: List[str],
|
|
395
490
|
window: int,
|
bbstrader/metatrader/account.py
CHANGED
|
@@ -688,7 +688,7 @@ class Account(object):
|
|
|
688
688
|
"minors": r"\b(Minors?)\b",
|
|
689
689
|
"exotics": r"\b(Exotics?)\b",
|
|
690
690
|
}
|
|
691
|
-
return self._get_symbols_by_category('
|
|
691
|
+
return self._get_symbols_by_category('FX', category, fx_categories)
|
|
692
692
|
|
|
693
693
|
def get_stocks_from_country(self, country_code: str = 'USA', etf=True) -> List[str]:
|
|
694
694
|
"""
|
bbstrader/metatrader/risk.py
CHANGED
|
@@ -156,6 +156,22 @@ class RiskManagement(Account):
|
|
|
156
156
|
self.symbol_info = super().get_symbol_info(self.symbol)
|
|
157
157
|
|
|
158
158
|
self._tf = time_frame
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def dailydd(self) -> float:
|
|
162
|
+
return self.daily_dd
|
|
163
|
+
|
|
164
|
+
@dailydd.setter
|
|
165
|
+
def dailydd(self, value: float):
|
|
166
|
+
self.daily_dd = value
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def maxrisk(self) -> float:
|
|
170
|
+
return self.max_risk
|
|
171
|
+
|
|
172
|
+
@maxrisk.setter
|
|
173
|
+
def maxrisk(self, value: float):
|
|
174
|
+
self.max_risk = value
|
|
159
175
|
|
|
160
176
|
def _convert_time_frame(self, tf: str) -> int:
|
|
161
177
|
"""Convert time frame to minutes"""
|
|
@@ -437,14 +453,23 @@ class RiskManagement(Account):
|
|
|
437
453
|
if trade_risk > 0:
|
|
438
454
|
currency_risk = round(self.var_loss_value(), 5)
|
|
439
455
|
volume = round(currency_risk*laverage)
|
|
440
|
-
|
|
456
|
+
try:
|
|
457
|
+
_lot = round((volume / (contract_size * av_price)), 2)
|
|
458
|
+
except ZeroDivisionError:
|
|
459
|
+
_lot = 0.0
|
|
441
460
|
lot = self._check_lot(_lot)
|
|
442
461
|
if COMD and contract_size > 1:
|
|
443
462
|
# lot = volume / av_price / contract_size
|
|
444
|
-
|
|
463
|
+
try:
|
|
464
|
+
lot = volume / av_price / contract_size
|
|
465
|
+
except ZeroDivisionError:
|
|
466
|
+
lot = 0.0
|
|
445
467
|
lot = self._check_lot(_lot)
|
|
446
468
|
if FX:
|
|
447
|
-
|
|
469
|
+
try:
|
|
470
|
+
__lot = round((volume / contract_size), 2)
|
|
471
|
+
except ZeroDivisionError:
|
|
472
|
+
__lot = 0.0
|
|
448
473
|
lot = self._check_lot(__lot)
|
|
449
474
|
|
|
450
475
|
tick_value = s_info.trade_tick_value
|
bbstrader/models/__init__.py
CHANGED
|
@@ -3,4 +3,8 @@ The `models` module provides a foundational framework for implementing various q
|
|
|
3
3
|
|
|
4
4
|
It is designed to be a versatile base module for different types of models used in financial analysis and trading.
|
|
5
5
|
"""
|
|
6
|
-
from bbstrader.models.risk import *
|
|
6
|
+
from bbstrader.models.risk import *
|
|
7
|
+
from bbstrader.models.optimization import *
|
|
8
|
+
from bbstrader.models.portfolios import *
|
|
9
|
+
from bbstrader.models.factors import *
|
|
10
|
+
from bbstrader.models.ml import *
|
bbstrader/models/optimization.py
CHANGED
|
@@ -6,7 +6,14 @@ from pypfopt.efficient_frontier import EfficientFrontier
|
|
|
6
6
|
from pypfopt.hierarchical_portfolio import HRPOpt
|
|
7
7
|
import warnings
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
__all__ = [
|
|
10
|
+
'markowitz_weights',
|
|
11
|
+
'hierarchical_risk_parity',
|
|
12
|
+
'equal_weighted',
|
|
13
|
+
'optimized_weights'
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
def markowitz_weights(prices=None, rfr=0.0, freq=252):
|
|
10
17
|
"""
|
|
11
18
|
Calculates optimal portfolio weights using Markowitz's mean-variance optimization (Max Sharpe Ratio) with multiple solvers.
|
|
12
19
|
|
|
@@ -45,7 +52,7 @@ def markowitz_weights(prices=None, freq=252):
|
|
|
45
52
|
weight_bounds=(0, 1),
|
|
46
53
|
solver=solver)
|
|
47
54
|
try:
|
|
48
|
-
weights = ef.max_sharpe()
|
|
55
|
+
weights = ef.max_sharpe(risk_free_rate=rfr)
|
|
49
56
|
return ef.clean_weights()
|
|
50
57
|
except Exception as e:
|
|
51
58
|
print(f"Solver {solver} failed with error: {e}")
|
|
@@ -83,7 +90,7 @@ def hierarchical_risk_parity(prices=None, returns=None, freq=252):
|
|
|
83
90
|
if returns is None and prices is None:
|
|
84
91
|
raise ValueError("Either prices or returns must be provided")
|
|
85
92
|
if returns is None:
|
|
86
|
-
returns = prices.pct_change().dropna()
|
|
93
|
+
returns = prices.pct_change().dropna(how='all')
|
|
87
94
|
# Remove duplicate columns and index
|
|
88
95
|
returns = returns.loc[:, ~returns.columns.duplicated()]
|
|
89
96
|
returns = returns.loc[~returns.index.duplicated(keep='first')]
|
|
@@ -128,7 +135,7 @@ def equal_weighted(prices=None, returns=None, round_digits=5):
|
|
|
128
135
|
columns = returns.columns
|
|
129
136
|
return {col: round(1/n, round_digits) for col in columns}
|
|
130
137
|
|
|
131
|
-
def optimized_weights(prices=None, returns=None, freq=252, method='
|
|
138
|
+
def optimized_weights(prices=None, returns=None, rfr=0.0, freq=252, method='equal'):
|
|
132
139
|
"""
|
|
133
140
|
Selects an optimization method to calculate portfolio weights based on user preference.
|
|
134
141
|
|
|
@@ -161,7 +168,7 @@ def optimized_weights(prices=None, returns=None, freq=252, method='markowitz'):
|
|
|
161
168
|
- 'equal': Equal weighting across all assets
|
|
162
169
|
"""
|
|
163
170
|
if method == 'markowitz':
|
|
164
|
-
return markowitz_weights(prices=prices, freq=freq)
|
|
171
|
+
return markowitz_weights(prices=prices, rfr=rfr, freq=freq)
|
|
165
172
|
elif method == 'hrp':
|
|
166
173
|
return hierarchical_risk_parity(prices=prices, returns=returns, freq=freq)
|
|
167
174
|
elif method == 'equal':
|
bbstrader/models/portfolios.py
CHANGED
bbstrader/trading/execution.py
CHANGED
|
@@ -78,10 +78,11 @@ EXIT_SIGNAL_ACTIONS = {
|
|
|
78
78
|
|
|
79
79
|
def _mt5_execution(
|
|
80
80
|
symbol_list, trades_instances, strategy_cls, /,
|
|
81
|
-
mm, trail, stop_trail, trail_after_points, be_plus_points, show_positions_orders,
|
|
82
|
-
|
|
81
|
+
mm, optimizer, trail, stop_trail, trail_after_points, be_plus_points, show_positions_orders,
|
|
82
|
+
iter_time, use_trade_time, period, period_end_action, closing_pnl, trading_days,
|
|
83
83
|
comment, **kwargs):
|
|
84
84
|
symbols = symbol_list.copy()
|
|
85
|
+
time_frame = kwargs.get('time_frame', '15m')
|
|
85
86
|
STRATEGY = kwargs.get('strategy_name')
|
|
86
87
|
mtrades = kwargs.get('max_trades')
|
|
87
88
|
notify = kwargs.get('notify', False)
|
|
@@ -89,6 +90,14 @@ def _mt5_execution(
|
|
|
89
90
|
telegram = kwargs.get('telegram', False)
|
|
90
91
|
bot_token = kwargs.get('bot_token')
|
|
91
92
|
chat_id = kwargs.get('chat_id')
|
|
93
|
+
|
|
94
|
+
def update_risk(weights):
|
|
95
|
+
if weights is not None:
|
|
96
|
+
for symbol in symbols:
|
|
97
|
+
if symbol not in weights:
|
|
98
|
+
continue
|
|
99
|
+
trade = trades_instances[symbol]
|
|
100
|
+
trade.dailydd = round(weights[symbol], 5)
|
|
92
101
|
|
|
93
102
|
def _send_notification(signal):
|
|
94
103
|
send_message(message=signal, notify_me=notify,
|
|
@@ -160,6 +169,8 @@ def _mt5_execution(
|
|
|
160
169
|
try:
|
|
161
170
|
check_mt5_connection()
|
|
162
171
|
signals = strategy.calculate_signals()
|
|
172
|
+
weights = strategy.apply_risk_management(optimizer)
|
|
173
|
+
update_risk(weights)
|
|
163
174
|
except Exception as e:
|
|
164
175
|
logger.error(f"Calculating signal, {e}, STRATEGY={STRATEGY}")
|
|
165
176
|
continue
|
|
@@ -181,7 +192,7 @@ def _mt5_execution(
|
|
|
181
192
|
if signal is not None:
|
|
182
193
|
signal = 'BMKT' if signal == 'LONG' else signal
|
|
183
194
|
signal = 'SMKT' if signal == 'SHORT' else signal
|
|
184
|
-
info = f"SIGNAL = {signal}, SYMBOL={trade.symbol}, STRATEGY={STRATEGY}"
|
|
195
|
+
info = f"SIGNAL = {signal}, SYMBOL={trade.symbol}, STRATEGY={STRATEGY}, TIMEFRAME={time_frame}"
|
|
185
196
|
msg = f"Sending {signal} Order ... SYMBOL={trade.symbol}, STRATEGY={STRATEGY}"
|
|
186
197
|
if signal not in EXIT_SIGNAL_ACTIONS:
|
|
187
198
|
logger.info(info)
|
|
@@ -436,12 +447,12 @@ class MT5ExecutionEngine():
|
|
|
436
447
|
strategy_cls: Strategy,
|
|
437
448
|
/,
|
|
438
449
|
mm: bool = True,
|
|
450
|
+
optimizer: str = 'equal',
|
|
439
451
|
trail: bool = True,
|
|
440
452
|
stop_trail: Optional[int] = None,
|
|
441
453
|
trail_after_points: Optional[int] = None,
|
|
442
454
|
be_plus_points: Optional[int] = None,
|
|
443
455
|
show_positions_orders: bool = False,
|
|
444
|
-
time_frame: str = '15m',
|
|
445
456
|
iter_time: int | float = 5,
|
|
446
457
|
use_trade_time: bool = True,
|
|
447
458
|
period: Literal['day', 'week', 'month'] = 'week',
|
|
@@ -457,8 +468,9 @@ class MT5ExecutionEngine():
|
|
|
457
468
|
trades_instances : Dictionary of Trade instances
|
|
458
469
|
strategy_cls : Strategy class to use for trading
|
|
459
470
|
mm : Enable Money Management. Defaults to False.
|
|
471
|
+
optimizer : Risk management optimizer. Defaults to 'equal'.
|
|
472
|
+
See `bbstrader.models.optimization` module for more information.
|
|
460
473
|
show_positions_orders : Print open positions and orders. Defaults to False.
|
|
461
|
-
time_frame : Time frame to trade. Defaults to '15m'.
|
|
462
474
|
iter_time : Interval to check for signals and `mm`. Defaults to 5.
|
|
463
475
|
use_trade_time : Open trades after the time is completed. Defaults to True.
|
|
464
476
|
period : Period to trade. Defaults to 'week'.
|
|
@@ -468,6 +480,7 @@ class MT5ExecutionEngine():
|
|
|
468
480
|
trading_days : Trading days in a week. Defaults to monday to friday.
|
|
469
481
|
comment: Comment for trades. Defaults to None.
|
|
470
482
|
**kwargs: Additional keyword arguments
|
|
483
|
+
_ time_frame : Time frame to trade. Defaults to '15m'.
|
|
471
484
|
- strategy_name (Optional[str]): Strategy name. Defaults to None.
|
|
472
485
|
- max_trades (Dict[str, int]): Maximum trades per symbol. Defaults to None.
|
|
473
486
|
- notify (bool): Enable notifications. Defaults to False.
|
|
@@ -497,12 +510,12 @@ class MT5ExecutionEngine():
|
|
|
497
510
|
self.trades_instances = trades_instances
|
|
498
511
|
self.strategy_cls = strategy_cls
|
|
499
512
|
self.mm = mm
|
|
513
|
+
self.optimizer = optimizer
|
|
500
514
|
self.trail = trail
|
|
501
515
|
self.stop_trail = stop_trail
|
|
502
516
|
self.trail_after_points = trail_after_points
|
|
503
517
|
self.be_plus_points = be_plus_points
|
|
504
518
|
self.show_positions_orders = show_positions_orders
|
|
505
|
-
self.time_frame = time_frame
|
|
506
519
|
self.iter_time = iter_time
|
|
507
520
|
self.use_trade_time = use_trade_time
|
|
508
521
|
self.period = period
|
|
@@ -519,12 +532,12 @@ class MT5ExecutionEngine():
|
|
|
519
532
|
self.trades_instances,
|
|
520
533
|
self.strategy_cls,
|
|
521
534
|
mm=self.mm,
|
|
535
|
+
optimizer=self.optimizer,
|
|
522
536
|
trail=self.trail,
|
|
523
537
|
stop_trail=self.stop_trail,
|
|
524
538
|
trail_after_points=self.trail_after_points,
|
|
525
539
|
be_plus_points=self.be_plus_points,
|
|
526
540
|
show_positions_orders=self.show_positions_orders,
|
|
527
|
-
time_frame=self.time_frame,
|
|
528
541
|
iter_time=self.iter_time,
|
|
529
542
|
use_trade_time=self.use_trade_time,
|
|
530
543
|
period=self.period,
|
|
@@ -2,31 +2,31 @@ bbstrader/__ini__.py,sha256=rCTy-3g2RlDAgIZ7cSET9-I74MwuCXpp-xGVTFS8NNc,482
|
|
|
2
2
|
bbstrader/config.py,sha256=_AD_Cd-w5zyabm1CBPNGhzcZuSjThB7jyzTcjbrIlUQ,3618
|
|
3
3
|
bbstrader/tseries.py,sha256=qJKLxHnPOjB7dXon-ITK7vU1fAuvl8evzET6lSSnijQ,53572
|
|
4
4
|
bbstrader/btengine/__init__.py,sha256=OaXZTjgDwqWrjPq-CNE4kJkmriKXt9t5pIghW1MDTeo,2911
|
|
5
|
-
bbstrader/btengine/backtest.py,sha256=
|
|
5
|
+
bbstrader/btengine/backtest.py,sha256=A3S84jpGTE_zhguOEGoGu6H_4ws4Iq5sf0n7TZaUYfQ,14615
|
|
6
6
|
bbstrader/btengine/data.py,sha256=A6jUqDnjl-w1OSzbLLPfS1WfJ8Se25AqigJs9pbe0wc,17966
|
|
7
7
|
bbstrader/btengine/event.py,sha256=zF_ST4tcjV5uJJVV1IbRXQgCLbca2R2fmE7A2MaIno4,8748
|
|
8
|
-
bbstrader/btengine/execution.py,sha256=
|
|
9
|
-
bbstrader/btengine/performance.py,sha256=
|
|
10
|
-
bbstrader/btengine/portfolio.py,sha256=
|
|
11
|
-
bbstrader/btengine/strategy.py,sha256=
|
|
8
|
+
bbstrader/btengine/execution.py,sha256=Fs6Hk64DxEOEVzAjsQ3CIVvYifWLLgkDjOixSh_Ghsc,10282
|
|
9
|
+
bbstrader/btengine/performance.py,sha256=WTYzB50lUD5aShPIEebbQPlaC2NVW6VfxdgGHjcIIAw,10707
|
|
10
|
+
bbstrader/btengine/portfolio.py,sha256=wCRmGxaZvihUPlXIlZp9cQo9fqPP-Tk5oALjknMfnos,16055
|
|
11
|
+
bbstrader/btengine/strategy.py,sha256=6IN1KQ-a-IQgbCEOflKTtGh-ouztwsVjik6TuMg6CY0,30210
|
|
12
12
|
bbstrader/metatrader/__init__.py,sha256=OLVOB_EieEb1P72I8V4Vem8kQWJ__D_L3c_wfwqY-9k,211
|
|
13
|
-
bbstrader/metatrader/account.py,sha256=
|
|
13
|
+
bbstrader/metatrader/account.py,sha256=3KeGLZ397kctf3EW_y8n9ENswAMU0tBQJuX_L0VXMrI,53909
|
|
14
14
|
bbstrader/metatrader/rates.py,sha256=1dJHbVqoT41m3EhF0wRe7dSGe5Kf3o5Maskkw-i5qsQ,20810
|
|
15
|
-
bbstrader/metatrader/risk.py,sha256=
|
|
15
|
+
bbstrader/metatrader/risk.py,sha256=8NnH1kRvWd_JLieCpVty6hHKz2awrIQV2c8oykxELh0,26596
|
|
16
16
|
bbstrader/metatrader/trade.py,sha256=uigDah9n_rVJiwSslTAArLP94sde1dxYyGyRVIPPgb4,70210
|
|
17
17
|
bbstrader/metatrader/utils.py,sha256=BTaZun4DKWpCxBBzY0SLQqqz7n_7F_R1F59APfyaa3E,17666
|
|
18
|
-
bbstrader/models/__init__.py,sha256=
|
|
18
|
+
bbstrader/models/__init__.py,sha256=mpxtXYEcE8hwNDbzJf8MRqnBIa2T1voraEk0U0ri53c,437
|
|
19
19
|
bbstrader/models/factors.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
bbstrader/models/ml.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
-
bbstrader/models/optimization.py,sha256=
|
|
22
|
-
bbstrader/models/portfolios.py,sha256=
|
|
21
|
+
bbstrader/models/optimization.py,sha256=JlMsda9L-ADSgw4YPE4o3CsL1Yyxfeahf9kUb-EZqqM,6699
|
|
22
|
+
bbstrader/models/portfolios.py,sha256=dFTZ3maRVY_O3UOIoRlLCbAow3SiLTQYt1q5DNaRUxE,8223
|
|
23
23
|
bbstrader/models/risk.py,sha256=Pm_WoGI-vtPW75fwo_7ptF2Br-xQYBwrAAOIgqDQmy8,15120
|
|
24
24
|
bbstrader/trading/__init__.py,sha256=3CCzV5rQbH8NthjDJhD0_2FABvpiCmkeC9cVeoW7bi4,438
|
|
25
|
-
bbstrader/trading/execution.py,sha256=
|
|
25
|
+
bbstrader/trading/execution.py,sha256=p_SKUziBBDuGiOmuxBsgBvxWu5nDVGZmtQBKw8OoZgE,25967
|
|
26
26
|
bbstrader/trading/scripts.py,sha256=rQmnG_4F_MuUEc96RXpAQT4kXrC-FkscsgHKgDAR_-Y,1902
|
|
27
27
|
bbstrader/trading/strategies.py,sha256=ztKNL4Nmlb-4N8_cq0OJyn3E2cRcdKdKu3FeTbZrHsU,36402
|
|
28
|
-
bbstrader-0.1.
|
|
29
|
-
bbstrader-0.1.
|
|
30
|
-
bbstrader-0.1.
|
|
31
|
-
bbstrader-0.1.
|
|
32
|
-
bbstrader-0.1.
|
|
28
|
+
bbstrader-0.1.93.dist-info/LICENSE,sha256=1EudjwwP2oTJy8Vh0e-Kzv8VZZU95y-t6c3DYhR51uc,1115
|
|
29
|
+
bbstrader-0.1.93.dist-info/METADATA,sha256=XByGtsQ885U8oZpZOR9DPhQjdfi5eHbScJyGlcbbjxM,9932
|
|
30
|
+
bbstrader-0.1.93.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
|
|
31
|
+
bbstrader-0.1.93.dist-info/top_level.txt,sha256=Wwj322jZmxGZ6gD_TdaPiPLjED5ReObm5omerwlmZIg,10
|
|
32
|
+
bbstrader-0.1.93.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|