Qubx 0.6.67__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.68__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.
Files changed (62) hide show
  1. qubx/backtester/data.py +19 -2
  2. qubx/cli/commands.py +125 -0
  3. qubx/connectors/ccxt/connection_manager.py +310 -0
  4. qubx/connectors/ccxt/data.py +116 -706
  5. qubx/connectors/ccxt/exchanges/binance/exchange.py +68 -16
  6. qubx/connectors/ccxt/handlers/__init__.py +29 -0
  7. qubx/connectors/ccxt/handlers/base.py +93 -0
  8. qubx/connectors/ccxt/handlers/factory.py +123 -0
  9. qubx/connectors/ccxt/handlers/funding_rate.py +93 -0
  10. qubx/connectors/ccxt/handlers/liquidation.py +91 -0
  11. qubx/connectors/ccxt/handlers/ohlc.py +202 -0
  12. qubx/connectors/ccxt/handlers/open_interest.py +208 -0
  13. qubx/connectors/ccxt/handlers/orderbook.py +186 -0
  14. qubx/connectors/ccxt/handlers/quote.py +98 -0
  15. qubx/connectors/ccxt/handlers/trade.py +94 -0
  16. qubx/connectors/ccxt/subscription_config.py +40 -0
  17. qubx/connectors/ccxt/subscription_manager.py +331 -0
  18. qubx/connectors/ccxt/subscription_orchestrator.py +215 -0
  19. qubx/connectors/ccxt/utils.py +88 -1
  20. qubx/connectors/ccxt/warmup_service.py +113 -0
  21. qubx/connectors/tardis/data.py +6 -6
  22. qubx/core/basics.py +15 -0
  23. qubx/core/helpers.py +43 -24
  24. qubx/core/initializer.py +5 -9
  25. qubx/core/metrics.py +252 -22
  26. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  27. qubx/core/series.pxd +22 -5
  28. qubx/core/series.pyi +33 -3
  29. qubx/core/series.pyx +116 -59
  30. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  31. qubx/data/readers.py +68 -33
  32. qubx/pandaz/ta.py +97 -22
  33. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  34. qubx/templates/__init__.py +5 -0
  35. qubx/templates/base.py +166 -0
  36. qubx/templates/project/accounts.toml.j2 +22 -0
  37. qubx/templates/project/config.yml.j2 +33 -0
  38. qubx/templates/project/jlive.sh.j2 +43 -0
  39. qubx/templates/project/jpaper.sh.j2 +6 -0
  40. qubx/templates/project/pyproject.toml.j2 +18 -0
  41. qubx/templates/project/src/{{ strategy_name }}/__init__.py.j2 +5 -0
  42. qubx/templates/project/src/{{ strategy_name }}/strategy.py.j2 +170 -0
  43. qubx/templates/project/template.yml +20 -0
  44. qubx/templates/simple/__init__.py.j2 +5 -0
  45. qubx/templates/simple/accounts.toml.j2 +22 -0
  46. qubx/templates/simple/config.yml.j2 +30 -0
  47. qubx/templates/simple/jlive.sh.j2 +43 -0
  48. qubx/templates/simple/jpaper.sh.j2 +6 -0
  49. qubx/templates/simple/strategy.py.j2 +95 -0
  50. qubx/templates/simple/template.yml +20 -0
  51. qubx/trackers/sizers.py +9 -2
  52. qubx/utils/charting/lookinglass.py +93 -15
  53. qubx/utils/misc.py +9 -2
  54. qubx/utils/runner/_jupyter_runner.pyt +4 -0
  55. qubx/utils/runner/configs.py +1 -0
  56. qubx/utils/runner/runner.py +1 -0
  57. qubx/utils/time.py +13 -13
  58. {qubx-0.6.67.dist-info → qubx-0.6.68.dist-info}/METADATA +50 -4
  59. {qubx-0.6.67.dist-info → qubx-0.6.68.dist-info}/RECORD +62 -30
  60. {qubx-0.6.67.dist-info → qubx-0.6.68.dist-info}/LICENSE +0 -0
  61. {qubx-0.6.67.dist-info → qubx-0.6.68.dist-info}/WHEEL +0 -0
  62. {qubx-0.6.67.dist-info → qubx-0.6.68.dist-info}/entry_points.txt +0 -0
qubx/backtester/data.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from collections import defaultdict
2
+ from typing import Any, TypeVar
2
3
 
