Qubx 0.5.8__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.0__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/__init__.py CHANGED
@@ -5,13 +5,33 @@ from typing import Callable
5
5
  import stackprinter
6
6
  from loguru import logger
7
7
 
8
- from qubx.core.lookups import FeesLookup, GlobalLookup, InstrumentsLookup
9
- from qubx.utils import runtime_env, set_mpl_theme
10
- from qubx.utils.misc import install_pyx_recompiler_for_dev
11
-
12
8
  # - TODO: import some main methods from packages
13
9
 
14
10
 
11
+ def runtime_env():
12
+ """
13
+ Check what environment this script is being run under
14
+ :return: environment name, possible values:
15
+ - 'notebook' jupyter notebook
16
+ - 'shell' any interactive shell (ipython, PyCharm's console etc)
17
+ - 'python' standard python interpreter
18
+ - 'unknown' can't recognize environment
19
+ """
20
+ try:
21
+ from IPython.core.getipython import get_ipython
22
+
23
+ shell = get_ipython().__class__.__name__
24
+
25
+ if shell == "ZMQInteractiveShell": # Jupyter notebook or qtconsole
26
+ return "notebook"
27
+ elif shell.endswith("TerminalInteractiveShell"): # Terminal running IPython
28
+ return "shell"
29
+ else:
30
+ return "unknown" # Other type (?)
31
+ except (NameError, ImportError):
32
+ return "python" # Probably standard Python interpreter
33
+
34
+
15
35
  def formatter(record):
16
36
  end = record["extra"].get("end", "\n")
17
37
  fmt = "<lvl>{message}</lvl>%s" % end
@@ -47,6 +67,11 @@ class QubxLogConfig:
47
67
  @staticmethod
48
68
  def setup_logger(level: str | None = None, custom_formatter: Callable | None = None):
49
69
  global logger
