Qubx 0.6.38__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.41__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/cli/deploy.py CHANGED
@@ -182,6 +182,29 @@ def setup_poetry_environment(output_dir: str) -> bool:
182
182
  return False
183
183
 
184
184
 
185
+ def create_strategy_runners(output_dir: str):
186
+ """
187
+ Creates a strategy runner script in the output_dir
188
+ """
189
+ import sys
190
+
191
+ if sys.platform == "win32":
192
+ _pfx = ""
193
+ _f_name = os.path.join(output_dir, "run_paper.bat")
194
+ else:
195
+ _pfx = "#!/bin/bash\n"
196
+ _f_name = os.path.join(output_dir, "run_paper.sh")
197
+
198
+ logger.info(f"Creating strategy paper runner script: {_f_name}")
199
+
200
+ try:
201
+ with open(_f_name, "w") as f:
202
+ f.write(f"{_pfx}poetry run qubx run config.yml --paper -j")
203
+ os.chmod(_f_name, 0o755)
204
+ except Exception as e:
205
+ logger.error(f"Failed to create strategy paper runner script: {e}")
206
+
207
+
185
208
  def deploy_strategy(zip_file: str, output_dir: str | None, force: bool) -> bool:
186
209
  """
187
210
  Deploys a strategy from a zip file created by the release command.
@@ -222,9 +245,10 @@ def deploy_strategy(zip_file: str, output_dir: str | None, force: bool) -> bool:
222
245
  if not setup_poetry_environment(resolved_output_dir):
223
246
  return False
224
247
 
248
+ # Create the strategy runners
249
+ create_strategy_runners(resolved_output_dir)
250
+
225
251
  # Success messages
226
252
  logger.info(f"Strategy deployed successfully to {resolved_output_dir}")
227
- logger.info(
228
- f"To run the strategy (paper mode): <cyan>cd {resolved_output_dir} && poetry run qubx run config.yml --paper</cyan>"
229
- )
253
+ logger.info(f" -> To run the strategy (in paper mode): <cyan>cd {resolved_output_dir} && ./run_paper.sh</cyan>")
230
254
  return True
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
@@ -884,7 +884,7 @@ class TradingSessionResult:
884
884
  _perf = info.pop("performance", None)
885
885
  info["instruments"] = info.pop("symbols")
886
886
  # - fix for old versions
887
- _exch = info.pop("exchange")
887
+ _exch = info.pop("exchange") if "exchange" in info else info.pop("exchanges")
888
888
  info["exchanges"] = _exch if isinstance(_exch, list) else [_exch]
889
889
  tsr = TradingSessionResult(**info, portfolio_log=portfolio, executions_log=executions, signals_log=signals)
890
890
  tsr.qubx_version = _qbx_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()]
@@ -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:
@@ -121,7 +121,8 @@ def load_strategy_config_from_yaml(path: Path | str, key: str | None = None) ->
121
121
  class StrategySimulationConfig(BaseModel):
122
122
  strategy: str | list[str]
123
123
  parameters: dict = Field(default_factory=dict)
124
- data: dict = Field(default_factory=dict)
124
+ data: list[TypedReaderConfig] = Field(default_factory=list)
125
+ aux: ReaderConfig | None = None
125
126
  simulation: dict = Field(default_factory=dict)
126
127
  description: str | list[str] | None = None
127
128
  variate: dict = Field(default_factory=dict)
@@ -12,7 +12,7 @@ from qubx.data.composite import CompositeReader
12
12
  from qubx.data.readers import DataReader
13
13
  from qubx.emitters.composite import CompositeMetricEmitter
14
14
  from qubx.utils.misc import class_import
15
- from qubx.utils.runner.configs import EmissionConfig, ExporterConfig, NotifierConfig, ReaderConfig, WarmupConfig
15
+ from qubx.utils.runner.configs import EmissionConfig, ExporterConfig, NotifierConfig, ReaderConfig, TypedReaderConfig
16
16
 
17
17
 
18
18
  def resolve_env_vars(value: str | Any) -> str | Any:
@@ -118,27 +118,27 @@ def create_metric_emitters(emission_config: EmissionConfig, strategy_name: str)
118
118
  return CompositeMetricEmitter(emitters, stats_interval=stats_interval)
119
119
 
120
120
 
121
- def create_data_type_readers(warmup: WarmupConfig | None) -> dict[str, DataReader]:
121
+ def create_data_type_readers(readers_configs: list[TypedReaderConfig] | None) -> dict[str, DataReader]:
122
122
  """
123
- Create a dictionary mapping data types to readers based on the warmup configuration.
123
+ Create a dictionary mapping data types to readers based on the readers list.
124
124
 