3
4
  import pandas as pd
4
5
 
@@ -19,6 +20,17 @@ from qubx.utils.time import infer_series_frequency
19
20
  from .account import SimulatedAccountProcessor
20
21
  from .utils import SimulatedTimeProvider
21
22
 
23
+ T = TypeVar("T")
24
+
25
+
26
+ def _get_first_existing(data: dict, keys: list, default: T = None) -> T:
27
+ data_get = data.get # Cache method lookup
28
+ sentinel = object()
29
+ for key in keys:
30
+ if (value := data_get(key, sentinel)) is not sentinel:
31
+ return value
32
+ return default
33
+
22
34
 
23
35
  class SimulatedDataProvider(IDataProvider):
24
36
  time_provider: SimulatedTimeProvider
@@ -164,8 +176,13 @@ class SimulatedDataProvider(IDataProvider):
164
176
  r.data["high"],
165
177
  r.data["low"],
166
178
  r.data["close"],
167
- r.data.get("volume", 0),
168
- r.data.get("bought_volume", 0),
179
+ volume=r.data.get("volume", 0),
180
+ bought_volume=_get_first_existing(r.data, ["taker_buy_volume", "bought_volume"], 0),
181
+ volume_quote=_get_first_existing(r.data, ["quote_volume", "volume_quote"], 0),
182
+ bought_volume_quote=_get_first_existing(
183
+ r.data, ["taker_buy_quote_volume", "bought_volume_quote"], 0
184
+ ),
185
+ trade_count=_get_first_existing(r.data, ["count", "trade_count"], 0),
169
186
  )
170
187
  )
171
188
 
qubx/cli/commands.py CHANGED
@@ -275,5 +275,130 @@ def browse(results_path: str):
275
275
  run_backtest_browser(results_path)
276
276
 
277
277
 
278
+ @main.command()
279
+ @click.option(
280
+ "--template",
281
+ "-t",
282
+ type=str,
283
+ default="simple",
284
+ help="Built-in template to use (default: simple)",
285
+ show_default=True,
286
+ )
287
+ @click.option(
288
+ "--template-path",
289
+ type=click.Path(exists=True, resolve_path=True),
290
+ help="Path to custom template directory",
291
+ )
292
+ @click.option(
293
+ "--name",
294
+ "-n",
295
+ type=str,
296
+ default="my_strategy",
297
+ help="Name of the strategy to create",
298
+ show_default=True,
299
+ )
300
+ @click.option(
301
+ "--exchange",
302
+ "-e",
303
+ type=str,
304
+ default="BINANCE.UM",
305
+ help="Exchange to configure for the strategy",
306
+ show_default=True,
307
+ )
308
+ @click.option(
309
+ "--symbols",
310
+ "-s",
311
+ type=str,
312
+ default="BTCUSDT",
313
+ help="Comma-separated list of symbols to trade",
314
+ show_default=True,
315
+ )
316
+ @click.option(
317
+ "--timeframe",
318
+ type=str,
319
+ default="1h",
320
+ help="Timeframe for market data",
321
+ show_default=True,
322
+ )
323
+ @click.option(
324
+ "--output-dir",
325
+ "-o",
326
+ type=click.Path(resolve_path=True),
327
+ default=".",
328
+ help="Directory to create the strategy in",
329
+ show_default=True,
330
+ )
331
+ @click.option(
332
+ "--list-templates",
333
+ is_flag=True,
334
+ help="List all available built-in templates",
335
+ )
336
+ def init(
337
+ template: str,
338
+ template_path: str | None,
339
+ name: str,
340
+ exchange: str,
341
+ symbols: str,
342
+ timeframe: str,
343
+ output_dir: str,
344
+ list_templates: bool,
345
+ ):
346
+ """
347
+ Create a new strategy from a template.
348
+
349
+ This command generates a complete strategy project structure with:
350
+ - Strategy class implementing IStrategy interface
351
+ - Configuration file for qubx run command
352
+ - Package structure for proper imports
353
+
354
+ The generated strategy can be run immediately with:
355
+ poetry run qubx run --config config.yml --paper
356
+ """
357
+ from qubx.templates import TemplateManager, TemplateError
358
+
359
+ try:
360
+ manager = TemplateManager()
361
+
362
+ if list_templates:
363
+ templates = manager.list_templates()
364
+ if not templates:
365
+ click.echo("No templates available.")
366
+ return
367
+
368
+ click.echo("Available templates:")
369
+ for template_name, metadata in templates.items():
370
+ description = metadata.get("description", "No description")
371
+ click.echo(f" {template_name:<15} - {description}")
372
+ return
373
+
374
+ # Generate strategy
375
+ strategy_path = manager.generate_strategy(
376
+ template_name=template if not template_path else None,
377
+ template_path=template_path,
378
+ output_dir=output_dir,
379
+ name=name,
380
+ exchange=exchange,
381
+ symbols=symbols,
382
+ timeframe=timeframe,
383
+ )
384
+
385
+ click.echo(f"✅ Strategy '{name}' created successfully!")
386
+ click.echo(f"📁 Location: {strategy_path}")
387
+ click.echo()
388
+ click.echo("To run your strategy:")
389
+ click.echo(f" cd {strategy_path}")
390
+ click.echo(" poetry run qubx run config.yml --paper")
391
+ click.echo()
392
+ click.echo("To run in Jupyter mode:")
393
+ click.echo(" ./jpaper.sh")
394
+
395
+ except TemplateError as e:
396
+ click.echo(f"❌ Template error: {e}", err=True)
397
+ raise click.Abort()
398
+ except Exception as e:
399
+ click.echo(f"❌ Unexpected error: {e}", err=True)
400
+ raise click.Abort()
401
+
402
+
278
403
  if __name__ == "__main__":
