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 +54 -11
- qubx/_nb_magic.py +1 -1
- qubx/backtester/simulator.py +2 -1
- qubx/backtester/utils.py +2 -1
- qubx/cli/commands.py +219 -4
- qubx/cli/deploy.py +232 -0
- qubx/cli/misc.py +381 -0
- qubx/cli/release.py +681 -0
- qubx/core/loggers.py +4 -0
- qubx/core/lookups.py +4 -0
- qubx/core/mixins/market.py +1 -1
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyi +21 -4
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/__init__.py +14 -3
- qubx/utils/_pyxreloader.py +111 -58
- qubx/utils/misc.py +0 -24
- qubx/utils/orderbook.py +2 -1
- qubx/utils/plotting/dashboard.py +2 -1
- qubx/utils/runner/__init__.py +0 -1
- qubx/utils/runner/accounts.py +3 -3
- qubx/utils/runner/runner.py +2 -1
- qubx/utils/version.py +182 -0
- {qubx-0.5.8.dist-info → qubx-0.6.0.dist-info}/METADATA +4 -1
- {qubx-0.5.8.dist-info → qubx-0.6.0.dist-info}/RECORD +28 -24
- {qubx-0.5.8.dist-info → qubx-0.6.0.dist-info}/WHEEL +0 -0
- {qubx-0.5.8.dist-info → qubx-0.6.0.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
82
|
+
|
|
58
83
|
level = level or QubxLogConfig.get_log_level()
|
|
59
|
-
|
|
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
|
-
|
|
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
qubx/backtester/simulator.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|