70
+
71
+ # First, remove all existing handlers to prevent resource leaks
72
+ # Use a safer approach that doesn't rely on internal attributes
73
+ logger.remove()
74
+
50
75
  config = {
51
76
  "handlers": [
52
77
  {"sink": sys.stdout, "format": "{time} - {message}"},
@@ -54,24 +79,42 @@ class QubxLogConfig:
54
79
  "extra": {"user": "someone"},
55
80
  }
56
81
  logger.configure(**config)
57
- logger.remove(None)
82
+
58
83
  level = level or QubxLogConfig.get_log_level()
59
- logger.add(sys.stdout, format=custom_formatter or formatter, colorize=True, level=level, enqueue=True)
84
+ # Add stdout handler with enqueue=True for thread/process safety
85
+ logger.add(
86
+ sys.stdout,
87
+ format=custom_formatter or formatter,
88
+ colorize=True,
89
+ level=level,
90
+ enqueue=True,
91
+ backtrace=True,
92
+ diagnose=True,
93
+ )
60
94
  logger = logger.opt(colors=True)
61
95
 
62
96
 
63
97
  QubxLogConfig.setup_logger()
64
98
 
65
99
 
66
- # - global lookup helper
67
- lookup = GlobalLookup(InstrumentsLookup(), FeesLookup())
68
-
69
-
70
100
  # registering magic for jupyter notebook
71
101
  if runtime_env() in ["notebook", "shell"]:
72
102
  from IPython.core.getipython import get_ipython
73
103
  from IPython.core.magic import Magics, line_cell_magic, line_magic, magics_class
74
104
 
105
+ from qubx.utils.charting.lookinglass import LookingGlass # noqa: F401
106
+ from qubx.utils.charting.mpl_helpers import ( # noqa: F401
107
+ ellips,
108
+ fig,
109
+ hline,
110
+ ohlc_plot,
111
+ plot_trends,
112
+ sbp,
113
+ set_mpl_theme,
114
+ vline,
115
+ )
116
+ from qubx.utils.misc import install_pyx_recompiler_for_dev
117
+
75
118
  @magics_class
76
119
  class QubxMagics(Magics):
77
120
  # process data manager
@@ -119,7 +162,7 @@ if runtime_env() in ["notebook", "shell"]:
119
162
  exec(_vscode_clr_trick, self.shell.user_ns)
120
163
 
121
164
  elif "light" in line.lower():
122
- set_mpl_theme("light")
165
+ sort: skip_mpl_theme("light")
123
166
 
124
167
  def _get_manager(self):
125
168
  if self.__manager is None:
qubx/_nb_magic.py CHANGED
@@ -3,7 +3,7 @@ Here stuff we want to have in every Jupyter notebook after calling %qubx magic
3
3
  """
4
4
 
5
5
  import qubx
6
- from qubx.utils import runtime_env
6
+ from qubx import runtime_env
7
7
  from qubx.utils.misc import add_project_to_system_path, logo
8
8
 
9
9
 
@@ -4,13 +4,14 @@ import numpy as np
4
4
  import pandas as pd
5
5
  from joblib import delayed
6
6
 
7
- from qubx import QubxLogConfig, logger, lookup
7
+ from qubx import QubxLogConfig, logger
8
8
  from qubx.core.basics import SW, DataType
9
9
  from qubx.core.context import StrategyContext
10
10
  from qubx.core.exceptions import SimulationConfigError, SimulationError
11
11
  from qubx.core.helpers import extract_parameters_from_object, full_qualified_class_name
12
12
  from qubx.core.interfaces import IStrategy
13
13
  from qubx.core.loggers import InMemoryLogsWriter, StrategyLogging
14
+ from qubx.core.lookups import lookup
14
15
  from qubx.core.metrics import TradingSessionResult
15
16
  from qubx.data.readers import DataReader
16
17
  from qubx.pandaz.utils import _frame_to_str
qubx/backtester/utils.py CHANGED
@@ -6,7 +6,7 @@ import numpy as np
6
6
  import pandas as pd
7
7
  import stackprinter
8
8
 
9
- from qubx import logger, lookup
9
+ from qubx import logger
10
10
  from qubx.core.basics import (
11
11
  CtrlChannel,
12
12
  DataType,
@@ -20,6 +20,7 @@ from qubx.core.basics import (
20
20
  from qubx.core.exceptions import SimulationConfigError, SimulationError
21
21
  from qubx.core.helpers import BasicScheduler
22
22
  from qubx.core.interfaces import IStrategy, IStrategyContext, PositionsTracker
23
+ from qubx.core.lookups import lookup
23
24
  from qubx.core.series import OHLCV, Bar, Quote, Trade
24
25
  from qubx.core.utils import time_delta_to_str
25
26
  from qubx.data.helpers import InMemoryCachedReader, TimeGuardedWrapper
qubx/cli/commands.py CHANGED
@@ -1,17 +1,50 @@
1
+ import os
2
+ import sys
1
3
  from pathlib import Path
2
4
 
3
5
  import click
4
6
 
5
- from qubx.utils.misc import add_project_to_system_path, logo
6
- from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter, simulate_strategy
7
+ from qubx import QubxLogConfig, logger
7
8
 
8
9
 
9
10
  @click.group()
10
- def main():
11
+ @click.option(
12
+ "--debug",
13
+ "-d",
14
+ is_flag=True,
15
+ help="Enable debug mode.",
16
+ )
17
+ @click.option(
18
+ "--debug-port",
19
+ "-p",
20
+ type=int,
21
+ help="Debug port.",
22
+ default=5678,
23
+ )
24
+ @click.option(
25
+ "--log-level",
26
+ "-l",
27
+ type=str,
28
+ help="Log level.",
29
+ default="INFO",
30
+ )
31
+ def main(debug: bool, debug_port: int, log_level: str):
11
32
  """
12
33
  Qubx CLI.
13
34
  """
14
- pass
35
+ log_level = log_level.upper() if not debug else "DEBUG"
36
+
37
+ QubxLogConfig.set_log_level(log_level)
38
+
39
+ if debug:
40
+ os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1"
41
+
42
+ import debugpy
43
+
44
+ logger.info(f"Waiting for debugger to attach (port {debug_port})")
45
+
46
+ debugpy.listen(debug_port)
47
+ debugpy.wait_for_client()
15
48
 
16
49
 
17
50
  @main.command()
@@ -36,6 +69,9 @@ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool
36
69
  - If exists, accounts.toml located in the same folder with the config searched.\n
37
70
  - If neither of the above are provided, the accounts.toml in the ~/qubx/accounts.toml path is searched.
38
71
  """
72
+ from qubx.utils.misc import add_project_to_system_path, logo
73
+ from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter
74
+
39
75
  add_project_to_system_path()
40
76
  add_project_to_system_path(str(config_file.parent))
41
77
  if jupyter:
@@ -57,11 +93,190 @@ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool
57
93
  "--output", "-o", default="results", type=str, help="Output directory for simulation results.", show_default=True
58
94
  )
