Qubx 0.6.19__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.20__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/connectors/ccxt/account.py +4 -2
- qubx/connectors/ccxt/data.py +23 -6
- qubx/core/context.py +36 -15
- qubx/core/deque.py +182 -0
- qubx/core/interfaces.py +213 -4
- qubx/core/mixins/processing.py +82 -75
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/health/__init__.py +3 -0
- qubx/health/base.py +668 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/collections.py +220 -0
- qubx/utils/runner/configs.py +8 -0
- qubx/utils/runner/runner.py +18 -2
- {qubx-0.6.19.dist-info → qubx-0.6.20.dist-info}/METADATA +1 -1
- {qubx-0.6.19.dist-info → qubx-0.6.20.dist-info}/RECORD +19 -15
- {qubx-0.6.19.dist-info → qubx-0.6.20.dist-info}/LICENSE +0 -0
- {qubx-0.6.19.dist-info → qubx-0.6.20.dist-info}/WHEEL +0 -0
- {qubx-0.6.19.dist-info → qubx-0.6.20.dist-info}/entry_points.txt +0 -0
qubx/connectors/ccxt/account.py
CHANGED
|
@@ -81,6 +81,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
81
81
|
open_order_backoff: str = "1Min",
|
|
82
82
|
max_position_restore_days: int = 30,
|
|
83
83
|
max_retries: int = 10,
|
|
84
|
+
read_only: bool = False,
|
|
84
85
|
):
|
|
85
86
|
super().__init__(
|
|
86
87
|
account_id=account_id,
|
|
@@ -106,6 +107,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
106
107
|
self._required_instruments = set()
|
|
107
108
|
self._latest_instruments = set()
|
|
108
109
|
self._subscription_manager = None
|
|
110
|
+
self._read_only = read_only
|
|
109
111
|
|
|
110
112
|
def set_subscription_manager(self, manager: ISubscriptionManager) -> None:
|
|
111
113
|
self._subscription_manager = manager
|
|
@@ -439,9 +441,9 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
439
441
|
for order in orders:
|
|
440
442
|
logger.debug(f" :: [SYNC] {order.side} {order.quantity} @ {order.price} ({order.status})")
|
|
441
443
|
else:
|
|
442
|
-
# TODO: think if this should actually be here
|
|
443
444
|
# - we need to cancel the unexpected orders
|
|
444
|
-
|
|
445
|
+
if not self._read_only:
|
|
446
|
+
await self._cancel_unexpected_orders(_open_orders)
|
|
445
447
|
|
|
446
448
|
async def _cancel_unexpected_orders(self, open_orders: dict[str, Order]) -> None:
|
|
447
449
|
_expected_orders = set(self._active_orders.keys())
|
qubx/connectors/ccxt/data.py
CHANGED
|
@@ -20,8 +20,9 @@ from ccxt.pro import Exchange
|
|
|
20
20
|
from qubx import logger
|
|
21
21
|
from qubx.core.basics import CtrlChannel, DataType, Instrument, ITimeProvider, dt_64
|
|
22
22
|
from qubx.core.helpers import BasicScheduler
|
|
23
|
-
from qubx.core.interfaces import IDataProvider
|
|
23
|
+
from qubx.core.interfaces import IDataProvider, IHealthMonitor
|
|
24
24
|
from qubx.core.series import Bar, Quote
|
|
25
|
+
from qubx.health import DummyHealthMonitor
|
|
25
26
|
from qubx.utils.misc import AsyncThreadLoop
|
|
26
27
|
|
|
27
28
|
from .exceptions import CcxtLiquidationParsingError, CcxtSymbolNotRecognized
|
|
@@ -64,12 +65,14 @@ class CcxtDataProvider(IDataProvider):
|
|
|
64
65
|
channel: CtrlChannel,
|
|
65
66
|
max_ws_retries: int = 10,
|
|
66
67
|
warmup_timeout: int = 120,
|
|
68
|
+
health_monitor: IHealthMonitor | None = None,
|
|
67
69
|
):
|
|
68
70
|
self._exchange_id = str(exchange.name)
|
|
69
71
|
self.time_provider = time_provider
|
|
70
72
|
self.channel = channel
|
|
71
73
|
self.max_ws_retries = max_ws_retries
|
|
72
74
|
self._warmup_timeout = warmup_timeout
|
|
75
|
+
self._health_monitor = health_monitor or DummyHealthMonitor()
|
|
73
76
|
|
|
74
77
|
# - create new even loop
|
|
75
78
|
self._exchange = exchange
|
|
@@ -416,11 +419,13 @@ class CcxtDataProvider(IDataProvider):
|
|
|
416
419
|
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
417
420
|
for _, ohlcvs in _data.items():
|
|
418
421
|
for oh in ohlcvs:
|
|
422
|
+
timestamp_ns = oh[0] * 1_000_000
|
|
423
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(timestamp_ns, "ns"))
|
|
419
424
|
channel.send(
|
|
420
425
|
(
|
|
421
426
|
instrument,
|
|
422
427
|
sub_type,
|
|
423
|
-
Bar(
|
|
428
|
+
Bar(timestamp_ns, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7]),
|
|
424
429
|
False, # not historical bar
|
|
425
430
|
)
|
|
426
431
|
)
|
|
@@ -463,7 +468,9 @@ class CcxtDataProvider(IDataProvider):
|
|
|
463
468
|
exch_symbol = trades[0]["symbol"]
|
|
464
469
|
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
465
470
|
for trade in trades:
|
|
466
|
-
|
|
471
|
+
converted_trade = ccxt_convert_trade(trade)
|
|
472
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(converted_trade.time, "ns"))
|
|
473
|
+
channel.send((instrument, sub_type, converted_trade, False))
|
|
467
474
|
|
|
468
475
|
async def un_watch_trades(instruments: list[Instrument]):
|
|
469
476
|
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
@@ -497,6 +504,7 @@ class CcxtDataProvider(IDataProvider):
|
|
|
497
504
|
ob = ccxt_convert_orderbook(ccxt_ob, instrument, levels=depth, tick_size_pct=tick_size_pct)
|
|
498
505
|
if ob is None:
|
|
499
506
|
return
|
|
507
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(ob.time, "ns"))
|
|
500
508
|
quote = ob.to_quote()
|
|
501
509
|
self._last_quotes[instrument] = quote
|
|
502
510
|
channel.send((instrument, sub_type, ob, False))
|
|
@@ -529,6 +537,7 @@ class CcxtDataProvider(IDataProvider):
|
|
|
529
537
|
for exch_symbol, ccxt_ticker in ccxt_tickers.items(): # type: ignore
|
|
530
538
|
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
531
539
|
quote = ccxt_convert_ticker(ccxt_ticker)
|
|
540
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(quote.time, "ns"))
|
|
532
541
|
self._last_quotes[instrument] = quote
|
|
533
542
|
channel.send((instrument, sub_type, quote, False))
|
|
534
543
|
|
|
@@ -562,8 +571,11 @@ class CcxtDataProvider(IDataProvider):
|
|
|
562
571
|
liquidations = await self._exchange.watch_liquidations_for_symbols(symbols)
|
|
563
572
|
for liquidation in liquidations:
|
|
564
573
|
try:
|
|
565
|
-
|
|
566
|
-
|
|
574
|
+
exch_symbol = liquidation["symbol"]
|
|
575
|
+
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
576
|
+
liquidation_event = ccxt_convert_liquidation(liquidation)
|
|
577
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(liquidation_event.time, "ns"))
|
|
578
|
+
channel.send((instrument, sub_type, liquidation_event, False))
|
|
567
579
|
except CcxtLiquidationParsingError:
|
|
568
580
|
logger.debug(f"Could not parse liquidation {liquidation}")
|
|
569
581
|
continue
|
|
@@ -593,12 +605,17 @@ class CcxtDataProvider(IDataProvider):
|
|
|
593
605
|
async def watch_funding_rates():
|
|
594
606
|
funding_rates = await self._exchange.watch_funding_rates() # type: ignore
|
|
595
607
|
instrument_to_funding_rate = {}
|
|
608
|
+
current_time = self.time_provider.time()
|
|
609
|
+
|
|
596
610
|
for symbol, info in funding_rates.items():
|
|
597
611
|
try:
|
|
598
612
|
instrument = ccxt_find_instrument(symbol, self._exchange)
|
|
599
|
-
|
|
613
|
+
funding_rate = ccxt_convert_funding_rate(info)
|
|
614
|
+
instrument_to_funding_rate[instrument] = funding_rate
|
|
615
|
+
self._health_monitor.record_data_arrival(sub_type, dt_64(current_time, "s"))
|
|
600
616
|
except CcxtSymbolNotRecognized:
|
|
601
617
|
continue
|
|
618
|
+
|
|
602
619
|
channel.send((None, sub_type, instrument_to_funding_rate, False))
|
|
603
620
|
|
|
604
621
|
async def un_watch_funding_rates():
|
qubx/core/context.py
CHANGED
|
@@ -4,7 +4,6 @@ from typing import Any, Callable
|
|
|
4
4
|
|
|
5
5
|
from qubx import logger
|
|
6
6
|
from qubx.core.basics import (
|
|
7
|
-
SW,
|
|
8
7
|
AssetBalance,
|
|
9
8
|
CtrlChannel,
|
|
10
9
|
DataType,
|
|
@@ -14,6 +13,7 @@ from qubx.core.basics import (
|
|
|
14
13
|
Order,
|
|
15
14
|
OrderRequest,
|
|
16
15
|
Position,
|
|
16
|
+
Timestamped,
|
|
17
17
|
dt_64,
|
|
18
18
|
)
|
|
19
19
|
from qubx.core.exceptions import StrategyExceededMaxNumberOfRuntimeFailuresError
|
|
@@ -27,6 +27,7 @@ from qubx.core.interfaces import (
|
|
|
27
27
|
IAccountProcessor,
|
|
28
28
|
IBroker,
|
|
29
29
|
IDataProvider,
|
|
30
|
+
IHealthMonitor,
|
|
30
31
|
IMarketManager,
|
|
31
32
|
IMetricEmitter,
|
|
32
33
|
IPositionGathering,
|
|
@@ -45,6 +46,7 @@ from qubx.core.interfaces import (
|
|
|
45
46
|
from qubx.core.loggers import StrategyLogging
|
|
46
47
|
from qubx.data.readers import DataReader
|
|
47
48
|
from qubx.gathering.simplest import SimplePositionGatherer
|
|
49
|
+
from qubx.health import DummyHealthMonitor
|
|
48
50
|
from qubx.trackers.sizers import FixedSizer
|
|
49
51
|
|
|
50
52
|
from .mixins import (
|
|
@@ -102,6 +104,7 @@ class StrategyContext(IStrategyContext):
|
|
|
102
104
|
initializer: BasicStrategyInitializer | None = None,
|
|
103
105
|
strategy_name: str | None = None,
|
|
104
106
|
strategy_state: StrategyState | None = None,
|
|
107
|
+
health_monitor: IHealthMonitor | None = None,
|
|
105
108
|
) -> None:
|
|
106
109
|
self.account = account
|
|
107
110
|
self.strategy = self.__instantiate_strategy(strategy, config)
|
|
@@ -127,6 +130,9 @@ class StrategyContext(IStrategyContext):
|
|
|
127
130
|
self._strategy_state = strategy_state if strategy_state is not None else StrategyState()
|
|
128
131
|
self._strategy_name = strategy_name if strategy_name is not None else strategy.__class__.__name__
|
|
129
132
|
|
|
133
|
+
self._health_monitor = health_monitor or DummyHealthMonitor()
|
|
134
|
+
self.health = self._health_monitor
|
|
135
|
+
|
|
130
136
|
__position_tracker = self.strategy.tracker(self)
|
|
131
137
|
if __position_tracker is None:
|
|
132
138
|
__position_tracker = StrategyContext.DEFAULT_POSITION_TRACKER()
|
|
@@ -180,6 +186,7 @@ class StrategyContext(IStrategyContext):
|
|
|
180
186
|
scheduler=self._scheduler,
|
|
181
187
|
is_simulation=self._data_provider.is_simulation,
|
|
182
188
|
exporter=self._exporter,
|
|
189
|
+
health_monitor=self._health_monitor,
|
|
183
190
|
)
|
|
184
191
|
self.__post_init__()
|
|
185
192
|
|
|
@@ -245,6 +252,9 @@ class StrategyContext(IStrategyContext):
|
|
|
245
252
|
# - start account processing
|
|
246
253
|
self.account.start()
|
|
247
254
|
|
|
255
|
+
# - start health metrics monitor
|
|
256
|
+
self._health_monitor.start()
|
|
257
|
+
|
|
248
258
|
# - update universe with initial instruments after the strategy is initialized
|
|
249
259
|
self.set_universe(self._initial_instruments, skip_callback=True)
|
|
250
260
|
|
|
@@ -299,6 +309,9 @@ class StrategyContext(IStrategyContext):
|
|
|
299
309
|
# - stop account processing
|
|
300
310
|
self.account.stop()
|
|
301
311
|
|
|
312
|
+
# - stop health metrics monitor
|
|
313
|
+
self._health_monitor.stop()
|
|
314
|
+
|
|
302
315
|
# - close logging
|
|
303
316
|
self._logging.close()
|
|
304
317
|
|
|
@@ -513,22 +526,30 @@ class StrategyContext(IStrategyContext):
|
|
|
513
526
|
def __process_incoming_data_loop(self, channel: CtrlChannel):
|
|
514
527
|
logger.info("[StrategyContext] :: Start processing market data")
|
|
515
528
|
while channel.control.is_set():
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
529
|
+
try:
|
|
530
|
+
# - waiting for incoming market data
|
|
531
|
+
instrument, d_type, data, hist = channel.receive()
|
|
532
|
+
|
|
533
|
+
_should_record = isinstance(data, Timestamped) and not hist
|
|
534
|
+
if _should_record:
|
|
535
|
+
self._health_monitor.record_start_processing(d_type, dt_64(data.time, "ns"))
|
|
536
|
+
|
|
537
|
+
if self.process_data(instrument, d_type, data, hist):
|
|
524
538
|
channel.stop()
|
|
525
539
|
break
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
540
|
+
|
|
541
|
+
if _should_record:
|
|
542
|
+
self._health_monitor.record_end_processing(d_type, dt_64(data.time, "ns"))
|
|
543
|
+
|
|
544
|
+
except StrategyExceededMaxNumberOfRuntimeFailuresError:
|
|
545
|
+
channel.stop()
|
|
546
|
+
break
|
|
547
|
+
except Exception as e:
|
|
548
|
+
logger.error(f"Error processing market data: {e}")
|
|
549
|
+
logger.opt(colors=False).error(traceback.format_exc())
|
|
550
|
+
if self._lifecycle_notifier:
|
|
551
|
+
self._lifecycle_notifier.notify_error(self._strategy_name, e)
|
|
552
|
+
# Don't stop the channel here, let it continue processing
|
|
532
553
|
logger.info("[StrategyContext] :: Market data processing stopped")
|
|
533
554
|
|
|
534
555
|
def __instantiate_strategy(self, strategy: IStrategy, config: dict[str, Any] | None) -> IStrategy:
|
qubx/core/deque.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast, fixed-size circular buffer implementation using Numba for performance.
|
|
3
|
+
When the deque is full, pushing new elements overwrites the oldest elements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numba import from_dtype, int32, types
|
|
8
|
+
from numba.experimental import jitclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_deque_class(element_dtype: np.dtype):
|
|
12
|
+
"""
|
|
13
|
+
Dynamically create and return a Deque jitclass that stores elements of the given NumPy dtype.
|
|
14
|
+
|
|
15
|
+
This is a fast, fixed-size circular buffer implementation using Numba for performance.
|
|
16
|
+
When the deque is full, pushing new elements overwrites the oldest elements.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
element_dtype (np.dtype): The NumPy dtype for elements to store
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
jitclass: A compiled Numba class for the deque
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: If the element_dtype is not supported
|
|
26
|
+
"""
|
|
27
|
+
# Determine whether it's a structured dtype or a scalar dtype
|
|
28
|
+
if element_dtype.fields is not None:
|
|
29
|
+
# It's a structured dtype. Convert to a Numba 'record' type
|
|
30
|
+
element_type = from_dtype(element_dtype)
|
|
31
|
+
data_type = element_type[:] # 1D array of that struct
|
|
32
|
+
else:
|
|
33
|
+
# It's a scalar dtype (e.g., float32)
|
|
34
|
+
if element_dtype == np.float32:
|
|
35
|
+
data_type = types.float32[:]
|
|
36
|
+
elif element_dtype == np.float64:
|
|
37
|
+
data_type = types.float64[:]
|
|
38
|
+
elif element_dtype == np.int32:
|
|
39
|
+
data_type = types.int32[:]
|
|
40
|
+
elif element_dtype == np.int64:
|
|
41
|
+
data_type = types.int64[:]
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f"Unsupported scalar dtype: {element_dtype}")
|
|
44
|
+
|
|
45
|
+
# Build the class spec
|
|
46
|
+
spec = [
|
|
47
|
+
("data", data_type),
|
|
48
|
+
("capacity", int32),
|
|
49
|
+
("head", int32),
|
|
50
|
+
("tail", int32),
|
|
51
|
+
("size", int32),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
class Deque:
|
|
55
|
+
def __init__(self, capacity):
|
|
56
|
+
self.data = np.empty(capacity, dtype=element_dtype)
|
|
57
|
+
self.capacity = capacity
|
|
58
|
+
self.head = 0
|
|
59
|
+
self.tail = 0
|
|
60
|
+
self.size = 0
|
|
61
|
+
|
|
62
|
+
def push_back(self, record):
|
|
63
|
+
if self.size == self.capacity:
|
|
64
|
+
# Overwrite oldest from the front
|
|
65
|
+
self.head = (self.head + 1) % self.capacity
|
|
66
|
+
self.size -= 1
|
|
67
|
+
|
|
68
|
+
self.data[self.tail] = record
|
|
69
|
+
self.tail = (self.tail + 1) % self.capacity
|
|
70
|
+
self.size += 1
|
|
71
|
+
|
|
72
|
+
def push_front(self, record):
|
|
73
|
+
if self.size == self.capacity:
|
|
74
|
+
# Overwrite oldest from the back
|
|
75
|
+
self.tail = (self.tail - 1) % self.capacity
|
|
76
|
+
self.size -= 1
|
|
77
|
+
|
|
78
|
+
self.head = (self.head - 1) % self.capacity
|
|
79
|
+
self.data[self.head] = record
|
|
80
|
+
self.size += 1
|
|
81
|
+
|
|
82
|
+
def pop_front(self):
|
|
83
|
+
if self.size == 0:
|
|
84
|
+
raise IndexError("Deque is empty")
|
|
85
|
+
record = self.data[self.head]
|
|
86
|
+
self.head = (self.head + 1) % self.capacity
|
|
87
|
+
self.size -= 1
|
|
88
|
+
return record
|
|
89
|
+
|
|
90
|
+
def pop_back(self):
|
|
91
|
+
if self.size == 0:
|
|
92
|
+
raise IndexError("Deque is empty")
|
|
93
|
+
self.tail = (self.tail - 1) % self.capacity
|
|
94
|
+
record = self.data[self.tail]
|
|
95
|
+
self.size -= 1
|
|
96
|
+
return record
|
|
97
|
+
|
|
98
|
+
def is_empty(self):
|
|
99
|
+
return self.size == 0
|
|
100
|
+
|
|
101
|
+
def is_full(self):
|
|
102
|
+
return self.size == self.capacity
|
|
103
|
+
|
|
104
|
+
def get_size(self):
|
|
105
|
+
return self.size
|
|
106
|
+
|
|
107
|
+
def __len__(self):
|
|
108
|
+
return self.size
|
|
109
|
+
|
|
110
|
+
def front(self):
|
|
111
|
+
if self.size == 0:
|
|
112
|
+
raise IndexError("Deque is empty")
|
|
113
|
+
return self.data[self.head]
|
|
114
|
+
|
|
115
|
+
def back(self):
|
|
116
|
+
if self.size == 0:
|
|
117
|
+
raise IndexError("Deque is empty")
|
|
118
|
+
return self.data[(self.tail - 1) % self.capacity]
|
|
119
|
+
|
|
120
|
+
def __getitem__(self, idx):
|
|
121
|
+
if idx < 0 or idx >= self.size:
|
|
122
|
+
raise IndexError("Index out of bounds")
|
|
123
|
+
return self.data[(self.tail - idx - 1) % self.capacity]
|
|
124
|
+
|
|
125
|
+
def to_array(self):
|
|
126
|
+
"""Return a NumPy array of the current elements in the Deque, from oldest to newest."""
|
|
127
|
+
out = np.empty(self.size, dtype=self.data.dtype)
|
|
128
|
+
for i in range(self.size):
|
|
129
|
+
idx = (self.head + i) % self.capacity
|
|
130
|
+
out[i] = self.data[idx]
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
def push_back_fields(self): ...
|
|
134
|
+
|
|
135
|
+
# Generate push_back_fields(...) for structured dtypes
|
|
136
|
+
if element_dtype.fields is not None:
|
|
137
|
+
field_names = list(element_dtype.fields.keys())
|
|
138
|
+
arg_list = ", ".join(field_names)
|
|
139
|
+
lines = []
|
|
140
|
+
lines.append(f"def push_back_fields(self, {arg_list}):")
|
|
141
|
+
lines.append(" if self.size == self.capacity:")
|
|
142
|
+
lines.append(" self.head = (self.head + 1) % self.capacity")
|
|
143
|
+
lines.append(" self.size -= 1")
|
|
144
|
+
lines.append("")
|
|
145
|
+
for f in field_names:
|
|
146
|
+
lines.append(f" self.data[self.tail]['{f}'] = {f}")
|
|
147
|
+
lines.append(" self.tail = (self.tail + 1) % self.capacity")
|
|
148
|
+
lines.append(" self.size += 1")
|
|
149
|
+
|
|
150
|
+
method_src = "\n".join(lines)
|
|
151
|
+
tmp_ns = {}
|
|
152
|
+
exec(method_src, {}, tmp_ns)
|
|
153
|
+
push_back_fields_func = tmp_ns["push_back_fields"]
|
|
154
|
+
setattr(Deque, "push_back_fields", push_back_fields_func)
|
|
155
|
+
|
|
156
|
+
_Deque = jitclass(spec)(Deque)
|
|
157
|
+
return _Deque
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Pre-compiled deque types for common use cases
|
|
161
|
+
DequeFloat64 = create_deque_class(np.dtype(np.float64))
|
|
162
|
+
DequeFloat32 = create_deque_class(np.dtype(np.float32))
|
|
163
|
+
DequeInt64 = create_deque_class(np.dtype(np.int64))
|
|
164
|
+
DequeInt32 = create_deque_class(np.dtype(np.int32))
|
|
165
|
+
|
|
166
|
+
# Deque type for storing indicator values with timestamps
|
|
167
|
+
DequeIndicator = create_deque_class(
|
|
168
|
+
np.dtype(
|
|
169
|
+
[
|
|
170
|
+
("timestamp", np.int64),
|
|
171
|
+
("value", np.float64),
|
|
172
|
+
],
|
|
173
|
+
align=True,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Instance types for use in other jitclasses
|
|
178
|
+
DequeIndicator_instance = DequeIndicator.class_type.instance_type
|
|
179
|
+
DequeFloat64_instance = DequeFloat64.class_type.instance_type
|
|
180
|
+
DequeFloat32_instance = DequeFloat32.class_type.instance_type
|
|
181
|
+
DequeInt64_instance = DequeInt64.class_type.instance_type
|
|
182
|
+
DequeInt32_instance = DequeInt32.class_type.instance_type
|
qubx/core/interfaces.py
CHANGED
|
@@ -14,7 +14,7 @@ This module includes:
|
|
|
14
14
|
|
|
15
15
|
import traceback
|
|
16
16
|
from dataclasses import dataclass
|
|
17
|
-
from typing import Any, Dict, List, Literal, Protocol, Set, Tuple
|
|
17
|
+
from typing import Any, Callable, Dict, List, Literal, Protocol, Set, Tuple
|
|
18
18
|
|
|
19
19
|
import numpy as np
|
|
20
20
|
import pandas as pd
|
|
@@ -1081,6 +1081,7 @@ class IStrategyContext(
|
|
|
1081
1081
|
broker: IBroker
|
|
1082
1082
|
account: IAccountProcessor
|
|
1083
1083
|
emitter: "IMetricEmitter"
|
|
1084
|
+
health: "IHealthReader"
|
|
1084
1085
|
|
|
1085
1086
|
_strategy_state: StrategyState
|
|
1086
1087
|
|
|
@@ -1208,6 +1209,214 @@ class PositionsTracker:
|
|
|
1208
1209
|
...
|
|
1209
1210
|
|
|
1210
1211
|
|
|
1212
|
+
@dataclass
|
|
1213
|
+
class HealthMetrics:
|
|
1214
|
+
"""
|
|
1215
|
+
Health metrics for system performance.
|
|
1216
|
+
|
|
1217
|
+
All latency values are in milliseconds.
|
|
1218
|
+
Dropped events are reported as events per second.
|
|
1219
|
+
Queue size is the number of events in the processing queue.
|
|
1220
|
+
"""
|
|
1221
|
+
|
|
1222
|
+
queue_size: float = 0.0
|
|
1223
|
+
drop_rate: float = 0.0
|
|
1224
|
+
|
|
1225
|
+
# Arrival latency statistics
|
|
1226
|
+
p50_arrival_latency: float = 0.0
|
|
1227
|
+
p90_arrival_latency: float = 0.0
|
|
1228
|
+
p99_arrival_latency: float = 0.0
|
|
1229
|
+
|
|
1230
|
+
# Queue latency statistics
|
|
1231
|
+
p50_queue_latency: float = 0.0
|
|
1232
|
+
p90_queue_latency: float = 0.0
|
|
1233
|
+
p99_queue_latency: float = 0.0
|
|
1234
|
+
|
|
1235
|
+
# Processing latency statistics
|
|
1236
|
+
p50_processing_latency: float = 0.0
|
|
1237
|
+
p90_processing_latency: float = 0.0
|
|
1238
|
+
p99_processing_latency: float = 0.0
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
class IHealthWriter(Protocol):
|
|
1242
|
+
"""
|
|
1243
|
+
Interface for recording health metrics.
|
|
1244
|
+
"""
|
|
1245
|
+
|
|
1246
|
+
def __call__(self, event_type: str) -> "IHealthWriter":
|
|
1247
|
+
"""
|
|
1248
|
+
Support for context manager usage with event type.
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
event_type: Type of event being timed
|
|
1252
|
+
|
|
1253
|
+
Returns:
|
|
1254
|
+
Self for use in 'with' statement
|
|
1255
|
+
"""
|
|
1256
|
+
...
|
|
1257
|
+
|
|
1258
|
+
def __enter__(self) -> "IHealthWriter":
|
|
1259
|
+
"""Enter context for timing measurement"""
|
|
1260
|
+
...
|
|
1261
|
+
|
|
1262
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
1263
|
+
"""Exit context and record timing"""
|
|
1264
|
+
...
|
|
1265
|
+
|
|
1266
|
+
def record_event_dropped(self, event_type: str) -> None:
|
|
1267
|
+
"""
|
|
1268
|
+
Record that an event was dropped.
|
|
1269
|
+
|
|
1270
|
+
Args:
|
|
1271
|
+
event_type: Type of the dropped event
|
|
1272
|
+
"""
|
|
1273
|
+
...
|
|
1274
|
+
|
|
1275
|
+
def record_data_arrival(self, event_type: str, event_time: dt_64) -> None:
|
|
1276
|
+
"""
|
|
1277
|
+
Record a data arrival time.
|
|
1278
|
+
|
|
1279
|
+
Args:
|
|
1280
|
+
event_type: Type of event (e.g., "order_execution")
|
|
1281
|
+
"""
|
|
1282
|
+
...
|
|
1283
|
+
|
|
1284
|
+
def record_start_processing(self, event_type: str, event_time: dt_64) -> None:
|
|
1285
|
+
"""
|
|
1286
|
+
Record a start processing time.
|
|
1287
|
+
"""
|
|
1288
|
+
...
|
|
1289
|
+
|
|
1290
|
+
def record_end_processing(self, event_type: str, event_time: dt_64) -> None:
|
|
1291
|
+
"""
|
|
1292
|
+
Record a end processing time.
|
|
1293
|
+
"""
|
|
1294
|
+
...
|
|
1295
|
+
|
|
1296
|
+
def set_event_queue_size(self, size: int) -> None:
|
|
1297
|
+
"""
|
|
1298
|
+
Set the current event queue size.
|
|
1299
|
+
|
|
1300
|
+
Args:
|
|
1301
|
+
size: Current size of the event queue
|
|
1302
|
+
"""
|
|
1303
|
+
...
|
|
1304
|
+
|
|
1305
|
+
def watch(self, scope_name: str = "") -> Callable[[Callable], Callable]:
|
|
1306
|
+
"""Decorator function to time a function execution.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
scope_name: Name for the timing scope. If empty string is provided,
|
|
1310
|
+
function's qualified name will be used.
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
Decorator function that times the decorated function.
|
|
1314
|
+
"""
|
|
1315
|
+
...
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
class IHealthReader(Protocol):
|
|
1319
|
+
"""
|
|
1320
|
+
Interface for reading health metrics about system performance.
|
|
1321
|
+
"""
|
|
1322
|
+
|
|
1323
|
+
def get_queue_size(self) -> int:
|
|
1324
|
+
"""
|
|
1325
|
+
Get the current event queue size.
|
|
1326
|
+
|
|
1327
|
+
Returns:
|
|
1328
|
+
Number of events waiting to be processed
|
|
1329
|
+
"""
|
|
1330
|
+
...
|
|
1331
|
+
|
|
1332
|
+
def get_arrival_latency(self, event_type: str, percentile: float = 90) -> float:
|
|
1333
|
+
"""
|
|
1334
|
+
Get latency for a specific event type.
|
|
1335
|
+
|
|
1336
|
+
Args:
|
|
1337
|
+
event_type: Type of event (e.g., "quote", "trade")
|
|
1338
|
+
percentile: Optional percentile (0-100) to retrieve (default: 90)
|
|
1339
|
+
|
|
1340
|
+
Returns:
|
|
1341
|
+
Latency value in milliseconds
|
|
1342
|
+
"""
|
|
1343
|
+
...
|
|
1344
|
+
|
|
1345
|
+
def get_queue_latency(self, event_type: str, percentile: float = 90) -> float:
|
|
1346
|
+
"""
|
|
1347
|
+
Get queue latency for a specific event type.
|
|
1348
|
+
"""
|
|
1349
|
+
...
|
|
1350
|
+
|
|
1351
|
+
def get_processing_latency(self, event_type: str, percentile: float = 90) -> float:
|
|
1352
|
+
"""
|
|
1353
|
+
Get processing latency for a specific event type.
|
|
1354
|
+
"""
|
|
1355
|
+
...
|
|
1356
|
+
|
|
1357
|
+
def get_latency(self, event_type: str, percentile: float = 90) -> float:
|
|
1358
|
+
"""
|
|
1359
|
+
Get end-to-end latency for a specific event type.
|
|
1360
|
+
"""
|
|
1361
|
+
...
|
|
1362
|
+
|
|
1363
|
+
def get_execution_latency(self, scope: str, percentile: float = 90) -> float:
|
|
1364
|
+
"""
|
|
1365
|
+
Get execution latency for a specific scope.
|
|
1366
|
+
"""
|
|
1367
|
+
...
|
|
1368
|
+
|
|
1369
|
+
def get_execution_latencies(self) -> dict[str, float]:
|
|
1370
|
+
"""
|
|
1371
|
+
Get all execution latencies.
|
|
1372
|
+
"""
|
|
1373
|
+
...
|
|
1374
|
+
|
|
1375
|
+
def get_event_frequency(self, event_type: str) -> float:
|
|
1376
|
+
"""
|
|
1377
|
+
Get the events per second for a specific event type.
|
|
1378
|
+
|
|
1379
|
+
Args:
|
|
1380
|
+
event_type: Type of event to get frequency for
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
Events per second
|
|
1384
|
+
"""
|
|
1385
|
+
...
|
|
1386
|
+
|
|
1387
|
+
def get_system_metrics(self) -> HealthMetrics:
|
|
1388
|
+
"""
|
|
1389
|
+
Get system-wide metrics.
|
|
1390
|
+
|
|
1391
|
+
Returns:
|
|
1392
|
+
HealthMetrics:
|
|
1393
|
+
- avg_queue_size: Average queue size in the last window
|
|
1394
|
+
- avg_dropped_events: Average number of dropped events per second
|
|
1395
|
+
- p50_arrival_latency: Median arrival latency (ms)
|
|
1396
|
+
- p90_arrival_latency: 90th percentile arrival latency (ms)
|
|
1397
|
+
- p99_arrival_latency: 99th percentile arrival latency (ms)
|
|
1398
|
+
- p50_queue_latency: Median queue latency (ms)
|
|
1399
|
+
- p90_queue_latency: 90th percentile queue latency (ms)
|
|
1400
|
+
- p99_queue_latency: 99th percentile queue latency (ms)
|
|
1401
|
+
- p50_processing_latency: Median processing latency (ms)
|
|
1402
|
+
- p90_processing_latency: 90th percentile processing latency (ms)
|
|
1403
|
+
- p99_processing_latency: 99th percentile processing latency (ms)
|
|
1404
|
+
"""
|
|
1405
|
+
...
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
class IHealthMonitor(IHealthWriter, IHealthReader):
|
|
1409
|
+
"""Interface for health metrics monitoring that combines writing and reading capabilities."""
|
|
1410
|
+
|
|
1411
|
+
def start(self) -> None:
|
|
1412
|
+
"""Start the health metrics monitor."""
|
|
1413
|
+
...
|
|
1414
|
+
|
|
1415
|
+
def stop(self) -> None:
|
|
1416
|
+
"""Stop the health metrics monitor."""
|
|
1417
|
+
...
|
|
1418
|
+
|
|
1419
|
+
|
|
1211
1420
|
def _unpickle_instance(chain: tuple[type], state: dict):
|
|
1212
1421
|
"""
|
|
1213
1422
|
chain is a tuple of the *original* classes, e.g. (A, B, C).
|
|
@@ -1638,7 +1847,7 @@ class IMetricEmitter:
|
|
|
1638
1847
|
class IStrategyLifecycleNotifier:
|
|
1639
1848
|
"""Interface for notifying about strategy lifecycle events."""
|
|
1640
1849
|
|
|
1641
|
-
def notify_start(self, strategy_name: str, metadata: dict[str,
|
|
1850
|
+
def notify_start(self, strategy_name: str, metadata: dict[str, Any] | None = None) -> None:
|
|
1642
1851
|
"""
|
|
1643
1852
|
Notify that a strategy has started.
|
|
1644
1853
|
|
|
@@ -1648,7 +1857,7 @@ class IStrategyLifecycleNotifier:
|
|
|
1648
1857
|
"""
|
|
1649
1858
|
pass
|
|
1650
1859
|
|
|
1651
|
-
def notify_stop(self, strategy_name: str, metadata: dict[str,
|
|
1860
|
+
def notify_stop(self, strategy_name: str, metadata: dict[str, Any] | None = None) -> None:
|
|
1652
1861
|
"""
|
|
1653
1862
|
Notify that a strategy has stopped.
|
|
1654
1863
|
|
|
@@ -1658,7 +1867,7 @@ class IStrategyLifecycleNotifier:
|
|
|
1658
1867
|
"""
|
|
1659
1868
|
pass
|
|
1660
1869
|
|
|
1661
|
-
def notify_error(self, strategy_name: str, error: Exception, metadata: dict[str,
|
|
1870
|
+
def notify_error(self, strategy_name: str, error: Exception, metadata: dict[str, Any] | None = None) -> None:
|
|
1662
1871
|
"""
|
|
1663
1872
|
Notify that a strategy has encountered an error.
|
|
1664
1873
|
|