Qubx 0.6.40__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.42__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/_nb_magic.py CHANGED
@@ -53,6 +53,7 @@ if runtime_env() in ["notebook", "shell"]:
53
53
  from qubx.core.metrics import ( # noqa: F401
54
54
  chart_signals,
55
55
  drop_symbols,
56
+ extend_trading_results,
56
57
  get_symbol_pnls,
57
58
  pick_symbols,
58
59
  pnl,
@@ -155,9 +155,7 @@ class BasicSimulatedExchange(ISimulatedExchange):
155
155
  if order.id == order_id:
156
156
  return self._process_ome_response(o.cancel_order(order_id))
157
157
 
158
- logger.error(
159
- f"[<y>{self.__class__.__name__}</y>] :: cancel_order :: can't find order with id = 'ValueError{order_id}'!"
160
- )
158
+ logger.warning(f"[<y>{self.__class__.__name__}</y>] :: cancel_order :: can't find order '{order_id}'!")
161
159
  return None
162
160
 
163
161
  ome = self._ome.get(instrument)
qubx/core/helpers.py CHANGED
@@ -20,7 +20,7 @@ from qubx.utils.time import convert_seconds_to_str, convert_tf_str_td64, interva
20
20
 
21
21
  class CachedMarketDataHolder:
22
22
  """
23
- Collected cached data updates from StrategyContext
23
+ Collected cached data updates from market
24
24
  """
25
25
 
26
26
  default_timeframe: np.timedelta64
@@ -35,7 +35,6 @@ class CachedMarketDataHolder:
35
35
  self._last_bar = defaultdict(lambda: None)
36
36
  self._updates = dict()
37
37
  self._instr_to_sub_to_buffer = defaultdict(lambda: defaultdict(lambda: deque(maxlen=max_buffer_size)))
38
- self._ready_instruments = set()
39
38
  if default_timeframe:
40
39
  self.update_default_timeframe(default_timeframe)
41
40
 
@@ -68,19 +67,8 @@ class CachedMarketDataHolder:
68
67
  self._ohlcvs = other._ohlcvs
69
68
  self._updates = other._updates
70
69
  self._instr_to_sub_to_buffer = other._instr_to_sub_to_buffer
71
- self._ready_instruments = set() # reset the ready instruments
72
70
  self._last_bar = defaultdict(lambda: None) # reset the last bar
73
71
 
74
- def is_data_ready(self) -> bool:
75
- """
76
- Check if at least one update was received for all instruments.
77
- """
78
- # Check if we have at least one update for each instrument
79
- if not self._ohlcvs:
80
- return False
81
-
82
- return all(instrument in self._ready_instruments for instrument in self._ohlcvs)
83
-
84
72
  @SW.watch("CachedMarketDataHolder")
85
73
  def get_ohlcv(self, instrument: Instrument, timeframe: str | None = None, max_size: float | int = np.inf) -> OHLCV:
86
74
  tf = convert_tf_str_td64(timeframe) if timeframe else self.default_timeframe
@@ -121,9 +109,6 @@ class CachedMarketDataHolder:
121
109
  if event_type != DataType.OHLC:
122
110
  self._instr_to_sub_to_buffer[instrument][event_type].append(data)
123
111
 
124
- if not is_historical and is_base_data:
125
- self._ready_instruments.add(instrument)
126
-
127
112
  if not update_ohlc:
128
113
  return
129
114
 
qubx/core/metrics.py CHANGED
@@ -21,7 +21,7 @@ from statsmodels.regression.linear_model import OLS
21
21
  from qubx import logger
22
22
  from qubx.core.basics import Instrument
23
23
  from qubx.core.series import OHLCV
24
- from qubx.pandaz.utils import ohlc_resample
24
+ from qubx.pandaz.utils import ohlc_resample, srows
25
25
  from qubx.utils.charting.lookinglass import LookingGlass
26
26
  from qubx.utils.charting.mpl_helpers import sbp
27
27
  from qubx.utils.misc import makedirs, version
@@ -1500,6 +1500,9 @@ def get_symbol_pnls(
1500
1500
 
1501
1501
 
1502
1502
  def combine_sessions(sessions: list[TradingSessionResult], name: str = "Portfolio") -> TradingSessionResult:
1503
+ """
1504
+ DEPRECATED: use extend_trading_results instead
1505
+ """
1503
1506
  session = copy(sessions[0])
1504
1507
  session.name = name
1505
1508
  session.instruments = list(set(chain.from_iterable([e.instruments for e in sessions])))
@@ -1518,6 +1521,45 @@ def combine_sessions(sessions: list[TradingSessionResult], name: str = "Portfoli
1518
1521
  return session
1519
1522
 
1520
1523
 
1524
+ def extend_trading_results(results: list[TradingSessionResult]) -> TradingSessionResult:
1525
+ """
1526
+ Combine multiple trading session results into a single result by extending the sessions.
1527
+ """
1528
+ import os
1529
+
1530
+ pfls, execs, exch, names, instrs, clss = [], [], [], [], [], []
1531
+ cap = 0.0
1532
+
1533
+ for b in sorted(results, key=lambda x: x.start):
1534
+ pfls.append(b.portfolio_log)
1535
+ execs.append(b.executions_log)
1536
+ exch.extend(b.exchanges)
1537
+ names.append(b.name)
1538
+ cap += b.capital if isinstance(b.capital, float) else 0.0 # TODO: add handling dict
1539
+ instrs.extend(b.instruments)
1540
+ clss.append(b.strategy_class)
1541
+ cmn = os.path.commonprefix(names)
1542
+ names = [x[len(cmn) :] for x in names]
1543
+ f_pfls: pd.DataFrame = srows(*pfls, keep="last") # type: ignore
1544
+ f_execs: pd.DataFrame = srows(*execs, keep="last") # type: ignore
1545
+ r = TradingSessionResult(
1546
+ 0,
1547
+ cmn + "-".join(names),
1548
+ start=f_pfls.index[0],
1549
+ stop=f_pfls.index[-1],
1550
+ exchanges=list(set(exch)),
1551
+ capital=cap / len(results), # average capital ???
1552
+ instruments=list(set(instrs)),
1553
+ base_currency=results[0].base_currency,
1554
+ commissions=results[0].commissions, # what if different commissions ???
1555
+ portfolio_log=f_pfls,
1556
+ executions_log=f_execs,
1557
+ signals_log=pd.DataFrame(),
1558
+ strategy_class="-".join(set(clss)), # what if different strategy classes ???
1559
+ )
1560
+ return r
1561
+
1562
+
1521
1563
  def _plt_to_base64() -> str:
1522
1564
  fig = plt.gcf()
1523
1565
 
@@ -66,6 +66,7 @@ class ProcessingManager(IProcessingManager):
66
66
  _pool: ThreadPool | None
67
67
  _trig_bar_freq_nsec: int | None = None
68
68
  _cur_sim_step: int | None = None
69
+ _updated_instruments: set[Instrument] = set()
69
70
 
70
71
  def __init__(
71
72
  self,
@@ -109,6 +110,7 @@ class ProcessingManager(IProcessingManager):
109
110
  }
110
111
  self._strategy_name = strategy.__class__.__name__
111
112
  self._trig_bar_freq_nsec = None
113
+ self._updated_instruments = set()
112
114
 
113
115
  def set_fit_schedule(self, schedule: str) -> None:
114
116
  rule = process_schedule_spec(schedule)
@@ -340,6 +342,12 @@ class ProcessingManager(IProcessingManager):
340
342
  _d_probe,
341
343
  )
342
344
 
345
+ def _is_data_ready(self) -> bool:
346
+ """
347
+ Check if at least one update was received for all instruments in the context.
348
+ """
349
+ return all(instrument in self._updated_instruments for instrument in self._context.instruments)
350
+
343
351
  def __update_base_data(
344
352
  self, instrument: Instrument, event_type: str, data: Timestamped, is_historical: bool = False
345
353
  ) -> bool:
@@ -366,6 +374,9 @@ class ProcessingManager(IProcessingManager):
366
374
  # update trackers, gatherers on base data
367
375
  if not is_historical:
368
376
  if is_base_data:
377
+ # - mark instrument as updated
378
+ self._updated_instruments.add(instrument)
379
+
369
380
  self._account.update_position_price(self._time_provider.time(), instrument, _update)
370
381
  target_positions = self.__process_and_log_target_positions(
371
382
  self._position_tracker.update(self._context, instrument, _update)
@@ -421,13 +432,13 @@ class ProcessingManager(IProcessingManager):
421
432
  pass
422
433
 
423
434
  def _handle_start(self) -> None:
424
- if not self._cache.is_data_ready():
435
+ if not self._is_data_ready():
425
436
  return
426
437
  self._strategy.on_start(self._context)
427
438
  self._context._strategy_state.is_on_start_called = True
428
439
 
429
440
  def _handle_state_resolution(self) -> None:
430
- if not self._cache.is_data_ready():
441
+ if not self._is_data_ready():
431
442
  return
432
443
 
433
444
  resolver = self._context.initializer.get_state_resolver()
@@ -448,7 +459,7 @@ class ProcessingManager(IProcessingManager):
448
459
  resolver(self._context, self._context.get_warmup_positions(), self._context.get_warmup_orders())
449
460
 
450
461
  def _handle_warmup_finished(self) -> None:
451
- if not self._cache.is_data_ready():
462
+ if not self._is_data_ready():
452
463
  return
453
464
  self._strategy.on_warmup_finished(self._context)
454
465
  self._context._strategy_state.is_on_warmup_finished_called = True
@@ -457,7 +468,7 @@ class ProcessingManager(IProcessingManager):
457
468
  """
458
469
  When scheduled fit event is happened - we need to invoke strategy on_fit method
459
470
  """
460
- if not self._cache.is_data_ready():
471
+ if not self._is_data_ready():
461
472
  return
462
473
  self._fit_is_running = True
463
474
  self._run_in_thread_pool(self.__invoke_on_fit)
qubx/data/tardis.py CHANGED
@@ -81,21 +81,25 @@ class TardisCsvDataReader(DataReader):
81
81
  _filt_files = [file for file in _files if t_0 <= file.stem.split("_")[0] <= t_1]
82
82
 
83
83
  tables = []
84
- fieldnames = None
84
+ # fieldnames = None
85
85
  for f_path in _filt_files:
86
- table = csv.read_csv(
87
- f_path,
88
- parse_options=csv.ParseOptions(ignore_empty_lines=True),
89
- )
90
- if not fieldnames:
91
- fieldnames = table.column_names
92
- tables.append(table.to_pandas())
93
-
94
- transform.start_transform(data_id, fieldnames or [], start=start, stop=stop)
95
- raw_data = pd.concat(tables).to_numpy()
96
- transform.process_data(raw_data)
97
-
98
- return transform.collect()
86
+ table = pd.read_csv(f_path)
87
+ tables.append(table)
88
+ # table = csv.read_csv(
89
+ # f_path,
90
+ # parse_options=csv.ParseOptions(ignore_empty_lines=True),
91
+ # )
92
+ # if not fieldnames:
93
+ # fieldnames = table.column_names
94
+ # tables.append(table.to_pandas())
95
+
96
+ return pd.concat(tables)
97
+
98
+ # transform.start_transform(data_id, fieldnames or [], start=start, stop=stop)
99
+ # raw_data = pd.concat(tables).to_numpy()
100
+ # transform.process_data(raw_data)
101
+
102
+ # return transform.collect()
99
103
 
100
104
  def get_exchanges(self) -> list[str]:
101
105
  return [exchange.name for exchange in self.path.iterdir() if exchange.is_dir()]
@@ -13,7 +13,12 @@ class IncrementalFormatter(DefaultFormatter):
13
13
  based on leverage changes.
14
14
  """
15
15
 
16
- def __init__(self, alert_name: str, exchange_mapping: Optional[Dict[str, str]] = None):
16
+ def __init__(
17
+ self,
18
+ alert_name: str,
19
+ exchange_mapping: Optional[Dict[str, str]] = None,
20
+ account: Optional[IAccountViewer] = None
21
+ ):
17
22
  """
18
23
  Initialize the IncrementalFormatter.
19
24
 
@@ -21,12 +26,16 @@ class IncrementalFormatter(DefaultFormatter):
21
26
  alert_name: The name of the alert to include in the messages
22
27
  exchange_mapping: Optional mapping of exchange names to use in messages.
23
28
  If an exchange is not in the mapping, the instrument's exchange is used.
29
+ account: The account viewer to get account information like total capital, leverage, etc.
24
30
  """
25
31
  super().__init__()
26
32
  self.alert_name = alert_name
27
33
  self.exchange_mapping = exchange_mapping or {}
28
34
  self.instrument_leverages: Dict[Instrument, float] = {}
29
35
 
36
+ if account:
37
+ self.instrument_leverages = dict(account.get_leverages())
38
+
30
39
  def format_position_change(
31
40
  self, time: dt_64, instrument: Instrument, price: float, account: IAccountViewer
32
41
  ) -> dict[str, Any]:
@@ -36,6 +36,7 @@ class RedisStreamsExporter(ITradeDataExport):
36
36
  max_stream_length: int = 1000,
37
37
  formatter: Optional[IExportFormatter] = None,
38
38
  max_workers: int = 2,
39
+ account: Optional[IAccountViewer] = None,
39
40
  ):
40
41
  """
41
42
  Initialize the Redis Streams Exporter.
@@ -52,6 +53,7 @@ class RedisStreamsExporter(ITradeDataExport):
52
53
  max_stream_length: Maximum length of each stream
53
54
  formatter: Formatter to use for formatting data (default: DefaultFormatter)
54
55
  max_workers: Maximum number of worker threads for Redis operations
56
+ account: Optional account viewer to get account information like total capital, leverage, etc.
55
57
  """
56
58
  self._redis = redis.from_url(redis_url)
57
59
  self._strategy_name = strategy_name
@@ -71,6 +73,9 @@ class RedisStreamsExporter(ITradeDataExport):
71
73
 
72
74
  self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="redis_exporter")
73
75
 
76
+ if account:
77
+ self._instrument_to_previous_leverage = dict(account.get_leverages())
78
+
74
79
  logger.info(
75
80
  f"[RedisStreamsExporter] Initialized for strategy '{strategy_name}' with "
76
81
  f"signals: {export_signals}, targets: {export_targets}, position_changes: {export_position_changes}"
@@ -201,6 +206,7 @@ class RedisStreamsExporter(ITradeDataExport):
201
206
 
202
207
  previous_leverage = self._instrument_to_previous_leverage.get(instrument, 0.0)
203
208
  new_leverage = account.get_leverage(instrument)
209
+ self._instrument_to_previous_leverage[instrument] = new_leverage
204
210
 
205
211
  try:
206
212
  # Format the leverage change using the formatter
@@ -7,5 +7,13 @@ for various notification channels.
7
7
 
8
8
  from .composite import CompositeLifecycleNotifier
9
9
  from .slack import SlackLifecycleNotifier
10
+ from .throttler import CountBasedThrottler, IMessageThrottler, NoThrottling, TimeWindowThrottler
10
11
 
11
- __all__ = ["CompositeLifecycleNotifier", "SlackLifecycleNotifier"]
12
+ __all__ = [
13
+ "CompositeLifecycleNotifier",
14
+ "SlackLifecycleNotifier",
15
+ "IMessageThrottler",
16
+ "TimeWindowThrottler",
17
+ "CountBasedThrottler",
18
+ "NoThrottling"
19
+ ]
@@ -1,17 +1,18 @@
1
1
  """
2
- Slack Strategy Lifecycle Notifier.
2
+ Slack notifications for strategy lifecycle events.
3
3
 
4
- This module provides an implementation of IStrategyLifecycleNotifier that sends notifications to Slack.
4
+ This module provides a Slack implementation of IStrategyLifecycleNotifier.
5
5
  """
6
6
 
7
7
  import datetime
8
8
  from concurrent.futures import ThreadPoolExecutor
9
- from typing import Dict, Optional
9
+ from typing import Any
10
10
 
11
11
  import requests
12
12
 
13
13
  from qubx import logger
14
14
  from qubx.core.interfaces import IStrategyLifecycleNotifier
15
+ from qubx.notifications.throttler import IMessageThrottler, NoThrottling
15
16
 
16
17
 
17
18
  class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
@@ -30,6 +31,7 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
30
31
  emoji_stop: str = ":checkered_flag:",
31
32
  emoji_error: str = ":rotating_light:",
32
33
  max_workers: int = 1,
34
+ throttler: IMessageThrottler | None = None,
33
35
  ):
34
36
  """
35
37
  Initialize the Slack Lifecycle Notifier.
@@ -40,18 +42,28 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
40
42
  emoji_start: Emoji to use for start events
41
43
  emoji_stop: Emoji to use for stop events
42
44
  emoji_error: Emoji to use for error events
45
+ max_workers: Number of worker threads for posting messages
46
+ throttler: Optional message throttler to prevent flooding
43
47
  """
44
48
  self._webhook_url = webhook_url
45
49
  self._environment = environment
46
50
  self._emoji_start = emoji_start
47
51
  self._emoji_stop = emoji_stop
48
52
  self._emoji_error = emoji_error
53
+ self._throttler = throttler if throttler is not None else NoThrottling()
49
54
 
50
55
  self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="slack_notifier")
51
56
 
52
57
  logger.info(f"[SlackLifecycleNotifier] Initialized for environment '{environment}'")
53
58
 
54
- def _post_to_slack(self, message: str, emoji: str, color: str, metadata: Optional[Dict[str, any]] = None) -> None:
59
+ def _post_to_slack(
60
+ self,
61
+ message: str,
62
+ emoji: str,
63
+ color: str,
64
+ metadata: dict[str, Any] | None = None,
65
+ throttle_key: str | None = None,
66
+ ) -> None:
55
67
  """
56
68
  Submit a notification to be posted to Slack by the worker thread.
57
69
 
@@ -60,15 +72,26 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
60
72
  emoji: Emoji to use in the message
61
73
  color: Color for the message attachment
62
74
  metadata: Optional dictionary with additional fields to include
75
+ throttle_key: Optional key for throttling (if None, no throttling is applied)
63
76
  """
64
77
  try:
78
+ # Check if the message should be throttled
79
+ if throttle_key is not None and not self._throttler.should_send(throttle_key):
80
+ logger.debug(f"[SlackLifecycleNotifier] Throttled message with key '{throttle_key}': {message}")
81
+ return
82
+
65
83
  # Submit the task to the executor
66
- self._executor.submit(self._post_to_slack_impl, message, emoji, color, metadata)
84
+ self._executor.submit(self._post_to_slack_impl, message, emoji, color, metadata, throttle_key)
67
85
  except Exception as e:
68
86
  logger.error(f"[SlackLifecycleNotifier] Failed to queue Slack message: {e}")
69
87
 
70
88
  def _post_to_slack_impl(
71
- self, message: str, emoji: str, color: str, metadata: Optional[Dict[str, any]] = None
89
+ self,
90
+ message: str,
91
+ emoji: str,
92
+ color: str,
93
+ metadata: dict[str, Any] | None = None,
94
+ throttle_key: str | None = None,
72
95
  ) -> bool:
73
96
  """
74
97
  Implementation that actually posts to Slack (called from worker thread).
@@ -78,6 +101,7 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
78
101
  emoji: Emoji to use in the message
79
102
  color: Color for the message attachment
80
103
  metadata: Optional dictionary with additional fields to include
104
+ throttle_key: Optional key used for throttling
81
105
 
82
106
  Returns:
83
107
  bool: True if the post was successful, False otherwise
@@ -107,13 +131,18 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
107
131
 
108
132
  response = requests.post(self._webhook_url, json=data)
109
133
  response.raise_for_status()
134
+
135
+ # Register that we sent the message (for throttling)
136
+ if throttle_key is not None:
137
+ self._throttler.register_sent(throttle_key)
138
+
110
139
  logger.debug(f"[SlackLifecycleNotifier] Successfully posted message: {message}")
111
140
  return True
112
141
  except requests.RequestException as e:
113
142
  logger.error(f"[SlackLifecycleNotifier] Failed to post to Slack: {e}")
114
143
  return False
115
144
 
116
- def notify_start(self, strategy_name: str, metadata: Optional[Dict[str, any]] = None) -> None:
145
+ def notify_start(self, strategy_name: str, metadata: dict[str, Any] | None = None) -> None:
117
146
  """
118
147
  Notify that a strategy has started.
119
148
 
@@ -128,7 +157,7 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
128
157
  except Exception as e:
129
158
  logger.error(f"[SlackLifecycleNotifier] Failed to notify start: {e}")
130
159
 
131
- def notify_stop(self, strategy_name: str, metadata: Optional[Dict[str, any]] = None) -> None:
160
+ def notify_stop(self, strategy_name: str, metadata: dict[str, Any] | None = None) -> None:
132
161
  """
133
162
  Notify that a strategy has stopped.
134
163
 
@@ -143,7 +172,7 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
143
172
  except Exception as e:
144
173
  logger.error(f"[SlackLifecycleNotifier] Failed to notify stop: {e}")
145
174
 
146
- def notify_error(self, strategy_name: str, error: Exception, metadata: Optional[Dict[str, any]] = None) -> None:
175
+ def notify_error(self, strategy_name: str, error: Exception, metadata: dict[str, Any] | None = None) -> None:
147
176
  """
148
177
  Notify that a strategy has encountered an error.
149
178
 
@@ -161,7 +190,11 @@ class SlackLifecycleNotifier(IStrategyLifecycleNotifier):
161
190
  metadata["Error Message"] = str(error)
162
191
 
163
192
  message = f"[{strategy_name}] ALERT: Strategy error in {self._environment}"
164
- self._post_to_slack(message, self._emoji_error, "#FF0000", metadata)
193
+
194
+ # Create a throttle key for this strategy/error type combination
195
+ throttle_key = f"error:{strategy_name}:{type(error).__name__}"
196
+
197
+ self._post_to_slack(message, self._emoji_error, "#FF0000", metadata, throttle_key=throttle_key)
165
198
  logger.debug(f"[SlackLifecycleNotifier] Queued error notification for {strategy_name}")
166
199
  except Exception as e:
167
200
  logger.error(f"[SlackLifecycleNotifier] Failed to notify error: {e}")
@@ -0,0 +1,182 @@
1
+ """
2
+ Message Throttling for Notifications.
3
+
4
+ This module defines interfaces and implementations for throttling
5
+ notification messages to prevent flooding notification channels.
6
+
7
+ Usage Examples:
8
+ 1. Basic TimeWindowThrottler with default settings (allows 1 message per key per 10 seconds):
9
+ ```python
10
+ from qubx.notifications.throttler import TimeWindowThrottler
11
+
12
+ throttler = TimeWindowThrottler()
13
+ if throttler.should_send("error:mystrategy:ValueError"):
14
+ # Send the message
15
+ send_message()
16
+ # Update the throttler
17
+ throttler.register_sent("error:mystrategy:ValueError")
18
+ ```
19
+
20
+ 2. CountBasedThrottler (allows up to N messages per key within a time window):
21
+ ```python
22
+ from qubx.notifications.throttler import CountBasedThrottler
23
+
24
+ # Allow up to 5 messages per minute for each key
25
+ throttler = CountBasedThrottler(max_count=5, window_seconds=60.0)
26
+ ```
27
+
28
+ 3. In a configuration file for SlackLifecycleNotifier:
29
+ ```yaml
30
+ notifiers:
31
+ - notifier: SlackLifecycleNotifier
32
+ parameters:
33
+ webhook_url: ${SLACK_WEBHOOK_URL}
34
+ environment: production
35
+ throttle:
36
+ type: TimeWindow
37
+ window_seconds: 30.0
38
+ ```
39
+ """
40
+
41
+ import time
42
+ from abc import ABC, abstractmethod
43
+
44
+
45
+ class IMessageThrottler(ABC):
46
+ """Interface for message throttlers that can limit the frequency of notifications."""
47
+
48
+ @abstractmethod
49
+ def should_send(self, key: str) -> bool:
50
+ """
51
+ Determine if a message with the given key should be sent based on throttling rules.
52
+
53
+ Args:
54
+ key: A unique identifier for the type of message being sent
55
+ (e.g., "error:{strategy_name}")
56
+
57
+ Returns:
58
+ bool: True if the message should be sent, False if it should be throttled
59
+ """
60
+ pass
61
+
62
+ @abstractmethod
63
+ def register_sent(self, key: str) -> None:
64
+ """
65
+ Register that a message with the given key was sent.
66
+ This updates the internal state of the throttler.
67
+
68
+ Args:
69
+ key: A unique identifier for the type of message that was sent
70
+ """
71
+ pass
72
+
73
+
74
+ class TimeWindowThrottler(IMessageThrottler):
75
+ """
76
+ Throttles messages based on a time window.
77
+
78
+ Only allows one message per key within a specified time window.
79
+ """
80
+
81
+ def __init__(self, window_seconds: float = 10.0):
82
+ """
83
+ Initialize the time window throttler.
84
+
85
+ Args:
86
+ window_seconds: Minimum time between messages with the same key, in seconds
87
+ """
88
+ self._window_seconds = window_seconds
89
+ self._last_sent_times: dict[str, float] = {}
90
+
91
+ def should_send(self, key: str) -> bool:
92
+ """
93
+ Check if a message with the given key should be sent based on the time window.
94
+
95
+ Args:
96
+ key: Message key to check
97
+
98
+ Returns:
99
+ bool: True if enough time has passed since the last message with this key
100
+ """
101
+ current_time = time.time()
102
+ last_sent = self._last_sent_times.get(key, 0)
103
+ return (current_time - last_sent) >= self._window_seconds
104
+
105
+ def register_sent(self, key: str) -> None:
106
+ """
107
+ Register that a message with the given key was sent.
108
+
109
+ Args:
110
+ key: Key of the message that was sent
111
+ """
112
+ self._last_sent_times[key] = time.time()
113
+
114
+
115
+ class CountBasedThrottler(IMessageThrottler):
116
+ """
117
+ Throttles messages based on a count within a time window.
118
+
119
+ Allows a specified number of messages per key within a time window.
120
+ """
121
+
122
+ def __init__(self, max_count: int = 3, window_seconds: float = 60.0):
123
+ """
124
+ Initialize the count-based throttler.
125
+
126
+ Args:
127
+ max_count: Maximum number of messages allowed in the time window
128
+ window_seconds: Time window in seconds
129
+ """
130
+ self._max_count = max_count
131
+ self._window_seconds = window_seconds
132
+ self._message_history: dict[str, list[float]] = {}
133
+
134
+ def should_send(self, key: str) -> bool:
135
+ """
136
+ Check if a message with the given key should be sent based on the count limit.
137
+
138
+ Args:
139
+ key: Message key to check
140
+
141
+ Returns:
142
+ bool: True if the message count is below the limit
143
+ """
144
+ current_time = time.time()
145
+
146
+ # Initialize history for this key if it doesn't exist
147
+ if key not in self._message_history:
148
+ self._message_history[key] = []
149
+
150
+ # Remove timestamps older than the window
151
+ self._message_history[key] = [
152
+ ts for ts in self._message_history[key] if (current_time - ts) < self._window_seconds
153
+ ]
154
+
155
+ # Check if we're under the message count limit
156
+ return len(self._message_history[key]) < self._max_count
157
+
158
+ def register_sent(self, key: str) -> None:
159
+ """
160
+ Register that a message with the given key was sent.
161
+
162
+ Args:
163
+ key: Key of the message that was sent
164
+ """
165
+ current_time = time.time()
166
+
167
+ if key not in self._message_history:
168
+ self._message_history[key] = []
169
+
170
+ self._message_history[key].append(current_time)
171
+
172
+
173
+ class NoThrottling(IMessageThrottler):
174
+ """A throttler implementation that doesn't actually throttle - allows all messages."""
175
+
176
+ def should_send(self, key: str) -> bool:
177
+ """Always returns True, allowing all messages to be sent."""
178
+ return True
179
+
180
+ def register_sent(self, key: str) -> None:
181
+ """No-op implementation."""
182
+ pass
qubx/pandaz/utils.py CHANGED
@@ -23,7 +23,10 @@ def check_frame_columns(x, *args):
23
23
 
24
24
 
25
25
  def rolling_forward_test_split(
26
- x: pd.Series | pd.DataFrame, training_period: int, test_period: int, units: str | None = None
26
+ x: pd.Series | pd.DataFrame,
27
+ training_period: int,
28
+ test_period: int,
29
+ units: str | None = None,
27
30
  ):
28
31
  """
29
32
  Split data into training and testing **rolling** periods.
@@ -51,7 +54,7 @@ def rolling_forward_test_split(
51
54
  :return:
52
55
  """
53
56
  # unit formats from pd.TimeDelta and formats for pd.resample
54
- units_format = {"H": "H", "D": "D", "W": "W", "M": "MS", "Q": "QS", "Y": "AS"}
57
+ units_format = {"H": "h", "D": "d", "W": "W", "M": "MS", "Q": "QS", "Y": "AS", "MIN": "min"}
55
58
 
56
59
  if units:
57
60
  if units.upper() not in units_format:
@@ -4,10 +4,10 @@ Factory functions for creating various components used in strategy running and s
4
4
 
5
5
  import inspect
6
6
  import os
7
- from typing import Any
7
+ from typing import Any, Optional
8
8
 
9
9
  from qubx import logger
10
- from qubx.core.interfaces import IMetricEmitter, IStrategyLifecycleNotifier, ITradeDataExport
10
+ from qubx.core.interfaces import IAccountViewer, IMetricEmitter, IStrategyLifecycleNotifier, ITradeDataExport
11
11
  from qubx.data.composite import CompositeReader
12
12
  from qubx.data.readers import DataReader
13
13
  from qubx.emitters.composite import CompositeMetricEmitter
@@ -180,7 +180,11 @@ def create_data_type_readers(readers_configs: list[TypedReaderConfig] | None) ->
180
180
  return data_type_to_reader
181
181
 
182
182
 
183
- def create_exporters(exporters: list[ExporterConfig] | None, strategy_name: str) -> ITradeDataExport | None:
183
+ def create_exporters(
184
+ exporters: list[ExporterConfig] | None,
185
+ strategy_name: str,
186
+ account: Optional[IAccountViewer] = None,
187
+ ) -> ITradeDataExport | None:
184
188
  """
185
189
  Create exporters from the configuration.
186
190
 
@@ -218,6 +222,9 @@ def create_exporters(exporters: list[ExporterConfig] | None, strategy_name: str)
218
222
  for fmt_key, fmt_value in formatter_args.items():
219
223
  formatter_args[fmt_key] = resolve_env_vars(fmt_value)
220
224
 
225
+ if account and "account" not in formatter_args:
226
+ formatter_args["account"] = account
227
+
221
228
  if formatter_class_name:
222
229
  if "." not in formatter_class_name:
223
230
  formatter_class_name = f"qubx.exporters.formatters.{formatter_class_name}"
@@ -229,6 +236,8 @@ def create_exporters(exporters: list[ExporterConfig] | None, strategy_name: str)
229
236
  # Add strategy_name if the exporter requires it and it's not already provided
230
237
  if "strategy_name" in inspect.signature(exporter_class).parameters and "strategy_name" not in params:
231
238
  params["strategy_name"] = strategy_name
239
+ if account and "account" not in params:
240
+ params["account"] = account
232
241
 
233
242
  # Create the exporter instance
234
243
  exporter = exporter_class(**params)
@@ -259,7 +268,7 @@ def create_lifecycle_notifiers(
259
268
  Create lifecycle notifiers from the configuration.
260
269
 
261
270
  Args:
262
- config: Strategy configuration
271
+ notifiers: List of notifier configurations
263
272
  strategy_name: Name of the strategy
264
273
 
265
274
  Returns:
@@ -283,6 +292,57 @@ def create_lifecycle_notifiers(
283
292
  for key, value in notifier_config.parameters.items():
284
293
  params[key] = resolve_env_vars(value)
285
294
 
295
+ # Create throttler if configured or use default TimeWindowThrottler
296
+ if "SlackLifecycleNotifier" in notifier_class_name and "throttler" not in params:
297
+ # Import here to avoid circular imports
298
+ from qubx.notifications.throttler import TimeWindowThrottler
299
+
300
+ # Create default throttler with 10s window
301
+ default_window = 10.0
302
+ params["throttler"] = TimeWindowThrottler(window_seconds=default_window)
303
+ logger.info(
304
+ f"Using default TimeWindowThrottler with window={default_window}s for {notifier_class_name}"
305
+ )
306
+ elif "throttle" in params:
307
+ throttle_config = params.pop("throttle")
308
+
309
+ if isinstance(throttle_config, dict):
310
+ throttler_type = throttle_config.get("type", "TimeWindow")
311
+ window_seconds = float(throttle_config.get("window_seconds", 10.0))
312
+ max_count = int(throttle_config.get("max_count", 3))
313
+
314
+ if throttler_type.lower() == "timewindow":
315
+ from qubx.notifications.throttler import TimeWindowThrottler
316
+
317
+ throttler = TimeWindowThrottler(window_seconds=window_seconds)
318
+ logger.info(f"Created TimeWindowThrottler with window_seconds={window_seconds}")
319
+ elif throttler_type.lower() == "countbased":
320
+ from qubx.notifications.throttler import CountBasedThrottler
321
+
322
+ throttler = CountBasedThrottler(max_count=max_count, window_seconds=window_seconds)
323
+ logger.info(
324
+ f"Created CountBasedThrottler with max_count={max_count}, window_seconds={window_seconds}"
325
+ )
326
+ elif throttler_type.lower() == "none":
327
+ from qubx.notifications.throttler import NoThrottling
328
+
329
+ throttler = NoThrottling()
330
+ logger.info("Created NoThrottling throttler")
331
+ else:
332
+ logger.warning(f"Unknown throttler type '{throttler_type}', defaulting to TimeWindowThrottler")
333
+ from qubx.notifications.throttler import TimeWindowThrottler
334
+
335
+ throttler = TimeWindowThrottler(window_seconds=window_seconds)
336
+
337
+ params["throttler"] = throttler
338
+ elif isinstance(throttle_config, (int, float)):
339
+ # Simple case: just a window_seconds value
340
+ from qubx.notifications.throttler import TimeWindowThrottler
341
+
342
+ throttler = TimeWindowThrottler(window_seconds=float(throttle_config))
343
+ logger.info(f"Created TimeWindowThrottler with window_seconds={throttle_config}")
344
+ params["throttler"] = throttler
345
+
286
346
  # Create the notifier instance
287
347
  notifier = notifier_class(**params)
288
348
  _notifiers.append(notifier)
@@ -261,9 +261,6 @@ def create_strategy_context(
261
261
 
262
262
  _aux_reader = construct_reader(config.aux) if config.aux else None
263
263
 
264
- # Create exporters if configured
265
- _exporter = create_exporters(config.exporters, stg_name)
266
-
267
264
  # Create metric emitters
268
265
  _metric_emitter = create_metric_emitters(config.emission, stg_name) if config.emission else None
269
266
 
@@ -335,6 +332,9 @@ def create_strategy_context(
335
332
  )
336
333
  _initializer = BasicStrategyInitializer(simulation=_exchange_to_data_provider[exchanges[0]].is_simulation)
337
334
 
335
+ # Create exporters if configured
336
+ _exporter = create_exporters(config.exporters, stg_name, _account)
337
+
338
338
  logger.info(f"- Strategy: <blue>{stg_name}</blue>\n- Mode: {_run_mode}\n- Parameters: {config.parameters}")
339
339
  ctx = StrategyContext(
340
340
  strategy=_strategy_class, # type: ignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: Qubx
3
- Version: 0.6.40
3
+ Version: 0.6.42
4
4
  Summary: Qubx - Quantitative Trading Framework
5
5
  Author: Dmitry Marienko
6
6
  Author-email: dmitry.marienko@xlydian.com
@@ -1,5 +1,5 @@
1
1
  qubx/__init__.py,sha256=GBvbyDpm2yCMJVmGW66Jo0giLOUsKKldDGcVA_r9Ohc,8294
2
- qubx/_nb_magic.py,sha256=kcYn8qNb8O223ZRPpq30_n5e__lD5GSVcd0U_jhfnbM,3019
2
+ qubx/_nb_magic.py,sha256=G3LkaX_-gN5Js6xl7rjaahSs_u3dVPDRCZW0IIxPCb0,3051
3
3
  qubx/backtester/__init__.py,sha256=OhXhLmj2x6sp6k16wm5IPATvv-E2qRZVIcvttxqPgcg,176
4
4
  qubx/backtester/account.py,sha256=0yvE06icSeK2ymovvaKkuftY8Ou3Z7Y2JrDa6VtkINw,3048
5
5
  qubx/backtester/broker.py,sha256=JMasxycLqCT99NxN50uyQ1uxtpHYL0wpp4sJ3hB6v2M,2688
@@ -9,7 +9,7 @@ qubx/backtester/ome.py,sha256=Uf3wqyVjUEpm1jrDQ4PE77E3R3B7wJJy1vaOOmvQqWg,15610
9
9
  qubx/backtester/optimization.py,sha256=HHUIYA6Y66rcOXoePWFOuOVX9iaHGKV0bGt_4d5e6FM,7619
10
10
  qubx/backtester/runner.py,sha256=TnNM0t8PgBE_gnCOZZTIOc28a3RqtXmp2Xj4Gq5j6bo,20504
11
11
  qubx/backtester/simulated_data.py,sha256=niujaMRj__jf4IyzCZrSBR5ZoH1VUbvsZHSewHftdmI,17240
12
- qubx/backtester/simulated_exchange.py,sha256=ATGcJXnKdD47kUwgbc5tvPVL0tq4_-6jpgsTTAMxW3c,8124
12
+ qubx/backtester/simulated_exchange.py,sha256=Xg0yv21gq4q9CeCeZoupcenNEBORrxpb93ONZEGL2xk,8076
13
13
  qubx/backtester/simulator.py,sha256=cSbW42X-YlAutZlOQ3Y4mAJWXr_1WomYprtWZVMe3Uk,9225
14
14
  qubx/backtester/utils.py,sha256=nHrgKcIkyp5gz8wrPwMp1fRItUtQfvOPjxZhcaCwN-o,32729
15
15
  qubx/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -40,23 +40,23 @@ qubx/core/context.py,sha256=ZetAOnQ9pfUiMAFln02zJr4FePTjKaY5OSIVwaMPhnE,22822
40
40
  qubx/core/deque.py,sha256=3PsmJ5LF76JpsK4Wp5LLogyE15rKn6EDCkNOOWT6EOk,6203
41
41
  qubx/core/errors.py,sha256=LENtlgmVzxxUFNCsuy4PwyHYhkZkxuZQ2BPif8jaGmw,1411
42
42
  qubx/core/exceptions.py,sha256=11wQC3nnNLsl80zBqbE6xiKCqm31kctqo6W_gdnZkg8,581
43
- qubx/core/helpers.py,sha256=qzRsttt4sMYMarDWMzWvc3b2W-Qp9qAQwFiQBljAsA0,19722
43
+ qubx/core/helpers.py,sha256=m7JrZaBckXHb4zjhKpdCbxFe3kfda-SCNLfagyq7Ve4,19158
44
44
  qubx/core/initializer.py,sha256=PUiD_cIjvGpuPjYyRpUjpwm3xNQ2Kipa8bAhbtxCQRo,3935
45
45
  qubx/core/interfaces.py,sha256=CzIl8tB6ImQkDcZEmhpstwHPOCY8NhZxXmBHLQUAieI,58253
46
46
  qubx/core/loggers.py,sha256=0g33jfipGFShSMrXBoYVzL0GfTzI36mwBJqHNUHmhdo,13342
47
47
  qubx/core/lookups.py,sha256=n5ZjjEhhRvmidCB-Cubr1b0Opm6lf_QVZNEWa_BOQG0,19376
48
- qubx/core/metrics.py,sha256=3zB4XISayK_rF4RkOB3QMMshg8IFq_Z0Xqwcf_Y5bhg,58713
48
+ qubx/core/metrics.py,sha256=IUTVDfO7HW68GGoLsj6kkLz8ueg-bnXRiWflIts8K_w,60264
49
49
  qubx/core/mixins/__init__.py,sha256=AMCLvfNuIb1kkQl3bhCj9jIOEl2eKcVPJeyLgrkB-rk,329
50
50
  qubx/core/mixins/market.py,sha256=lBappEimPhIuI0vmUvwVlIztkYjlEjJBpP-AdpfudII,3948
51
- qubx/core/mixins/processing.py,sha256=dqehukrfqcLy5BeILKnkpHCvva4SbLKj1ZbQdnByu1k,24552
51
+ qubx/core/mixins/processing.py,sha256=cmjD0PcQv3gFP6oILfNgdNgw7Tez0fUZu_nFn6680VI,24979
52
52
  qubx/core/mixins/subscription.py,sha256=V_g9wCPQ8S5SHkU-qOZ84cV5nReAUrV7DoSNAGG0LPY,10372
53
53
  qubx/core/mixins/trading.py,sha256=idfRPaqrvkfMxzu9mXr9i_xfqLee-ZAOrERxkxv6Ruo,7256
54
54
  qubx/core/mixins/universe.py,sha256=L3s2Jw46_J1iDh4622Gk_LvCjol4W7mflBwEHrLfZEw,9899
55
- qubx/core/series.cpython-312-x86_64-linux-gnu.so,sha256=vh0iqqRGDBZy1F9LjMMjhF3txG_zcB7J4pu0XPYmDdc,978280
55
+ qubx/core/series.cpython-312-x86_64-linux-gnu.so,sha256=8DijkxPrz0lEvZCp81RfOj5oaxVt_0YTq0ihU5UtIVs,978280
56
56
  qubx/core/series.pxd,sha256=jBdMwgO8J4Zrue0e_xQ5RlqTXqihpzQNu6V3ckZvvpY,3978
57
57
  qubx/core/series.pyi,sha256=RaHm_oHHiWiNUMJqVfx5FXAXniGLsHxUFOUpacn7GC0,4604
58
58
  qubx/core/series.pyx,sha256=7cM3zZThW59waHiYcZmMxvYj-HYD7Ej_l7nKA4emPjE,46477
59
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so,sha256=9Wj1t1JoF9F3ic-tCyzlC2F8m1nF_HOIHIqHdLjywwo,86568
59
+ qubx/core/utils.cpython-312-x86_64-linux-gnu.so,sha256=pP6PczrIK0qymrZDt2sywmhnkhCZqyPpzxT_4680uFU,86568
60
60
  qubx/core/utils.pyi,sha256=a-wS13V2p_dM1CnGq40JVulmiAhixTwVwt0ah5By0Hc,348
61
61
  qubx/core/utils.pyx,sha256=k5QHfEFvqhqWfCob89ANiJDKNG8gGbOh-O4CVoneZ8M,1696
62
62
  qubx/data/__init__.py,sha256=ELZykvpPGWc5rX7QoNyNQwMLgdKMG8MACOByA4pM5hA,549
@@ -65,7 +65,7 @@ qubx/data/helpers.py,sha256=VcXBl1kfWzAOqrjadKrP9WemGjJIB0q3xascbesErh4,16268
65
65
  qubx/data/hft.py,sha256=be7AwzTOjqqCENn0ClrZoHDyKv3SFG66IyTp8QadHlM,33687
66
66
  qubx/data/readers.py,sha256=H68n38VLMjjk8R5FW7URGLcJCh0MREKFGdMGgWCWzhU,62503
67
67
  qubx/data/registry.py,sha256=45mjy5maBSO6cf-0zfIRRDs8b0VDW7wHSPn43aRjv-o,3883
68
- qubx/data/tardis.py,sha256=VMw13tIIlrGZwermKvdFRSNtLUiJDGOKW4l6WuAMQSA,33747
68
+ qubx/data/tardis.py,sha256=O-zglpusmO6vCY3arSOgH6KUbkfPajSAIQfMKlVmh_E,33878
69
69
  qubx/emitters/__init__.py,sha256=tpJ9OoW-gycTBXGJ0647tT8-dVBmq23T2wMX_kmk3nM,565
70
70
  qubx/emitters/base.py,sha256=z0CiEnIGkizd-4Btvq9Auxg3BpnkKN6M8-ksAH2enQc,7745
71
71
  qubx/emitters/composite.py,sha256=8DsPIUtaJ95Oww9QTVVB6LR7Wcb6TJ-c1jIHMGuttz4,2784
@@ -76,9 +76,9 @@ qubx/exporters/__init__.py,sha256=7HeYHCZfKAaBVAByx9wE8DyGv6C55oeED9uUphcyjuc,36
76
76
  qubx/exporters/composite.py,sha256=c45XcMC0dsIDwOyOxxCuiyYQjUNhqPjptAulbaSqttU,2973
77
77
  qubx/exporters/formatters/__init__.py,sha256=La9rMsl3wyplza0xVyAFrUwhFyrGDIMJWmOB_boJyIg,488
78
78
  qubx/exporters/formatters/base.py,sha256=j381c-JgjUnUHJF7k1J1MPeHB0sFDC9xNcFt2jZNhNY,5671
79
- qubx/exporters/formatters/incremental.py,sha256=tzjD4eWzane_MqCI_s9EFMy4ZurLwWqpL0Uaitv-wS4,4918
79
+ qubx/exporters/formatters/incremental.py,sha256=06r7LId5LoZD5VY7V0VQE4q3h2t4VxlP2SgF8j3xua0,5209
80
80
  qubx/exporters/formatters/slack.py,sha256=MPjbEFh7PQufPdkg_Fwiu2tVw5zYJa977tCemoI790Y,7017
81
- qubx/exporters/redis_streams.py,sha256=8Cd39kAXUYSOS6-dQMSm1PpeQ4urOGVq0oe3dAXwUEI,8924
81
+ qubx/exporters/redis_streams.py,sha256=ywbZrrqJDbMmV4QIW_XHBFh8NZu8gbOb47P3WH6UXIk,9257
82
82
  qubx/exporters/slack.py,sha256=wnVZRwWOKq9lMQyW0MWh_6gkW1id1TUanfOKy-_clwI,7723
83
83
  qubx/features/__init__.py,sha256=ZFCX7K5bDAH7yTsG-mf8zibW8UW8GCneEagL1_p8kDQ,385
84
84
  qubx/features/core.py,sha256=eXa1qIu-LXo40td1X4EUBFQ5jJcSTuaQIi-562bPCoM,10587
@@ -96,12 +96,13 @@ qubx/loggers/inmemory.py,sha256=49Y-jsRxDzBLWQdQMIKjVTvnx_79EbjFpHwj3v8Mgno,2642
96
96
  qubx/loggers/mongo.py,sha256=dOEpCcIxT6O9MgpK2srpzxyuto6QaQgTxMK0WcEIR70,2703
97
97
  qubx/math/__init__.py,sha256=ltHSQj40sCBm3owcvtoZp34h6ws7pZCFcSZgUkTsUCY,114
98
98
  qubx/math/stats.py,sha256=uXm4NpBRxuHFTjXERv8rjM0MAJof8zr1Cklyra4CcBA,4056
99
- qubx/notifications/__init__.py,sha256=2xsk3kPykiNZDZv0y4YMDbgnFvKy14SkNeg7StHk4bI,340
99
+ qubx/notifications/__init__.py,sha256=cb3DGxuiA8UwSTlTeF5pQKy4-vBef3gMeKtfcxEjdN4,547
100
100
  qubx/notifications/composite.py,sha256=fa-rvHEn6k-Fma5N7cT-7Sk7hzVyB0KDs2ktDyoyLxM,2689
101
- qubx/notifications/slack.py,sha256=Odwx_qBrDoZPARp9ENmMI957FM1v5BvpUIWOuatrM0o,6711
101
+ qubx/notifications/slack.py,sha256=FZ0zfTA-zRHOsOsIVBtM7mkt4m-adiuiV5yrwliS9RM,7976
102
+ qubx/notifications/throttler.py,sha256=8jnymPQbrgtN1rD7REQa2sA9teSWTqkk_uT9oaknOyc,5618
102
103
  qubx/pandaz/__init__.py,sha256=6BYz6gSgxjNa7WP1XqWflYG7WIq1ppSD9h1XGR5M5YQ,682
103
104
  qubx/pandaz/ta.py,sha256=SjK3jORf6Q3XYBwTixXnjK5elbzdvT14WR2OJdQXo20,91666
104
- qubx/pandaz/utils.py,sha256=zAHUIAApSRrlQa5AjpIbiQ9ftSGIBOu_ppDg0c3gXaE,23380
105
+ qubx/pandaz/utils.py,sha256=f7airZjUKHkhKICL0HF_R9Vvcswdgef7Yr35Xb_Ppzs,23407
105
106
  qubx/resources/_build.py,sha256=XE7XNuDqfXPc2OriLobKXmPMvwa7Z8AKAD-18fnf0e4,8802
106
107
  qubx/resources/instruments/symbols-binance.cm.json,sha256=rNI3phNeeRY95_IE7_0Um9d5U4jUtEijZQ_PaYg5cdw,25127
107
108
  qubx/resources/instruments/symbols-binance.json,sha256=Qx_XckgsWNhmmV8_t5DpG0AeGkuTyt1uiif2EeeBDIg,939678
@@ -122,7 +123,7 @@ qubx/restorers/signal.py,sha256=9TAaJOEKPjZXuciFFVn6Z8a-Z8CfVSjRGFRcwEgbPLY,1074
122
123
  qubx/restorers/state.py,sha256=dLaVnUwRCNRkUqbYyi0RfZs3Q3AdglkI_qTtQ8GDD5Y,7289
123
124
  qubx/restorers/utils.py,sha256=We2gfqwQKWziUYhuUnjb-xo-5tSlbuHWpPQn0CEMTn0,1155
124
125
  qubx/ta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
125
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so,sha256=dr8KjRec6PNcjTCGOzlnt-G6e5xCMw0jEoNtP4RbYI4,654440
126
+ qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so,sha256=NK4SU3DKjt9hjTM0N2_8zuGDYH8lZiLEMtOL3c4saog,654440
126
127
  qubx/ta/indicators.pxd,sha256=Goo0_N0Xnju8XGo3Xs-3pyg2qr_0Nh5C-_26DK8U_IE,4224
127
128
  qubx/ta/indicators.pyi,sha256=19W0uERft49In5bf9jkJHkzJYEyE9gzudN7_DJ5Vdv8,1963
128
129
  qubx/ta/indicators.pyx,sha256=Xgpew46ZxSXsdfSEWYn3A0Q35MLsopB9n7iyCsXTufs,25969
@@ -155,12 +156,12 @@ qubx/utils/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
155
156
  qubx/utils/runner/_jupyter_runner.pyt,sha256=fDj4AUs25jsdGmY9DDeSFufH1JkVhLFwy0BOmVO7nIU,9609
156
157
  qubx/utils/runner/accounts.py,sha256=mpiv6oxr5z97zWt7STYyARMhWQIpc_XFKungb_pX38U,3270
157
158
  qubx/utils/runner/configs.py,sha256=nxIelzfHtv7GagkEHBJ6mRm_30jmBa6pSPujL-k0uqo,3749
158
- qubx/utils/runner/factory.py,sha256=EwtM1mE-w2IwWcWoazNITsPPl9Q_5K9IDtxfQc4BaZA,11694
159
- qubx/utils/runner/runner.py,sha256=egv4OtPwEIwG0-Ia3ai9E9MYQIaslZLyoT4tL1FxQcY,28891
159
+ qubx/utils/runner/factory.py,sha256=8IxKvsEL0opx5OlO4XEQgAlmu05sgxXxkY4G2MQdLbg,14941
160
+ qubx/utils/runner/runner.py,sha256=DhICMOfgE0i7VEC7pNMSEzIyZbb-TbOa6FbR1obZpyc,28901
160
161
  qubx/utils/time.py,sha256=J0ZFGjzFL5T6GA8RPAel8hKG0sg2LZXeQ5YfDCfcMHA,10055
161
162
  qubx/utils/version.py,sha256=e52fIHyxzCiIuH7svCF6pkHuDlqL64rklqz-2XjWons,5309
162
- qubx-0.6.40.dist-info/LICENSE,sha256=qwMHOSJ2TD0nx6VUJvFhu1ynJdBfNozRMt6tnSul-Ts,35140
163
- qubx-0.6.40.dist-info/METADATA,sha256=V2Xo_isQ1pWzbVKAKgUI8mTZDok7ZCy4UxuF3QuS_nI,4492
164
- qubx-0.6.40.dist-info/WHEEL,sha256=XjdW4AGUgFDhpG9b3b2KPhtR_JLZvHyfemLgJJwcqOI,110
165
- qubx-0.6.40.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
166
- qubx-0.6.40.dist-info/RECORD,,
163
+ qubx-0.6.42.dist-info/LICENSE,sha256=qwMHOSJ2TD0nx6VUJvFhu1ynJdBfNozRMt6tnSul-Ts,35140
164
+ qubx-0.6.42.dist-info/METADATA,sha256=IimoSRSYNj-wJp_LKP7T4Gr0OVu_9Wuxo7Pm2unbjp4,4492
165
+ qubx-0.6.42.dist-info/WHEEL,sha256=UckHTmFUCaLKpi4yFY8Dewu0c6XkY-KvEAGzGOnaWo8,110
166
+ qubx-0.6.42.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
167
+ qubx-0.6.42.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.2
2
+ Generator: poetry-core 2.1.3
3
3
  Root-Is-Purelib: false
4
4
  Tag: cp312-cp312-manylinux_2_39_x86_64
File without changes