59
95
  def simulate(config_file: Path, start: str | None, end: str | None, output: str | None):
96
+ """
97
+ Simulates the strategy with the given configuration file.
98
+ """
99
+ from qubx.utils.misc import add_project_to_system_path, logo
100
+ from qubx.utils.runner.runner import simulate_strategy
101
+
60
102
  add_project_to_system_path()
61
103
  add_project_to_system_path(str(config_file.parent))
62
104
  logo()
63
105
  simulate_strategy(config_file, output, start, end)
64
106
 
65
107
 
108
+ @main.command()
109
+ @click.argument(
110
+ "directory",
111
+ type=click.Path(exists=True, resolve_path=True),
112
+ default=".",
113
+ callback=lambda ctx, param, value: os.path.abspath(os.path.expanduser(value)),
114
+ )
115
+ def ls(directory: str):
116
+ """
117
+ Lists all strategies in the given directory.
118
+
119
+ Strategies are identified by the inheritance from IStrategy interface.
120
+ """
121
+ from .release import ls_strats
122
+
123
+ ls_strats(directory)
124
+
125
+
126
+ @main.command()
127
+ @click.argument(
128
+ "directory",
129
+ type=click.Path(exists=False),
130
+ default=".",
131
+ callback=lambda ctx, param, value: os.path.abspath(os.path.expanduser(value)),
132
+ )
133
+ @click.option(
134
+ "--strategy",
135
+ "-s",
136
+ type=click.STRING,
137
+ help="Strategy name to release (should match the strategy class name) or path to a config YAML file",
138
+ required=True,
139
+ )
140
+ @click.option(
141
+ "--output-dir",
142
+ "-o",
143
+ type=click.STRING,
144
+ help="Output directory to put zip file.",
145
+ default="releases",
146
+ show_default=True,
147
+ )
148
+ @click.option(
149
+ "--tag",
150
+ "-t",
151
+ type=click.STRING,
152
+ help="Additional tag for this release (e.g. 'v1.0.0')",
153
+ required=False,
154
+ )
155
+ @click.option(
156
+ "--message",
157
+ "-m",
158
+ type=click.STRING,
159
+ help="Release message (added to the info yaml file).",
160
+ required=False,
161
+ default=None,
162
+ show_default=True,
163
+ )
164
+ @click.option(
165
+ "--commit",
166
+ "-c",
167
+ is_flag=True,
168
+ default=False,
169
+ help="Commit changes and create tag in repo (default: False)",
170
+ show_default=True,
171
+ )
172
+ @click.option(
173
+ "--default-exchange",
174
+ type=click.STRING,
175
+ help="Default exchange to use in the generated config.",
176
+ default="BINANCE.UM",
177
+ show_default=True,
178
+ )
179
+ @click.option(
180
+ "--default-connector",
181
+ type=click.STRING,
182
+ help="Default connector to use in the generated config.",
183
+ default="ccxt",
184
+ show_default=True,
185
+ )
186
+ @click.option(
187
+ "--default-instruments",
188
+ type=click.STRING,
189
+ help="Default instruments to use in the generated config (comma-separated).",
190
+ default="BTCUSDT",
191
+ show_default=True,
192
+ )
193
+ def release(
194
+ directory: str,
195
+ strategy: str,
196
+ tag: str | None,
197
+ message: str | None,
198
+ commit: bool,
199
+ output_dir: str,
200
+ default_exchange: str,
201
+ default_connector: str,
202
+ default_instruments: str,
203
+ ) -> None:
204
+ """
205
+ Releases the strategy to a zip file.
206
+
207
+ The strategy can be specified in two ways:
208
+ 1. As a strategy name (class name) - strategies are scanned in the given directory
209
+ 2. As a path to a config YAML file containing the strategy configuration in StrategyConfig format
210
+
211
+ If a strategy name is provided, a default configuration will be generated with:
212
+ - The strategy parameters from the strategy class
213
+ - Default exchange, connector, and instruments from the command options
214
+ - Standard logging configuration
215
+
216
+ If a config file is provided, it must follow the StrategyConfig structure with:
217
+ - strategy: The strategy name or path
218
+ - parameters: Dictionary of strategy parameters
219
+ - exchanges: Dictionary of exchange configurations
220
+ - aux: Auxiliary configuration
221
+ - logging: Logging configuration
222
+
223
+ All of the dependencies are included in the zip file.
224
+ """
225
+ from .release import release_strategy
226
+
227
+ # Parse default instruments
228
+ instruments = [instr.strip() for instr in default_instruments.split(",")]
229
+
230
+ release_strategy(
231
+ directory=directory,
232
+ strategy_name=strategy,
233
+ tag=tag,
234
+ message=message,
235
+ commit=commit,
236
+ output_dir=output_dir,
237
+ default_exchange=default_exchange,
238
+ default_connector=default_connector,
239
+ default_instruments=instruments,
240
+ )
241
+
242
+
243
+ @main.command()
244
+ @click.argument(
245
+ "zip-file",
246
+ type=click.Path(exists=True, resolve_path=True),
247
+ callback=lambda ctx, param, value: os.path.abspath(os.path.expanduser(value)),
248
+ )
249
+ @click.option(
250
+ "--output-dir",
251
+ "-o",
252
+ type=click.Path(exists=False),
253
+ help="Output directory to unpack the zip file. Defaults to the directory containing the zip file.",
254
+ default=None,
255
+ )
256
+ @click.option(
257
+ "--force",
258
+ "-f",
259
+ is_flag=True,
260
+ default=False,
261
+ help="Force overwrite if the output directory already exists.",
262
+ show_default=True,
263
+ )
264
+ def deploy(zip_file: str, output_dir: str | None, force: bool):
265
+ """
266
+ Deploys a strategy from a zip file created by the release command.
267
+
268
+ This command:
269
+ 1. Unpacks the zip file to the specified output directory
270
+ 2. Creates a Poetry virtual environment in the .venv folder
271
+ 3. Installs dependencies from the poetry.lock file
272
+
273
+ If no output directory is specified, the zip file is unpacked in the same directory
274
+ as the zip file, in a folder with the same name as the zip file (without the .zip extension).
275
+ """
276
+ from .deploy import deploy_strategy
277
+
278
+ deploy_strategy(zip_file, output_dir, force)
279
+
280
+
66
281
  if __name__ == "__main__":
