Qubx 0.7.4__cp312-cp312-manylinux_2_39_x86_64.whl → 0.7.6__cp312-cp312-manylinux_2_39_x86_64.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 Qubx might be problematic. Click here for more details.
- qubx/backtester/runner.py +9 -4
- qubx/connectors/xlighter/account.py +28 -40
- qubx/connectors/xlighter/broker.py +8 -14
- qubx/connectors/xlighter/parsers.py +7 -31
- qubx/connectors/xlighter/websocket.py +0 -2
- qubx/core/account.py +4 -3
- qubx/core/context.py +23 -13
- qubx/core/interfaces.py +12 -2
- qubx/core/mixins/processing.py +4 -0
- qubx/core/mixins/trading.py +6 -3
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyi +1 -0
- qubx/core/series.pyx +43 -27
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/exporters/slack.py +27 -3
- qubx/notifications/slack.py +20 -21
- qubx/restarts/state_resolvers.py +1 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +59 -1
- qubx/ta/indicators.pyi +13 -0
- qubx/ta/indicators.pyx +214 -18
- qubx/utils/hft/orderbook.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/ringbuffer.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/runner/runner.py +3 -2
- qubx/utils/slack.py +275 -113
- {qubx-0.7.4.dist-info → qubx-0.7.6.dist-info}/METADATA +1 -1
- {qubx-0.7.4.dist-info → qubx-0.7.6.dist-info}/RECORD +30 -30
- {qubx-0.7.4.dist-info → qubx-0.7.6.dist-info}/WHEEL +0 -0
- {qubx-0.7.4.dist-info → qubx-0.7.6.dist-info}/entry_points.txt +0 -0
- {qubx-0.7.4.dist-info → qubx-0.7.6.dist-info}/licenses/LICENSE +0 -0
qubx/backtester/runner.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any
|
|
1
|
+
from typing import Any, cast
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pandas as pd
|
|
@@ -19,6 +19,7 @@ from qubx.core.interfaces import (
|
|
|
19
19
|
IMetricEmitter,
|
|
20
20
|
IStrategy,
|
|
21
21
|
IStrategyContext,
|
|
22
|
+
IStrategyNotifier,
|
|
22
23
|
ITimeProvider,
|
|
23
24
|
StrategyState,
|
|
24
25
|
)
|
|
@@ -59,6 +60,7 @@ class SimulationRunner:
|
|
|
59
60
|
portfolio_log_freq: str
|
|
60
61
|
ctx: IStrategyContext
|
|
61
62
|
logs_writer: InMemoryLogsWriter
|
|
63
|
+
notifier: IStrategyNotifier | None
|
|
62
64
|
|
|
63
65
|
account: CompositeAccountProcessor
|
|
64
66
|
channel: CtrlChannel
|
|
@@ -85,6 +87,7 @@ class SimulationRunner:
|
|
|
85
87
|
emitter: IMetricEmitter | None = None,
|
|
86
88
|
strategy_state: StrategyState | None = None,
|
|
87
89
|
initializer: BasicStrategyInitializer | None = None,
|
|
90
|
+
notifier: IStrategyNotifier | None = None,
|
|
88
91
|
warmup_mode: bool = False,
|
|
89
92
|
):
|
|
90
93
|
"""
|
|
@@ -101,11 +104,12 @@ class SimulationRunner:
|
|
|
101
104
|
"""
|
|
102
105
|
self.setup = setup
|
|
103
106
|
self.data_config = data_config
|
|
104
|
-
self.start = pd.Timestamp(start)
|
|
105
|
-
self.stop = pd.Timestamp(stop)
|
|
107
|
+
self.start = cast(pd.Timestamp, pd.Timestamp(start))
|
|
108
|
+
self.stop = cast(pd.Timestamp, pd.Timestamp(stop))
|
|
106
109
|
self.account_id = account_id
|
|
107
110
|
self.portfolio_log_freq = portfolio_log_freq
|
|
108
111
|
self.emitter = emitter
|
|
112
|
+
self.notifier = notifier
|
|
109
113
|
self.strategy_state = strategy_state if strategy_state is not None else StrategyState()
|
|
110
114
|
self.initializer = initializer
|
|
111
115
|
self.warmup_mode = warmup_mode
|
|
@@ -282,7 +286,7 @@ class SimulationRunner:
|
|
|
282
286
|
else:
|
|
283
287
|
_run = self._process_strategy
|
|
284
288
|
|
|
285
|
-
start, stop = pd.Timestamp(start), pd.Timestamp(stop)
|
|
289
|
+
start, stop = cast(pd.Timestamp, pd.Timestamp(start)), cast(pd.Timestamp, pd.Timestamp(stop))
|
|
286
290
|
total_duration = stop - start
|
|
287
291
|
update_delta = total_duration / 100
|
|
288
292
|
prev_dt = np.datetime64(start)
|
|
@@ -474,6 +478,7 @@ class SimulationRunner:
|
|
|
474
478
|
aux_data_provider=self._aux_data_reader,
|
|
475
479
|
emitter=self.emitter,
|
|
476
480
|
strategy_state=self.strategy_state,
|
|
481
|
+
notifier=self.notifier,
|
|
477
482
|
initializer=self.initializer,
|
|
478
483
|
)
|
|
479
484
|
|
|
@@ -202,15 +202,6 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
202
202
|
# Add fee to position's commission tracking
|
|
203
203
|
position.commissions += deal.fee_amount
|
|
204
204
|
|
|
205
|
-
self.__debug(
|
|
206
|
-
f"Tracked fee for {instrument.symbol}: {deal.fee_amount:.6f} {deal.fee_currency} "
|
|
207
|
-
f"(total commissions: {position.commissions:.6f})"
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
self.__debug(
|
|
211
|
-
f"Processed {len(deals)} deal(s) for {instrument.symbol} - fees tracked, positions synced from account_all"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
205
|
def process_order(self, order: Order, update_locked_value: bool = True) -> None:
|
|
215
206
|
"""
|
|
216
207
|
Override process_order to handle Lighter's server-assigned order IDs.
|
|
@@ -228,8 +219,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
228
219
|
if order.client_id and order.client_id in self._active_orders:
|
|
229
220
|
# Get the existing order stored under client_id
|
|
230
221
|
existing_order = self._active_orders[order.client_id]
|
|
231
|
-
|
|
232
|
-
self.__debug(f"Migrating order: client_id={order.client_id} → server_id={order.id}")
|
|
222
|
+
# self.__debug(f"Migrating order: client_id={order.client_id} → server_id={order.id}")
|
|
233
223
|
|
|
234
224
|
# Remove from old location
|
|
235
225
|
self._active_orders.pop(order.client_id)
|
|
@@ -383,10 +373,13 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
383
373
|
if position.last_update_price == 0 or np.isnan(position.last_update_price):
|
|
384
374
|
position.last_update_price = pos_state.avg_entry_price
|
|
385
375
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
f"@
|
|
389
|
-
|
|
376
|
+
updated_positions_str = "\n\t".join(
|
|
377
|
+
[
|
|
378
|
+
f"{instrument.symbol} --> {pos_state.quantity:+.4f} @ {pos_state.avg_entry_price:.4f}"
|
|
379
|
+
for instrument, pos_state in position_states.items()
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
self.__debug(f"Updated positions:\n\t{updated_positions_str}")
|
|
390
383
|
|
|
391
384
|
if not self._account_positions_initialized:
|
|
392
385
|
self._account_positions_initialized = True
|
|
@@ -399,10 +392,10 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
399
392
|
# False means not a snapshot
|
|
400
393
|
self.channel.send((instrument, "deals", [deal], False))
|
|
401
394
|
|
|
402
|
-
self.__debug(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
395
|
+
# self.__debug(
|
|
396
|
+
# f"Sent deal: {instrument.symbol} {deal.amount:+.4f} @ {deal.price:.4f} "
|
|
397
|
+
# f"fee={deal.fee_amount:.6f} (id={deal.id})"
|
|
398
|
+
# )
|
|
406
399
|
|
|
407
400
|
# Funding payments are handled by the data provider, so I commented them here to avoid double sending
|
|
408
401
|
# Send funding payments through channel
|
|
@@ -419,27 +412,19 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
419
412
|
logger.exception(e)
|
|
420
413
|
|
|
421
414
|
async def _handle_account_all_orders_message(self, message: dict):
|
|
422
|
-
"""
|
|
423
|
-
Handle account_all_orders WebSocket messages.
|
|
424
|
-
|
|
425
|
-
Parses order updates and sends Order objects through channel.
|
|
426
|
-
|
|
427
|
-
Follows CCXT pattern: parse → send through channel → framework handles rest.
|
|
428
|
-
"""
|
|
429
415
|
try:
|
|
430
|
-
# Parse message into list of (Instrument, Order) tuples
|
|
431
416
|
orders = parse_account_all_orders_message(message, self.instrument_loader)
|
|
432
417
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
self.channel.send((instrument, "order", order, False))
|
|
418
|
+
_d_msg = "\n\t".join([f"{order.id} ({order.instrument.symbol})" for order in orders])
|
|
419
|
+
logger.debug(f"Received {len(orders)} orders: \n\t{_d_msg}")
|
|
420
|
+
|
|
421
|
+
for order in orders:
|
|
422
|
+
self.channel.send((order.instrument, "order", order, False))
|
|
438
423
|
|
|
439
|
-
self.__debug(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
)
|
|
424
|
+
# self.__debug(
|
|
425
|
+
# f"Sent order: {instrument.symbol} {order.side} {order.quantity:+.4f} @ {order.price:.4f} "
|
|
426
|
+
# f"[{order.status}] (order_id={order.id})"
|
|
427
|
+
# )
|
|
443
428
|
|
|
444
429
|
except Exception as e:
|
|
445
430
|
self.__error(f"Error handling account_all_orders message: {e}")
|
|
@@ -459,10 +444,13 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
459
444
|
for currency, balance in balances.items():
|
|
460
445
|
self.update_balance(currency, balance.total, balance.locked)
|
|
461
446
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
f"free={balance.free:.2f}, locked={balance.locked:.2f}"
|
|
465
|
-
|
|
447
|
+
updated_balances_str = "\n\t".join(
|
|
448
|
+
[
|
|
449
|
+
f"{currency} --> {balance.total:.2f} (free={balance.free:.2f}, locked={balance.locked:.2f})"
|
|
450
|
+
for currency, balance in balances.items()
|
|
451
|
+
]
|
|
452
|
+
)
|
|
453
|
+
self.__debug(f"Updated balances:\n\t{updated_balances_str}")
|
|
466
454
|
|
|
467
455
|
if not self._account_stats_initialized:
|
|
468
456
|
self._account_stats_initialized = True
|
|
@@ -255,11 +255,6 @@ class LighterBroker(IBroker):
|
|
|
255
255
|
else:
|
|
256
256
|
price = mid_price * (1 - max_slippage)
|
|
257
257
|
|
|
258
|
-
logger.debug(
|
|
259
|
-
f"Market order slippage protection: mid={mid_price:.4f}, "
|
|
260
|
-
f"slippage={max_slippage * 100:.1f}%, protected_price={price:.4f}"
|
|
261
|
-
)
|
|
262
|
-
|
|
263
258
|
except Exception as e:
|
|
264
259
|
raise InvalidOrderParameters(
|
|
265
260
|
f"Failed to calculate market order price for {instrument.symbol}: {e}"
|
|
@@ -329,7 +324,6 @@ class LighterBroker(IBroker):
|
|
|
329
324
|
# This makes it available for cancellation before WebSocket updates arrive
|
|
330
325
|
self.account.process_order(order)
|
|
331
326
|
|
|
332
|
-
logger.info(f"Order submitted via WebSocket: {order_id} ({client_id})")
|
|
333
327
|
return order
|
|
334
328
|
|
|
335
329
|
except Exception as e:
|
|
@@ -370,7 +364,7 @@ class LighterBroker(IBroker):
|
|
|
370
364
|
return abs(hash(order.id)) % (2**56)
|
|
371
365
|
|
|
372
366
|
async def _cancel_order(self, order: Order) -> bool:
|
|
373
|
-
logger.info(f"Canceling order
|
|
367
|
+
logger.info(f"[{order.instrument}] Canceling order @ {order.price} {order.side} {order.quantity} [{order.id}]")
|
|
374
368
|
|
|
375
369
|
try:
|
|
376
370
|
market_id = self.instrument_loader.get_market_id(order.instrument.symbol)
|
|
@@ -388,7 +382,6 @@ class LighterBroker(IBroker):
|
|
|
388
382
|
return False
|
|
389
383
|
|
|
390
384
|
await self.ws_manager.send_tx(tx_type=TX_TYPE_CANCEL_ORDER, tx_info=tx_info, tx_id=f"cancel_{order.id}")
|
|
391
|
-
logger.info(f"Order cancellation submitted via WebSocket: {order.id}")
|
|
392
385
|
return True
|
|
393
386
|
|
|
394
387
|
except Exception as e:
|
|
@@ -408,7 +401,9 @@ class LighterBroker(IBroker):
|
|
|
408
401
|
base_amount_int = int(amount * (10**instrument.size_precision))
|
|
409
402
|
price_int = int(price * (10**instrument.price_precision))
|
|
410
403
|
|
|
411
|
-
logger.debug(
|
|
404
|
+
logger.debug(
|
|
405
|
+
f"[{order.instrument.symbol}] :: Modifying order {order.id}: amount={order.quantity} → {amount}, price={order.price} → {price}"
|
|
406
|
+
)
|
|
412
407
|
|
|
413
408
|
# Step 1: Sign modification transaction locally
|
|
414
409
|
signer = self.client.signer_client
|
|
@@ -442,7 +437,6 @@ class LighterBroker(IBroker):
|
|
|
442
437
|
options=order.options,
|
|
443
438
|
)
|
|
444
439
|
|
|
445
|
-
logger.info(f"Order modification submitted via WebSocket: {order.id}")
|
|
446
440
|
return updated_order
|
|
447
441
|
|
|
448
442
|
except Exception as e:
|
|
@@ -572,10 +566,10 @@ class LighterBroker(IBroker):
|
|
|
572
566
|
else:
|
|
573
567
|
price = mid_price * (1 - max_slippage)
|
|
574
568
|
|
|
575
|
-
logger.debug(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
)
|
|
569
|
+
# logger.debug(
|
|
570
|
+
# f"Market order slippage protection (batch): mid={mid_price:.4f}, "
|
|
571
|
+
# f"slippage={max_slippage * 100:.1f}%, protected_price={price:.4f}"
|
|
572
|
+
# )
|
|
579
573
|
|
|
580
574
|
except Exception as e:
|
|
581
575
|
raise InvalidOrderParameters(
|
|
@@ -185,7 +185,7 @@ def parse_account_tx_message(
|
|
|
185
185
|
|
|
186
186
|
def parse_account_all_orders_message(
|
|
187
187
|
message: dict[str, Any], instrument_loader: LighterInstrumentLoader
|
|
188
|
-
) -> list[
|
|
188
|
+
) -> list[Order]:
|
|
189
189
|
"""
|
|
190
190
|
Parse account_all_orders WebSocket message into list of Orders.
|
|
191
191
|
|
|
@@ -220,24 +220,8 @@ def parse_account_all_orders_message(
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
```
|
|
223
|
-
|
|
224
|
-
Args:
|
|
225
|
-
message: Raw WebSocket message from account_all_orders channel
|
|
226
|
-
instrument_loader: Loader with market_id -> Instrument mapping
|
|
227
|
-
|
|
228
|
-
Returns:
|
|
229
|
-
List of (Instrument, Order) tuples. Empty list if:
|
|
230
|
-
- Message is subscription confirmation (type="subscribed/account_all_orders")
|
|
231
|
-
- No orders in message
|
|
232
|
-
- Orders dict is empty
|
|
233
|
-
|
|
234
|
-
Note:
|
|
235
|
-
- Order.quantity is signed: positive for BUY, negative for SELL
|
|
236
|
-
- Order.quantity represents the original order size (initial_base_amount)
|
|
237
|
-
- Filled/remaining amounts are stored in Order.options dict
|
|
238
|
-
- Lighter order statuses map to Qubx OrderStatus literals
|
|
239
223
|
"""
|
|
240
|
-
orders
|
|
224
|
+
orders = []
|
|
241
225
|
|
|
242
226
|
# Get orders dict (keyed by market_index)
|
|
243
227
|
orders_by_market = message.get("orders", {})
|
|
@@ -262,7 +246,7 @@ def parse_account_all_orders_message(
|
|
|
262
246
|
for order_data in market_orders:
|
|
263
247
|
try:
|
|
264
248
|
order = _parse_lighter_order(order_data, instrument)
|
|
265
|
-
orders.append(
|
|
249
|
+
orders.append(order)
|
|
266
250
|
except Exception as e:
|
|
267
251
|
logger.error(f"Failed to parse order: {e}")
|
|
268
252
|
continue
|
|
@@ -291,9 +275,9 @@ def _parse_lighter_order(order_data: dict[str, Any], instrument: Instrument) ->
|
|
|
291
275
|
client_order_id = str(client_order_id)
|
|
292
276
|
|
|
293
277
|
# Amounts
|
|
294
|
-
initial_amount = float(order_data.get("initial_base_amount", "0"))
|
|
295
|
-
remaining_amount = float(order_data.get("remaining_base_amount", "0"))
|
|
296
|
-
filled_amount = float(order_data.get("filled_base_amount", "0"))
|
|
278
|
+
initial_amount = abs(float(order_data.get("initial_base_amount", "0")))
|
|
279
|
+
remaining_amount = abs(float(order_data.get("remaining_base_amount", "0")))
|
|
280
|
+
filled_amount = abs(float(order_data.get("filled_base_amount", "0")))
|
|
297
281
|
|
|
298
282
|
# Price (may be "0.0000" for market orders)
|
|
299
283
|
price_str = order_data.get("price", "0")
|
|
@@ -343,10 +327,6 @@ def _parse_lighter_order(order_data: dict[str, Any], instrument: Instrument) ->
|
|
|
343
327
|
# Use updated_at as order time (most recent)
|
|
344
328
|
timestamp = pd.Timestamp(updated_at, unit="s").asm8
|
|
345
329
|
|
|
346
|
-
# Calculate cost (filled_amount * avg_price)
|
|
347
|
-
# For simplicity, use order price as avg price
|
|
348
|
-
cost = filled_amount * price if price > 0 else 0
|
|
349
|
-
|
|
350
330
|
# Build options dict with additional info
|
|
351
331
|
options = {
|
|
352
332
|
"initial_amount": initial_amount,
|
|
@@ -363,21 +343,17 @@ def _parse_lighter_order(order_data: dict[str, Any], instrument: Instrument) ->
|
|
|
363
343
|
if trigger_price_str and trigger_price_str != "0.0000":
|
|
364
344
|
options["trigger_price"] = float(trigger_price_str)
|
|
365
345
|
|
|
366
|
-
# Signed quantity: positive for BUY, negative for SELL
|
|
367
|
-
quantity = initial_amount if side == "BUY" else -initial_amount
|
|
368
|
-
|
|
369
346
|
return Order(
|
|
370
347
|
id=order_id,
|
|
371
348
|
type=order_type,
|
|
372
349
|
instrument=instrument,
|
|
373
350
|
time=timestamp,
|
|
374
|
-
quantity=
|
|
351
|
+
quantity=remaining_amount,
|
|
375
352
|
price=price,
|
|
376
353
|
side=side,
|
|
377
354
|
status=status,
|
|
378
355
|
time_in_force=time_in_force,
|
|
379
356
|
client_id=client_order_id,
|
|
380
|
-
cost=cost,
|
|
381
357
|
options=options,
|
|
382
358
|
)
|
|
383
359
|
|
|
@@ -148,7 +148,6 @@ class LighterWebSocketManager(BaseWebSocketManager):
|
|
|
148
148
|
"data": {"id": tx_id, "tx_type": tx_type, "tx_info": tx_info_dict},
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
logger.debug(f"Sending transaction via WebSocket: type={tx_type}, id={tx_id}")
|
|
152
151
|
await self.send(message)
|
|
153
152
|
|
|
154
153
|
# Wait for response (next message should be the tx response)
|
|
@@ -202,7 +201,6 @@ class LighterWebSocketManager(BaseWebSocketManager):
|
|
|
202
201
|
"data": {"id": batch_id, "tx_types": tx_types, "tx_infos": tx_infos_dicts},
|
|
203
202
|
}
|
|
204
203
|
|
|
205
|
-
logger.info(f"Sending transaction batch via WebSocket: count={len(tx_types)}, id={batch_id}")
|
|
206
204
|
await self.send(message)
|
|
207
205
|
|
|
208
206
|
# Return confirmation
|
qubx/core/account.py
CHANGED
|
@@ -246,9 +246,10 @@ class BasicAccountProcessor(IAccountProcessor):
|
|
|
246
246
|
else:
|
|
247
247
|
self._active_orders[order.id] = order
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
if order.id in self._active_orders:
|
|
250
|
+
# - calculate amount locked by this order
|
|
251
|
+
if update_locked_value and order.type == "LIMIT":
|
|
252
|
+
self._lock_limit_order_value(self._active_orders[order.id])
|
|
252
253
|
|
|
253
254
|
if _closed or _cancel:
|
|
254
255
|
# TODO: (LIVE) WE NEED TO THINK HOW TO CLEANUP THIS COLLECTION !!!! -> @DM
|
qubx/core/context.py
CHANGED
|
@@ -314,6 +314,10 @@ class StrategyContext(IStrategyContext):
|
|
|
314
314
|
logger.info(f"[StrategyContext] :: Received {sig_name} signal - initiating graceful shutdown")
|
|
315
315
|
self.stop()
|
|
316
316
|
|
|
317
|
+
@property
|
|
318
|
+
def strategy_name(self) -> str:
|
|
319
|
+
return self._strategy_name or self.strategy.__class__.__name__
|
|
320
|
+
|
|
317
321
|
def start(self, blocking: bool = False):
|
|
318
322
|
if self._is_initialized:
|
|
319
323
|
raise ValueError("Strategy is already started !")
|
|
@@ -333,17 +337,6 @@ class StrategyContext(IStrategyContext):
|
|
|
333
337
|
self._atexit_registered = True
|
|
334
338
|
logger.debug("[StrategyContext] :: Registered atexit handler")
|
|
335
339
|
|
|
336
|
-
# Notify strategy start
|
|
337
|
-
if self._notifier:
|
|
338
|
-
try:
|
|
339
|
-
self._notifier.notify_start(
|
|
340
|
-
{
|
|
341
|
-
"Instruments": [str(i) for i in self._initial_instruments],
|
|
342
|
-
},
|
|
343
|
-
)
|
|
344
|
-
except Exception as e:
|
|
345
|
-
logger.error(f"[StrategyContext] :: Failed to notify strategy start: {e}")
|
|
346
|
-
|
|
347
340
|
# - run cron scheduler
|
|
348
341
|
self._scheduler.run()
|
|
349
342
|
|
|
@@ -365,6 +358,21 @@ class StrategyContext(IStrategyContext):
|
|
|
365
358
|
open_positions = {k: p for k, p in self.get_positions().items() if p.is_open()}
|
|
366
359
|
self._initial_instruments = list(set(open_positions.keys()) | set(self._initial_instruments))
|
|
367
360
|
|
|
361
|
+
# Notify strategy start
|
|
362
|
+
if self._notifier and not self.is_simulation:
|
|
363
|
+
try:
|
|
364
|
+
self._notifier.notify_start(
|
|
365
|
+
{
|
|
366
|
+
"Exchanges": "|".join(self.exchanges),
|
|
367
|
+
"Total Capital": f"${self.get_total_capital():,.0f}",
|
|
368
|
+
"Net Leverage": f"{self.get_net_leverage():.1%}",
|
|
369
|
+
"Open Positions": len(open_positions),
|
|
370
|
+
"Instruments": len(self._initial_instruments),
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"[StrategyContext] :: Failed to notify strategy start: {e}")
|
|
375
|
+
|
|
368
376
|
# - update universe with initial instruments after the strategy is initialized
|
|
369
377
|
self.set_universe(self._initial_instruments, skip_callback=True)
|
|
370
378
|
|
|
@@ -402,7 +410,7 @@ class StrategyContext(IStrategyContext):
|
|
|
402
410
|
# These are the most important callbacks that must always run
|
|
403
411
|
|
|
404
412
|
# Notify strategy stop
|
|
405
|
-
if self._notifier:
|
|
413
|
+
if self._notifier and not self.is_simulation:
|
|
406
414
|
try:
|
|
407
415
|
self._notifier.notify_stop(
|
|
408
416
|
{
|
|
@@ -444,7 +452,9 @@ class StrategyContext(IStrategyContext):
|
|
|
444
452
|
data_provider.close()
|
|
445
453
|
logger.debug(f"[StrategyContext] :: Closed data provider: {type(data_provider).__name__}")
|
|
446
454
|
except Exception as e:
|
|
447
|
-
logger.error(
|
|
455
|
+
logger.error(
|
|
456
|
+
f"[StrategyContext] :: Failed to close data provider {type(data_provider).__name__}: {e}"
|
|
457
|
+
)
|
|
448
458
|
except Exception as e:
|
|
449
459
|
logger.error(f"[StrategyContext] :: Error iterating data providers: {e}")
|
|
450
460
|
|
qubx/core/interfaces.py
CHANGED
|
@@ -942,8 +942,7 @@ class ITradingManager:
|
|
|
942
942
|
|
|
943
943
|
Args:
|
|
944
944
|
instrument: The instrument to get the minimum size for
|
|
945
|
-
amount: The amount to
|
|
946
|
-
price: The price to get the minimum size for
|
|
945
|
+
amount: The amount to be traded to determine if it's position reducing or not
|
|
947
946
|
"""
|
|
948
947
|
...
|
|
949
948
|
|
|
@@ -1453,6 +1452,7 @@ class IStrategyContext(
|
|
|
1453
1452
|
emitter: "IMetricEmitter"
|
|
1454
1453
|
health: "IHealthReader"
|
|
1455
1454
|
notifier: "IStrategyNotifier"
|
|
1455
|
+
strategy_name: str
|
|
1456
1456
|
|
|
1457
1457
|
_strategy_state: StrategyState
|
|
1458
1458
|
|
|
@@ -2352,6 +2352,16 @@ class IStrategy(metaclass=Mixable):
|
|
|
2352
2352
|
"""
|
|
2353
2353
|
return None
|
|
2354
2354
|
|
|
2355
|
+
def on_deals(self, ctx: IStrategyContext, deals: list[Deal]) -> None:
|
|
2356
|
+
"""
|
|
2357
|
+
Called when deals are received.
|
|
2358
|
+
|
|
2359
|
+
Args:
|
|
2360
|
+
ctx: Strategy context.
|
|
2361
|
+
deals: The deals.
|
|
2362
|
+
"""
|
|
2363
|
+
return None
|
|
2364
|
+
|
|
2355
2365
|
def on_error(self, ctx: IStrategyContext, error: BaseErrorEvent) -> None:
|
|
2356
2366
|
"""
|
|
2357
2367
|
Called when an error occurs.
|
qubx/core/mixins/processing.py
CHANGED
|
@@ -940,6 +940,10 @@ class ProcessingManager(IProcessingManager):
|
|
|
940
940
|
account=self._account,
|
|
941
941
|
)
|
|
942
942
|
|
|
943
|
+
# - notify strategy about deals
|
|
944
|
+
if deals:
|
|
945
|
+
self._strategy.on_deals(self._context, deals)
|
|
946
|
+
|
|
943
947
|
# - process active targets: if we got 0 position after executions remove current position from active
|
|
944
948
|
if not self._context.get_position(instrument).is_open():
|
|
945
949
|
self._active_targets.pop(instrument, None)
|
qubx/core/mixins/trading.py
CHANGED
|
@@ -464,9 +464,12 @@ class TradingManager(ITradingManager):
|
|
|
464
464
|
size_adj = instrument.round_size_down(abs(amount))
|
|
465
465
|
min_size = self._get_min_size(instrument, amount)
|
|
466
466
|
if abs(size_adj) < min_size:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
)
|
|
467
|
+
# Try just in case to round up to avoid too small orders
|
|
468
|
+
size_adj = instrument.round_size_up(abs(amount))
|
|
469
|
+
if abs(size_adj) < min_size:
|
|
470
|
+
raise InvalidOrderSize(
|
|
471
|
+
f"[{instrument.symbol}] Attempt to trade size {abs(amount)} less than minimal allowed {min_size} !"
|
|
472
|
+
)
|
|
470
473
|
return size_adj
|
|
471
474
|
|
|
472
475
|
def _adjust_price(self, instrument: Instrument, price: float | None, amount: float) -> float | None:
|
|
Binary file
|
qubx/core/series.pyi
CHANGED
|
@@ -169,6 +169,7 @@ class OHLCV(TimeSeries):
|
|
|
169
169
|
def to_records(self) -> dict: ...
|
|
170
170
|
def to_series(self, length: int | None = None) -> pd.DataFrame: ...
|
|
171
171
|
def pd(self, length: int | None = None) -> pd.DataFrame: ...
|
|
172
|
+
def resample(self, timeframe: str, max_series_length=np.inf) -> "OHLCV": ...
|
|
172
173
|
@staticmethod
|
|
173
174
|
def from_dataframe(df_p: pd.DataFrame, name: str = "ohlc") -> "OHLCV": ...
|
|
174
175
|
|
qubx/core/series.pyx
CHANGED
|
@@ -311,9 +311,20 @@ cdef class TimeSeries:
|
|
|
311
311
|
|
|
312
312
|
def resample(self, timeframe: str, max_series_length=INFINITY, process_every_update=True) -> TimeSeries:
|
|
313
313
|
"""
|
|
314
|
-
Returns resampled series.
|
|
314
|
+
Returns resampled series object. This object is linked to original series so all updates in original series will be propagated to resampled one.
|
|
315
315
|
"""
|
|
316
|
-
|
|
316
|
+
|
|
317
|
+
# - check resampling timeframe
|
|
318
|
+
r_timeframe = recognize_timeframe(timeframe)
|
|
319
|
+
if r_timeframe < self.timeframe:
|
|
320
|
+
raise ValueError("Can't resample to lower timeframe !")
|
|
321
|
+
|
|
322
|
+
# - check if series already has this resampler
|
|
323
|
+
name = self.name + "." + time_delta_to_str(r_timeframe).lower()
|
|
324
|
+
if name in self.indicators:
|
|
325
|
+
return self.indicators[name]
|
|
326
|
+
|
|
327
|
+
return Resampler(name, self, timeframe, max_series_length, process_every_update)
|
|
317
328
|
|
|
318
329
|
def diff(self, int period=1):
|
|
319
330
|
"""
|
|
@@ -520,19 +531,14 @@ cdef class IndicatorOHLC(Indicator):
|
|
|
520
531
|
raise ValueError("Indicator must implement calculate() method")
|
|
521
532
|
|
|
522
533
|
|
|
523
|
-
cdef class
|
|
534
|
+
cdef class Resampler(TimeSeries):
|
|
524
535
|
"""
|
|
525
|
-
|
|
536
|
+
Resampled timeseries helper - convert all updates from underlying series into higher timeframe series (applying 'last' aggreagation logic)
|
|
526
537
|
"""
|
|
527
538
|
|
|
528
539
|
def __init__(
|
|
529
|
-
self, TimeSeries series, timeframe, max_series_length=INFINITY, process_every_update=True
|
|
540
|
+
self, str name, TimeSeries series, timeframe, max_series_length=INFINITY, process_every_update=True
|
|
530
541
|
):
|
|
531
|
-
r_timeframe = recognize_timeframe(timeframe)
|
|
532
|
-
if r_timeframe < series.timeframe:
|
|
533
|
-
raise ValueError("Can't resample to lower timeframe !")
|
|
534
|
-
|
|
535
|
-
name = series.name + "." + time_delta_to_str(r_timeframe).lower()
|
|
536
542
|
super().__init__(name, timeframe, max_series_length, process_every_update)
|
|
537
543
|
|
|
538
544
|
# - attach as indicator - it should receive updates from underlying series
|
|
@@ -1230,7 +1236,10 @@ cdef class OHLCV(TimeSeries):
|
|
|
1230
1236
|
|
|
1231
1237
|
return self._is_new_item
|
|
1232
1238
|
|
|
1233
|
-
cpdef short update_by_bar(
|
|
1239
|
+
cpdef short update_by_bar(
|
|
1240
|
+
self, long long time, double open, double high, double low, double close,
|
|
1241
|
+
double vol_incr=0.0, double b_vol_incr=0.0, double volume_quote=0.0, double bought_volume_quote=0.0, int trade_count=0, short is_incremental=1
|
|
1242
|
+
):
|
|
1234
1243
|
cdef Bar b
|
|
1235
1244
|
cdef Bar l_bar
|
|
1236
1245
|
bar_start_time = floor_t64(time, self.timeframe)
|
|
@@ -1426,7 +1435,17 @@ cdef class OHLCV(TimeSeries):
|
|
|
1426
1435
|
"""
|
|
1427
1436
|
Returns resampled OHLCV series.
|
|
1428
1437
|
"""
|
|
1429
|
-
|
|
1438
|
+
# - check resampling timeframe
|
|
1439
|
+
r_timeframe = recognize_timeframe(timeframe)
|
|
1440
|
+
if r_timeframe < self.timeframe:
|
|
1441
|
+
raise ValueError("Can't resample OHLCV series to lower timeframe !")
|
|
1442
|
+
|
|
1443
|
+
# - check if series already has this resampler
|
|
1444
|
+
name = self.name + "." + time_delta_to_str(r_timeframe).lower() + "_wrapper"
|
|
1445
|
+
if name in self.indicators:
|
|
1446
|
+
return self.indicators[name]
|
|
1447
|
+
|
|
1448
|
+
return ResamplerOHLC(name, self, timeframe, max_series_length)
|
|
1430
1449
|
|
|
1431
1450
|
def to_series(self, length: int | None = None) -> pd.DataFrame:
|
|
1432
1451
|
df = pd.DataFrame({
|
|
@@ -1452,14 +1471,15 @@ cdef class OHLCV(TimeSeries):
|
|
|
1452
1471
|
ValueError(f"Input must be a pandas DataFrame, got {type(df_p).__name__}")
|
|
1453
1472
|
|
|
1454
1473
|
_ohlc = OHLCV(name, infer_series_frequency(df_p).item())
|
|
1474
|
+
_has_count = "count" in df_p.columns
|
|
1455
1475
|
for t in df_p.itertuples():
|
|
1456
1476
|
_ohlc.update_by_bar(
|
|
1457
|
-
t.Index.asm8, t.open, t.high, t.low, t.close,
|
|
1458
|
-
getattr(t, "volume", 0.0),
|
|
1459
|
-
getattr(t, "taker_buy_volume", 0.0),
|
|
1460
|
-
getattr(t, "quote_volume", 0.0),
|
|
1461
|
-
getattr(t, "taker_buy_quote_volume", 0.0),
|
|
1462
|
-
|
|
1477
|
+
time=t.Index.asm8, open=t.open, high=t.high, low=t.low, close=t.close,
|
|
1478
|
+
vol_incr=getattr(t, "volume", 0.0),
|
|
1479
|
+
b_vol_incr=getattr(t, "taker_buy_volume", 0.0),
|
|
1480
|
+
volume_quote=getattr(t, "quote_volume", 0.0),
|
|
1481
|
+
bought_volume_quote=getattr(t, "taker_buy_quote_volume", 0.0),
|
|
1482
|
+
trade_count=t.count if _has_count else 0,
|
|
1463
1483
|
)
|
|
1464
1484
|
return _ohlc
|
|
1465
1485
|
|
|
@@ -1469,7 +1489,7 @@ cdef class OHLCV(TimeSeries):
|
|
|
1469
1489
|
return dict(zip(ts, bs))
|
|
1470
1490
|
|
|
1471
1491
|
|
|
1472
|
-
cdef class
|
|
1492
|
+
cdef class ResamplerOHLC(OHLCV):
|
|
1473
1493
|
"""
|
|
1474
1494
|
Derived resampled OHLCV timeseries - convert all updates from underlying series into higher timeframe (apply 'last' aggregation logic)
|
|
1475
1495
|
|
|
@@ -1477,13 +1497,8 @@ cdef class ResampleOHLC(OHLCV):
|
|
|
1477
1497
|
"""
|
|
1478
1498
|
|
|
1479
1499
|
def __init__(
|
|
1480
|
-
self, OHLCV ohlc, timeframe, max_series_length=INFINITY
|
|
1500
|
+
self, str name, OHLCV ohlc, timeframe, max_series_length=INFINITY
|
|
1481
1501
|
):
|
|
1482
|
-
r_timeframe = recognize_timeframe(timeframe)
|
|
1483
|
-
if r_timeframe < ohlc.timeframe:
|
|
1484
|
-
raise ValueError("Can't resample OHLCV series to lower timeframe !")
|
|
1485
|
-
|
|
1486
|
-
name = ohlc.name + "." + time_delta_to_str(r_timeframe).lower()
|
|
1487
1502
|
super().__init__(name, timeframe, max_series_length)
|
|
1488
1503
|
|
|
1489
1504
|
# - attach a wrapper indicator that will forward Bar updates to this series
|
|
@@ -1502,9 +1517,9 @@ cdef class _ResampleOHLCWrapper:
|
|
|
1502
1517
|
"""
|
|
1503
1518
|
cdef public str name
|
|
1504
1519
|
cdef public OHLCV source
|
|
1505
|
-
cdef public
|
|
1520
|
+
cdef public ResamplerOHLC target
|
|
1506
1521
|
|
|
1507
|
-
def __init__(self, str name, OHLCV source,
|
|
1522
|
+
def __init__(self, str name, OHLCV source, ResamplerOHLC target):
|
|
1508
1523
|
self.name = name
|
|
1509
1524
|
self.source = source
|
|
1510
1525
|
self.target = target
|
|
@@ -1520,6 +1535,7 @@ cdef class _ResampleOHLCWrapper:
|
|
|
1520
1535
|
value.trade_count
|
|
1521
1536
|
)
|
|
1522
1537
|
|
|
1538
|
+
|
|
1523
1539
|
cdef class GenericSeries(TimeSeries):
|
|
1524
1540
|
"""
|
|
1525
1541
|
Generic series for storing any Timestamped data type (Quote, Trade, FundingRate, etc.).
|
|
Binary file
|