125
125
  This function ensures that identical reader configurations are only instantiated once,
126
126
  and multiple data types can share the same reader instance if they have identical configurations.
127
127
 
128
128
  Args:
129
- warmup: The warmup configuration containing reader definitions.
129
+ readers_configs: The readers list containing reader definitions.
130
130
 
131
131
  Returns:
132
132
  A dictionary mapping data types to reader instances.
133
133
  """
134
- if warmup is None:
134
+ if readers_configs is None:
135
135
  return {}
136
136
 
137
137
  # First, create unique readers to avoid duplicate instantiation
138
138
  unique_readers = {} # Maps reader config hash to reader instance
139
139
  data_type_to_reader = {} # Maps data type to reader instance
140
140
 
141
- for typed_reader_config in warmup.readers:
141
+ for typed_reader_config in readers_configs:
142
142
  data_types = typed_reader_config.data_type
143
143
  if isinstance(data_types, str):
144
144
  data_types = [data_types]
@@ -259,7 +259,7 @@ def create_lifecycle_notifiers(
259
259
  Create lifecycle notifiers from the configuration.
260
260
 
261
261
  Args:
262
- config: Strategy configuration
262
+ notifiers: List of notifier configurations
263
263
  strategy_name: Name of the strategy
264
264
 
265
265
  Returns:
@@ -283,6 +283,57 @@ def create_lifecycle_notifiers(
283
283
  for key, value in notifier_config.parameters.items():
284
284
  params[key] = resolve_env_vars(value)
285
285
 
286
+ # Create throttler if configured or use default TimeWindowThrottler
287
+ if "SlackLifecycleNotifier" in notifier_class_name and "throttler" not in params:
288
+ # Import here to avoid circular imports
289
+ from qubx.notifications.throttler import TimeWindowThrottler
290
+
291
+ # Create default throttler with 10s window
292
+ default_window = 10.0
293
+ params["throttler"] = TimeWindowThrottler(window_seconds=default_window)
294
+ logger.info(
295
+ f"Using default TimeWindowThrottler with window={default_window}s for {notifier_class_name}"
296
+ )
297
+ elif "throttle" in params:
298
+ throttle_config = params.pop("throttle")
299
+
300
+ if isinstance(throttle_config, dict):
301
+ throttler_type = throttle_config.get("type", "TimeWindow")
302
+ window_seconds = float(throttle_config.get("window_seconds", 10.0))
303
+ max_count = int(throttle_config.get("max_count", 3))
304
+
305
+ if throttler_type.lower() == "timewindow":
306
+ from qubx.notifications.throttler import TimeWindowThrottler
307
+
308
+ throttler = TimeWindowThrottler(window_seconds=window_seconds)
309
+ logger.info(f"Created TimeWindowThrottler with window_seconds={window_seconds}")
310
+ elif throttler_type.lower() == "countbased":
311
+ from qubx.notifications.throttler import CountBasedThrottler
312
+
313
+ throttler = CountBasedThrottler(max_count=max_count, window_seconds=window_seconds)
314
+ logger.info(
315
+ f"Created CountBasedThrottler with max_count={max_count}, window_seconds={window_seconds}"
316
+ )
317
+ elif throttler_type.lower() == "none":
318
+ from qubx.notifications.throttler import NoThrottling
319
+
320
+ throttler = NoThrottling()
321
+ logger.info("Created NoThrottling throttler")
322
+ else:
323
+ logger.warning(f"Unknown throttler type '{throttler_type}', defaulting to TimeWindowThrottler")
324
+ from qubx.notifications.throttler import TimeWindowThrottler
325
+
326
+ throttler = TimeWindowThrottler(window_seconds=window_seconds)
327
+
328
+ params["throttler"] = throttler
329
+ elif isinstance(throttle_config, (int, float)):
330
+ # Simple case: just a window_seconds value
331
+ from qubx.notifications.throttler import TimeWindowThrottler
332
+
333
+ throttler = TimeWindowThrottler(window_seconds=float(throttle_config))
334
+ logger.info(f"Created TimeWindowThrottler with window_seconds={throttle_config}")
335
+ params["throttler"] = throttler
336
+
286
337
  # Create the notifier instance
287
338
  notifier = notifier_class(**params)
288
339
  _notifiers.append(notifier)
@@ -49,7 +49,7 @@ from qubx.loggers import create_logs_writer
49
49
  from qubx.restarts.state_resolvers import StateResolver
50
50
  from qubx.restarts.time_finders import TimeFinder
51
51
  from qubx.restorers import create_state_restorer
52
- from qubx.utils.misc import class_import, makedirs, red
52
+ from qubx.utils.misc import class_import, green, makedirs, red
53
53
  from qubx.utils.runner.configs import (
54
54
  ExchangeConfig,
55
55
  LoggingConfig,
@@ -533,9 +533,7 @@ def _create_broker(
533
533
  secret=creds.secret,
534
534
  enable_mm=_enable_mm,
535
535
  )
536
- return get_ccxt_broker(
537
- exchange_name, exchange, channel, time_provider, account, data_provider, **params
538
- )
536
+ return get_ccxt_broker(exchange_name, exchange, channel, time_provider, account, data_provider, **params)
539
537
  case "paper":
540
538
  assert isinstance(account, SimulatedAccountProcessor)
541
539
  return SimulatedBroker(channel=channel, account=account, simulated_exchange=account._exchange)
@@ -597,7 +595,7 @@ def _run_warmup(
597
595
  logger.info(f"<yellow>Warmup start time: {warmup_start_time}</yellow>")
598
596
 
599
597
  # - construct warmup readers
600
- data_type_to_reader = create_data_type_readers(warmup)
598
+ data_type_to_reader = create_data_type_readers(warmup.readers) if warmup else {}
601
599
 
602
600
  if not data_type_to_reader:
603
601
  logger.warning("<yellow>No readers were created for warmup</yellow>")
@@ -745,10 +743,8 @@ def simulate_strategy(
745
743
  experiments = {simulation_name: strategy}
746
744
  _n_jobs = 1
747
745
 
748
- data_i = {}
749
-
750
- for k, v in cfg.data.items():
751
- data_i[k] = eval(v)
746
+ # - resolve data readers
747
+ data_i = create_data_type_readers(cfg.data) if cfg.data else {}
752
748
 
753
749
  sim_params = cfg.simulation
754
750
  for mp in ["instruments", "capital", "commissions", "start", "stop"]:
@@ -764,18 +760,13 @@ def simulate_strategy(
764
760
  logger.info(f"Stop date set to {stop}")
765
761
 
766
762
  # - check for aux_data parameter
767
- if "aux_data" in sim_params:
768
- aux_data = sim_params.pop("aux_data")
769
- if aux_data is not None:
770
- try:
771
- sim_params["aux_data"] = eval(aux_data)
772
- except Exception as e:
773
- raise ValueError(f"Invalid aux_data parameter: {aux_data}") from e
763
+ if cfg.aux is not None:
764
+ sim_params["aux_data"] = construct_reader(cfg.aux)
774
765
 
775
766
  # - run simulation
776
767
  print(f" > Run simulation for [{red(simulation_name)}] ::: {sim_params['start']} - {sim_params['stop']}")
777
768
  sim_params["n_jobs"] = sim_params.get("n_jobs", _n_jobs)
778
- test_res = simulate(experiments, data=data_i, **sim_params)
769
+ test_res = simulate(experiments, data=data_i, **sim_params) # type: ignore
779
770
 
780
771
  _where_to_save = save_path if save_path is not None else Path("results/")
781
772
  s_path = Path(makedirs(str(_where_to_save))) / simulation_name
@@ -791,13 +782,13 @@ def simulate_strategy(
791
782
  if len(test_res) > 1:
792
783
  # - TODO: think how to deal with variations !
793
784
  s_path = s_path / f"variations.{_v_id}"
794
- print(f" > Saving variations results to <g>{s_path}</g> ...")
785
+ print(f" > Saving variations results to {green(s_path)} ...")
795
786
  for k, t in enumerate(test_res):
796
787
  # - set variation name
797
788
  t.variation_name = f"{simulation_name}.{_v_id}"
798
789
  t.to_file(str(s_path), description=_descr, suffix=f".{k}", attachments=[str(config_file)])
799
790
  else:
800
- print(f" > Saving simulation results to <g>{s_path}</g> ...")
791
+ print(f" > Saving simulation results to {green(s_path)} ...")
801
792
  test_res[0].to_file(str(s_path), description=_descr, attachments=[str(config_file)])
802
793
 
803
794
  return test_res
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: Qubx
3
- Version: 0.6.38
3
+ Version: 0.6.41
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,12 +9,12 @@ 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
16
16
  qubx/cli/commands.py,sha256=EwGqbNvY5VRCEO9T1w0GgqtcEvPFYMW96KzC-FPUvDM,7259
17
- qubx/cli/deploy.py,sha256=WfrEJ4n_e_xB6e1JstP2Rb4EGIxCftqG_flT03fjPXE,7595
17
+ qubx/cli/deploy.py,sha256=pQ9FPOsywDyy8jOjLfrgYTTkKQ-MCixCzbgsG68Q3_0,8319
18
18
  qubx/cli/misc.py,sha256=tP28QxLEzuP8R2xnt8g3JTs9Z7aYy4iVWY4g3VzKTsQ,14777
19
19
  qubx/cli/release.py,sha256=JYdNt_ZM9jarmYiRDtKqbRqqllzm2Qwi6VggokB2j8A,28167
20
20
  qubx/connectors/ccxt/__init__.py,sha256=HEQ7lM9HS8sED_zfsAHrhFT7F9E7NFGAecwZwNr-TDE,65
@@ -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=Gq3Ultwn5meICfyauBUJrBS4nffSxFVH3OF6N1Y0xgo,58664
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=oCjBv31hRr3bJ47EucFuzDzwStG05dUWTNyAXBg2HwQ,978280
55
+ qubx/core/series.cpython-312-x86_64-linux-gnu.so,sha256=kaqGuSydE041VcwqUyHpIybK27yE1g-INOtDP1zfI8Q,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=jpJmqz2ebCWD6_Wb8-IkGvGLhhgT2Gk0f2EmL8bkUC8,86568
59
+ qubx/core/utils.cpython-312-x86_64-linux-gnu.so,sha256=LWgrhKGrBQ7wFwPPHwZgjqPZxcRLzq81z0x3B1iiBE0,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
@@ -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=EAm2KZhw7wkpqvoEQRH9a3Z2enYqYQkk5EdVVOPuB6s,654440
126
+ qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so,sha256=CCvVqxcz64fK-Am57qTJzknn3CNYY-8PFEHlRgj7RTI,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
@@ -154,13 +155,13 @@ qubx/utils/questdb.py,sha256=TdjmlGPoZXdjidZ_evcBIkFtoL4nGQXPR4IQSUc6IvA,2509
154
155
  qubx/utils/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
155
156
  qubx/utils/runner/_jupyter_runner.pyt,sha256=fDj4AUs25jsdGmY9DDeSFufH1JkVhLFwy0BOmVO7nIU,9609
156
157
  qubx/utils/runner/accounts.py,sha256=mpiv6oxr5z97zWt7STYyARMhWQIpc_XFKungb_pX38U,3270
157
- qubx/utils/runner/configs.py,sha256=4lonQgksh4wDygsN67lIidVRIUksskWuhL25A2IZwho,3694
158
- qubx/utils/runner/factory.py,sha256=vQ2dBTbrQE9YH__-TvuFzGF-E1li-vt_qQum9GHa11g,11666
159
- qubx/utils/runner/runner.py,sha256=dyrwFiVmU3nkgGiDRIg6cxhikf3KJ4ylEdojUdf9WaQ,29070
158
+ qubx/utils/runner/configs.py,sha256=nxIelzfHtv7GagkEHBJ6mRm_30jmBa6pSPujL-k0uqo,3749
159
+ qubx/utils/runner/factory.py,sha256=Mh_soHKci7Xj5uYI3vhxzXkrPUm2Z-Cq3PGC6w3KCUY,14620
160
+ qubx/utils/runner/runner.py,sha256=egv4OtPwEIwG0-Ia3ai9E9MYQIaslZLyoT4tL1FxQcY,28891
160
161
  qubx/utils/time.py,sha256=J0ZFGjzFL5T6GA8RPAel8hKG0sg2LZXeQ5YfDCfcMHA,10055
161
162
  qubx/utils/version.py,sha256=e52fIHyxzCiIuH7svCF6pkHuDlqL64rklqz-2XjWons,5309
162
- qubx-0.6.38.dist-info/LICENSE,sha256=qwMHOSJ2TD0nx6VUJvFhu1ynJdBfNozRMt6tnSul-Ts,35140
163
- qubx-0.6.38.dist-info/METADATA,sha256=_71dUs78KDAbSy2GWSHBg_oTYYZnugqV8e8oACi9t-4,4492
164
- qubx-0.6.38.dist-info/WHEEL,sha256=XjdW4AGUgFDhpG9b3b2KPhtR_JLZvHyfemLgJJwcqOI,110
165
- qubx-0.6.38.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
166
- qubx-0.6.38.dist-info/RECORD,,
163
+ qubx-0.6.41.dist-info/LICENSE,sha256=qwMHOSJ2TD0nx6VUJvFhu1ynJdBfNozRMt6tnSul-Ts,35140
164
+ qubx-0.6.41.dist-info/METADATA,sha256=TdDDd1n1SALzg1jwDaAG153aD7-MqDAJh40T--FQ-4c,4492
165
+ qubx-0.6.41.dist-info/WHEEL,sha256=UckHTmFUCaLKpi4yFY8Dewu0c6XkY-KvEAGzGOnaWo8,110
166
+ qubx-0.6.41.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
167
+ qubx-0.6.41.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