67
282
  main()
qubx/cli/deploy.py ADDED
@@ -0,0 +1,232 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import zipfile
5
+ from pathlib import Path
6
+
7
+ from qubx import logger
8
+
9
+
10
+ def validate_zip_file(zip_file: str) -> bool:
11
+ """
12
+ Validates that the provided file is a zip file.
13
+
14
+ Args:
15
+ zip_file: Path to the zip file to validate
16
+
17
+ Returns:
18
+ bool: True if valid, False otherwise
19
+ """
20
+ if not zip_file.endswith(".zip"):
21
+ logger.error("The file must be a zip file with .zip extension")
22
+ return False
23
+ return True
24
+
25
+
26
+ def determine_output_directory(zip_file: str, output_dir: str | None) -> str:
27
+ """
28
+ Determines the output directory for the deployment.
29
+
30
+ Args:
31
+ zip_file: Path to the zip file to deploy
32
+ output_dir: User-specified output directory or None
33
+
34
+ Returns:
35
+ str: The resolved output directory path
36
+ """
37
+ zip_path = Path(zip_file)
38
+ zip_name = zip_path.stem
39
+
40
+ if output_dir is None:
41
+ return str(zip_path.parent / zip_name)
42
+
43
+ output_dir = os.path.abspath(os.path.expanduser(output_dir))
44
+ # If output_dir is a directory that exists, create a subdirectory with the zip name
45
+ if os.path.isdir(output_dir):
46
+ return os.path.join(output_dir, zip_name)
47
+
48
+ return output_dir
49
+
50
+
51
+ def prepare_output_directory(output_dir: str, force: bool) -> bool:
52
+ """
53
+ Prepares the output directory, handling existing directories based on the force flag.
54
+
55
+ Args:
56
+ output_dir: The output directory path
57
+ force: Whether to force overwrite if the directory exists
58
+
59
+ Returns:
60
+ bool: True if successful, False otherwise
61
+ """
62
+ if os.path.exists(output_dir):
63
+ if not force:
64
+ logger.error(f"Output directory {output_dir} already exists. Use --force to overwrite.")
65
+ return False
66
+ logger.warning(f"Removing existing directory {output_dir}")
67
+ shutil.rmtree(output_dir)
68
+
69
+ os.makedirs(output_dir, exist_ok=True)
70
+ return True
71
+
72
+
73
+ def extract_zip_file(zip_file: str, output_dir: str) -> bool:
74
+ """
75
+ Extracts the zip file to the output directory.
76
+
77
+ Args:
78
+ zip_file: Path to the zip file to extract
79
+ output_dir: Directory to extract to
80
+
81
+ Returns:
82
+ bool: True if successful, False otherwise
83
+ """
84
+ logger.info(f"Unpacking {zip_file} to {output_dir}")
85
+ try:
86
+ with zipfile.ZipFile(zip_file, "r") as zip_ref:
87
+ zip_ref.extractall(output_dir)
88
+ return True
89
+ except zipfile.BadZipFile:
90
+ logger.error(f"The file {zip_file} is not a valid zip file")
91
+ return False
92
+ except Exception as e:
93
+ logger.error(f"Failed to unpack zip file: {e}")
94
+ return False
95
+
96
+
97
+ def ensure_poetry_lock_exists(output_dir: str) -> bool:
98
+ """
99
+ Ensures that a poetry.lock file exists in the output directory.
100
+ If not, attempts to generate one.
101
+
102
+ Args:
103
+ output_dir: The directory to check/generate in
104
+
105
+ Returns:
106
+ bool: True if successful, False otherwise
107
+ """
108
+ poetry_lock_path = os.path.join(output_dir, "poetry.lock")
109
+ if not os.path.exists(poetry_lock_path):
110
+ logger.warning("poetry.lock not found in the zip file. Attempting to generate it.")
111
+ try:
112
+ subprocess.run(
113
+ ["poetry", "lock", "--no-update"], cwd=output_dir, check=True, capture_output=True, text=True
114
+ )
115
+ return True
116
+ except subprocess.CalledProcessError as e:
117
+ logger.error(f"Failed to generate poetry.lock: {e.stderr}")
118
+ return False
119
+ return True
120
+
121
+
122
+ def setup_poetry_environment(output_dir: str) -> bool:
123
+ """
124
+ Sets up the Poetry virtual environment in the output directory.
125
+
126
+ Args:
127
+ output_dir: The directory to set up the environment in
128
+
129
+ Returns:
130
+ bool: True if successful, False otherwise
131
+ """
132
+ logger.info("Creating Poetry virtual environment")
133
+ try:
134
+ # Configure Poetry to create a virtual environment in the .venv directory
135
+ logger.info("Configuring Poetry")
136
+ subprocess.run(
137
+ ["poetry", "config", "virtualenvs.in-project", "true", "--local"],
138
+ cwd=output_dir,
139
+ check=True,
140
+ capture_output=True,
141
+ text=True,
142
+ )
143
+
144
+ # Check if we're already in a Poetry shell
145
+ in_poetry_env = "POETRY_ACTIVE" in os.environ or "VIRTUAL_ENV" in os.environ
146
+
147
+ if in_poetry_env:
148
+ logger.debug(
149
+ "Detected active Poetry environment. "
150
+ "Will explicitly create a new environment for the deployed strategy."
151
+ )
152
+
153
+ # Install dependencies
154
+ logger.info("Installing dependencies")
155
+
156
+ # If we're in a Poetry shell, we need to be more explicit about creating a new environment
157
+ install_cmd = ["poetry", "install"]
158
+ if in_poetry_env:
159
+ # Force Poetry to create a new environment even if we're in an active one
160
+ env = os.environ.copy()
161
+ # Temporarily unset Poetry environment variables to avoid interference
162
+ for var in ["POETRY_ACTIVE", "VIRTUAL_ENV"]:
163
+ if var in env:
164
+ del env[var]
165
+
166
+ subprocess.run(install_cmd, cwd=output_dir, check=True, capture_output=True, text=True, env=env)
167
+ else:
168
+ # Normal case - not in a Poetry shell
169
+ subprocess.run(install_cmd, cwd=output_dir, check=True, capture_output=True, text=True)
170
+
171
+ # Verify that the virtual environment was created
172
+ venv_path = os.path.join(output_dir, ".venv")
173
+ if not os.path.exists(venv_path):
174
+ logger.warning(
175
+ "Virtual environment directory (.venv) not found. "
176
+ "This might happen if you're already in a Poetry shell. "
177
+ "You may need to run 'cd %s && poetry env use python' to create it manually.",
178
+ output_dir,
179
+ )
180
+
181
+ return True
182
+ except subprocess.CalledProcessError as e:
183
+ logger.error(f"Failed to set up Poetry environment: {e.stderr}")
184
+ return False
185
+
186
+
187
+ def deploy_strategy(zip_file: str, output_dir: str | None, force: bool) -> bool:
188
+ """
189
+ Deploys a strategy from a zip file created by the release command.
190
+
191
+ This function:
192
+ 1. Unpacks the zip file to the specified output directory
193
+ 2. Creates a Poetry virtual environment in the .venv folder
194
+ 3. Installs dependencies from the poetry.lock file
195
+
196
+ Args:
197
+ zip_file: Path to the zip file to deploy
198
+ output_dir: Output directory to unpack the zip file. If None, uses the directory containing the zip file.
199
+ force: Whether to force overwrite if the output directory already exists
200
+
201
+ Returns:
202
+ bool: True if deployment was successful, False otherwise
203
+ """
204
+ # Validate the zip file
205
+ if not validate_zip_file(zip_file):
206
+ return False
207
+
208
+ # Determine the output directory
209
+ resolved_output_dir = determine_output_directory(zip_file, output_dir)
210
+
211
+ # Prepare the output directory
212
+ if not prepare_output_directory(resolved_output_dir, force):
213
+ return False
214
+
215
+ # Extract the zip file
216
+ if not extract_zip_file(zip_file, resolved_output_dir):
217
+ return False
218
+
219
+ # Ensure poetry.lock exists
220
+ if not ensure_poetry_lock_exists(resolved_output_dir):
221
+ return False
222
+
223
+ # Set up the Poetry environment
224
+ if not setup_poetry_environment(resolved_output_dir):
225
+ return False
226
+
227
+ # Success messages
228
+ logger.info(f"Strategy deployed successfully to {resolved_output_dir}")
229
+ logger.info(
230
+ f"To run the strategy (paper mode): <cyan>cd {resolved_output_dir} && poetry run qubx run config.yml --paper</cyan>"
231
+ )
232
+ return True