279
404
  main()
@@ -0,0 +1,310 @@
1
+ """
2
+ Connection management for CCXT data provider.
3
+
4
+ This module handles WebSocket connections, retry logic, and stream lifecycle management,
5
+ separating connection concerns from subscription state and data handling.
6
+ """
7
+
8
+ import asyncio
9
+ import concurrent.futures
10
+ from asyncio.exceptions import CancelledError
11
+ from collections import defaultdict
12
+ from typing import Awaitable, Callable, Dict
13
+
14
+ from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
15
+ from ccxt.pro import Exchange
16
+ from qubx import logger
17
+ from qubx.core.basics import CtrlChannel
18
+
19
+ from .exceptions import CcxtSymbolNotRecognized
20
+ from .subscription_manager import SubscriptionManager
21
+
22
+
23
+ class ConnectionManager:
24
+ """
25
+ Manages WebSocket connections and stream lifecycle for CCXT data provider.
26
+
27
+ Responsibilities:
28
+ - Handle WebSocket connection establishment and management
29
+ - Implement retry logic and error handling
30
+ - Manage stream lifecycle (start, stop, cleanup)
31
+ - Coordinate with SubscriptionManager for state updates
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ exchange_id: str,
37
+ max_ws_retries: int = 10,
38
+ subscription_manager: SubscriptionManager | None = None
39
+ ):
40
+ self._exchange_id = exchange_id
41
+ self.max_ws_retries = max_ws_retries
42
+ self._subscription_manager = subscription_manager
43
+
44
+ # Stream state management
45
+ self._is_stream_enabled: Dict[str, bool] = defaultdict(lambda: False)
46
+ self._stream_to_unsubscriber: Dict[str, Callable[[], Awaitable[None]]] = {}
47
+
48
+ # Connection tracking
49
+ self._stream_to_coro: Dict[str, concurrent.futures.Future] = {}
50
+
51
+ def set_subscription_manager(self, subscription_manager: SubscriptionManager) -> None:
52
+ """Set the subscription manager for state coordination."""
53
+ self._subscription_manager = subscription_manager
54
+
55
+ async def listen_to_stream(
56
+ self,
57
+ subscriber: Callable[[], Awaitable[None]],
58
+ exchange: Exchange,
59
+ channel: CtrlChannel,
60
+ stream_name: str,
61
+ unsubscriber: Callable[[], Awaitable[None]] | None = None,
62
+ ) -> None:
63
+ """
64
+ Listen to a WebSocket stream with error handling and retry logic.
65
+
66
+ Args:
67
+ subscriber: Async function that handles the stream data
68
+ exchange: CCXT exchange instance
69
+ channel: Control channel for data flow
70
+ stream_name: Unique name for this stream
71
+ unsubscriber: Optional cleanup function for graceful unsubscription
72
+ """
73
+ logger.info(f"<yellow>{self._exchange_id}</yellow> Listening to {stream_name}")
74
+
75
+ # Register unsubscriber for cleanup
76
+ if unsubscriber is not None:
77
+ self._stream_to_unsubscriber[stream_name] = unsubscriber
78
+
79
+ # Enable the stream
80
+ self._is_stream_enabled[stream_name] = True
81
+ n_retry = 0
82
+ connection_established = False
83
+
84
+ while channel.control.is_set() and self._is_stream_enabled[stream_name]:
85
+ try:
86
+ await subscriber()
87
+ n_retry = 0 # Reset retry counter on success
88
+
89
+ # Mark subscription as active on first successful data reception
90
+ if not connection_established and self._subscription_manager:
91
+ subscription_type = self._subscription_manager.find_subscription_type_by_name(stream_name)
92
+ if subscription_type:
93
+ self._subscription_manager.mark_subscription_active(subscription_type)
94
+ connection_established = True
95
+
96
+ # Check if stream was disabled during subscriber execution
97
+ if not self._is_stream_enabled[stream_name]:
98
+ break
99
+
100
+ except CcxtSymbolNotRecognized:
101
+ # Skip unrecognized symbols but continue listening
102
+ continue
103
+ except CancelledError:
104
+ # Graceful cancellation
105
+ break
106
+ except ExchangeClosedByUser:
107
+ # Connection closed by us, stop gracefully
108
+ logger.info(f"<yellow>{self._exchange_id}</yellow> {stream_name} listening has been stopped")
109
+ break
110
+ except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
111
+ # Network/exchange errors - retry after short delay
112
+ logger.error(f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {stream_name} : {e}")
113
+ await asyncio.sleep(1)
114
+ continue
115
+ except Exception as e:
116
+ # Unexpected errors
117
+ if not channel.control.is_set() or not self._is_stream_enabled[stream_name]:
118
+ # Channel closed or stream disabled, exit gracefully
119
+ break
120
+
121
+ logger.error(f"<yellow>{self._exchange_id}</yellow> Exception in {stream_name}: {e}")
122
+ logger.exception(e)
123
+
124
+ n_retry += 1
125
+ if n_retry >= self.max_ws_retries:
126
+ logger.error(
127
+ f"<yellow>{self._exchange_id}</yellow> Max retries reached for {stream_name}. Closing connection."
128
+ )
129
+ # Clean up exchange reference to force reconnection
130
+ del exchange
131
+ break
132
+
133
+ # Exponential backoff with cap at 60 seconds
134
+ await asyncio.sleep(min(2**n_retry, 60))
135
+
136
+ # Stream ended, cleanup
137
+ logger.debug(f"<yellow>{self._exchange_id}</yellow> Stream {stream_name} ended")
138
+
139
+ async def stop_stream(
140
+ self,
141
+ stream_name: str,
142
+ future: concurrent.futures.Future | None = None,
143
+ is_resubscription: bool = False
144
+ ) -> None:
145
+ """
146
+ Stop a stream gracefully with proper cleanup.
147
+
148
+ Args:
149
+ stream_name: Name of the stream to stop
150
+ future: Optional future representing the stream task
151
+ is_resubscription: True if this is stopping an old stream during resubscription
152
+ """
153
+ try:
154
+ context = "old stream" if is_resubscription else "stream"
155
+ logger.debug(f"<yellow>{self._exchange_id}</yellow> Stopping {context} {stream_name}")
156
+
157
+ # Disable the stream to signal it should stop
158
+ self._is_stream_enabled[stream_name] = False
159
+
160
+ # Wait for the stream to stop naturally
161
+ if future:
162
+ total_sleep_time = 0.0
163
+ while future.running() and total_sleep_time < 20.0:
164
+ await asyncio.sleep(1.0)
165
+ total_sleep_time += 1.0
166
+
167
+ if future.running():
168
+ logger.warning(
169
+ f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} is still running. Cancelling it."
170
+ )
171
+ future.cancel()
172
+ else:
173
+ logger.debug(f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} has been stopped")
174
+
175
+ # Run unsubscriber if available
176
+ if stream_name in self._stream_to_unsubscriber:
177
+ logger.debug(f"<yellow>{self._exchange_id}</yellow> Unsubscribing from {stream_name}")
178
+ await self._stream_to_unsubscriber[stream_name]()
179
+ del self._stream_to_unsubscriber[stream_name]
180
+
181
+ # Clean up stream state
182
+ if is_resubscription:
183
+ # For resubscription, only clean up if the stream is actually disabled
184
+ # (avoid interfering with new streams using the same name)
185
+ if stream_name in self._is_stream_enabled and not self._is_stream_enabled[stream_name]:
186
+ del self._is_stream_enabled[stream_name]
187
+ else:
188
+ # For regular stops, always clean up completely
189
+ self._is_stream_enabled.pop(stream_name, None)
190
+ self._stream_to_coro.pop(stream_name, None)
191
+
192
+ logger.debug(f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} stopped")
193
+
194
+ except Exception as e:
195
+ logger.error(f"<yellow>{self._exchange_id}</yellow> Error stopping {stream_name}")
196
+ logger.exception(e)
197
+
198
+ def register_stream_future(
199
+ self,
200
+ stream_name: str,
201
+ future: concurrent.futures.Future
202
+ ) -> None:
203
+ """
204
+ Register a future for a stream for tracking and cleanup.
205
+
206
+ Args:
207
+ stream_name: Name of the stream
208
+ future: Future representing the stream task
209
+ """
210
+ self._stream_to_coro[stream_name] = future
211
+
212
+ def is_stream_enabled(self, stream_name: str) -> bool:
213
+ """
214
+ Check if a stream is enabled.
215
+
216
+ Args:
217
+ stream_name: Name of the stream to check
218
+
219
+ Returns:
220
+ True if stream is enabled, False otherwise
221
+ """
222
+ return self._is_stream_enabled.get(stream_name, False)
223
+
224
+ def get_stream_future(self, stream_name: str) -> concurrent.futures.Future | None:
225
+ """
226
+ Get the future for a stream.
227
+
228
+ Args:
229
+ stream_name: Name of the stream
230
+
231
+ Returns:
232
+ Future if exists, None otherwise
233
+ """
234
+ return self._stream_to_coro.get(stream_name)
235
+
236
+ def disable_stream(self, stream_name: str) -> None:
237
+ """
238
+ Disable a stream (signal it to stop).
239
+
240
+ Args:
241
+ stream_name: Name of the stream to disable
242
+ """
243
+ self._is_stream_enabled[stream_name] = False
244
+
245
+ def enable_stream(self, stream_name: str) -> None:
246
+ """
247
+ Enable a stream.
248
+
249
+ Args:
250
+ stream_name: Name of the stream to enable
251
+ """
252
+ self._is_stream_enabled[stream_name] = True
253
+
254
+ def set_stream_unsubscriber(
255
+ self,
256
+ stream_name: str,
257
+ unsubscriber: Callable[[], Awaitable[None]]
258
+ ) -> None:
259
+ """
260
+ Set unsubscriber function for a stream.
261
+
262
+ Args:
263
+ stream_name: Name of the stream
264
+ unsubscriber: Async function to call for unsubscription
265
+ """
266
+ self._stream_to_unsubscriber[stream_name] = unsubscriber
267
+
268
+ def get_stream_unsubscriber(self, stream_name: str) -> Callable[[], Awaitable[None]] | None:
269
+ """
270
+ Get unsubscriber function for a stream.
271
+
272
+ Args:
273
+ stream_name: Name of the stream
274
+
275
+ Returns:
276
+ Unsubscriber function if exists, None otherwise
277
+ """
278
+ return self._stream_to_unsubscriber.get(stream_name)
279
+
280
+ def set_stream_coro(
281
+ self,
282
+ stream_name: str,
283
+ coro: concurrent.futures.Future
284
+ ) -> None:
285
+ """
286
+ Set coroutine/future for a stream.
287
+
288
+ Args:
289
+ stream_name: Name of the stream
290
+ coro: Future representing the stream task
291
+ """
292
+ self._stream_to_coro[stream_name] = coro
293
+
294
+ def get_stream_coro(self, stream_name: str) -> concurrent.futures.Future | None:
295
+ """
296
+ Get coroutine/future for a stream.
297
+
298
+ Args:
299
+ stream_name: Name of the stream
300
+
301
+ Returns:
302
+ Future if exists, None otherwise
303
+ """
304
+ return self._stream_to_coro.get(stream_name)
305
+
306
+ def cleanup_all_streams(self) -> None:
307
+ """Clean up all stream state (for shutdown)."""
308
+ self._is_stream_enabled.clear()
309
+ self._stream_to_unsubscriber.clear()
310
+ self._stream_to_coro.clear()