Qubx 0.5.3__cp311-cp311-manylinux_2_35_x86_64.whl → 0.5.6__cp311-cp311-manylinux_2_35_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/backtester/account.py +4 -3
- qubx/backtester/management.py +134 -16
- qubx/backtester/simulator.py +4 -1
- qubx/core/context.py +6 -2
- qubx/core/helpers.py +29 -15
- qubx/core/interfaces.py +63 -5
- qubx/core/metrics.py +85 -7
- qubx/core/mixins/processing.py +21 -5
- qubx/core/mixins/universe.py +138 -21
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/trackers/sizers.py +54 -0
- qubx/utils/runner/configs.py +2 -0
- qubx/utils/runner/runner.py +56 -9
- qubx/utils/time.py +85 -0
- {qubx-0.5.3.dist-info → qubx-0.5.6.dist-info}/METADATA +1 -1
- {qubx-0.5.3.dist-info → qubx-0.5.6.dist-info}/RECORD +20 -20
- {qubx-0.5.3.dist-info → qubx-0.5.6.dist-info}/WHEEL +0 -0
- {qubx-0.5.3.dist-info → qubx-0.5.6.dist-info}/entry_points.txt +0 -0
qubx/backtester/account.py
CHANGED
|
@@ -48,14 +48,15 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
|
|
|
48
48
|
if self._fill_stop_order_at_price:
|
|
49
49
|
logger.info(f"[<y>{self.__class__.__name__}</y>] :: emulates stop orders executions at exact price")
|
|
50
50
|
|
|
51
|
-
def get_orders(self, instrument: Instrument | None = None) ->
|
|
51
|
+
def get_orders(self, instrument: Instrument | None = None) -> dict[str, Order]:
|
|
52
52
|
if instrument is not None:
|
|
53
53
|
ome = self.ome.get(instrument)
|
|
54
54
|
if ome is None:
|
|
55
55
|
raise ValueError(f"ExchangeService:get_orders :: No OME configured for '{instrument}'!")
|
|
56
|
-
return ome.get_open_orders()
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
return {o.id: o for o in ome.get_open_orders()}
|
|
58
|
+
|
|
59
|
+
return {o.id: o for ome in self.ome.values() for o in ome.get_open_orders()}
|
|
59
60
|
|
|
60
61
|
def get_position(self, instrument: Instrument) -> Position:
|
|
61
62
|
if instrument in self.positions:
|
qubx/backtester/management.py
CHANGED
|
@@ -60,7 +60,20 @@ class BacktestsResultsManager:
|
|
|
60
60
|
|
|
61
61
|
return self
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def __getitem__(
|
|
64
|
+
self, name: str | int | list[int] | list[str] | slice
|
|
65
|
+
) -> TradingSessionResult | list[TradingSessionResult]:
|
|
66
|
+
return self.load(name)
|
|
67
|
+
|
|
68
|
+
def load(
|
|
69
|
+
self, name: str | int | list[int] | list[str] | slice
|
|
70
|
+
) -> TradingSessionResult | list[TradingSessionResult]:
|
|
71
|
+
match name:
|
|
72
|
+
case list():
|
|
73
|
+
return [self.load(i) for i in name]
|
|
74
|
+
case slice():
|
|
75
|
+
return [self.load(i) for i in range(name.start, name.stop, name.step if name.step else 1)]
|
|
76
|
+
|
|
64
77
|
for info in self.results.values():
|
|
65
78
|
match name:
|
|
66
79
|
case int():
|
|
@@ -69,12 +82,80 @@ class BacktestsResultsManager:
|
|
|
69
82
|
case str():
|
|
70
83
|
if info.get("name", "") == name:
|
|
71
84
|
return TradingSessionResult.from_file(info["path"])
|
|
72
|
-
case list():
|
|
73
|
-
return [self.load(i) for i in name]
|
|
74
|
-
|
|
75
85
|
raise ValueError(f"No result found for {name}")
|
|
76
86
|
|
|
77
|
-
def
|
|
87
|
+
def delete(self, name: str | int | list[int] | list[str] | slice):
|
|
88
|
+
def _del_idx(idx):
|
|
89
|
+
for info in self.results.values():
|
|
90
|
+
if info.get("idx", -1) == idx:
|
|
91
|
+
Path(info["path"]).unlink()
|
|
92
|
+
return info.get("name", idx)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
match name:
|
|
96
|
+
case str():
|
|
97
|
+
nms = [_del_idx(i) for i in self._find_indices(name)]
|
|
98
|
+
self.reload()
|
|
99
|
+
print(f" -> Deleted {red(', '.join(nms))} ...")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
case list():
|
|
103
|
+
nms = [_del_idx(i) for i in name]
|
|
104
|
+
self.reload()
|
|
105
|
+
print(f" -> Deleted {red(', '.join(nms))} ...")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
case slice():
|
|
109
|
+
nms = [_del_idx(i) for i in range(name.start, name.stop, name.step if name.step else 1)]
|
|
110
|
+
self.reload()
|
|
111
|
+
print(f" -> Deleted {red(', '.join(nms))} ...")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
for info in self.results.values():
|
|
115
|
+
match name:
|
|
116
|
+
case int():
|
|
117
|
+
if info.get("idx", -1) == name:
|
|
118
|
+
Path(info["path"]).unlink()
|
|
119
|
+
print(f" -> Deleted {red(info.get('name', name))} ...")
|
|
120
|
+
self.reload()
|
|
121
|
+
return
|
|
122
|
+
case str():
|
|
123
|
+
if info.get("name", "") == name:
|
|
124
|
+
Path(info["path"]).unlink()
|
|
125
|
+
print(f" -> Deleted {red(info.get('name', name))} ...")
|
|
126
|
+
self.reload()
|
|
127
|
+
return
|
|
128
|
+
print(f" -> No results found for {red(name)} !")
|
|
129
|
+
|
|
130
|
+
def _find_indices(self, regex: str):
|
|
131
|
+
for n in sorted(self.results.keys()):
|
|
132
|
+
info = self.results[n]
|
|
133
|
+
s_cls = info.get("strategy_class", "").split(".")[-1]
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
if not re.match(regex, n, re.IGNORECASE):
|
|
137
|
+
if not re.match(regex, s_cls, re.IGNORECASE):
|
|
138
|
+
continue
|
|
139
|
+
except Exception:
|
|
140
|
+
if regex.lower() != n.lower() and regex.lower() != s_cls.lower():
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
yield info.get("idx", -1)
|
|
144
|
+
|
|
145
|
+
def list(self, regex: str = "", with_metrics=True, params=False, as_table=False):
|
|
146
|
+
"""List backtesting results with optional filtering and formatting.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
- regex (str, optional): Regular expression pattern to filter results by strategy name or class. Defaults to "".
|
|
150
|
+
- with_metrics (bool, optional): Whether to include performance metrics in output. Defaults to True.
|
|
151
|
+
- params (bool, optional): Whether to display strategy parameters. Defaults to False.
|
|
152
|
+
- as_table (bool, optional): Return results as a pandas DataFrame instead of printing. Defaults to False.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
- Optional[pd.DataFrame]: If as_table=True, returns a DataFrame containing the results sorted by creation time.
|
|
156
|
+
- Otherwise prints formatted results to console.
|
|
157
|
+
"""
|
|
158
|
+
_t_rep = []
|
|
78
159
|
for n in sorted(self.results.keys()):
|
|
79
160
|
info = self.results[n]
|
|
80
161
|
s_cls = info.get("strategy_class", "").split(".")[-1]
|
|
@@ -89,9 +170,18 @@ class BacktestsResultsManager:
|
|
|
89
170
|
start = pd.Timestamp(info.get("start", "")).round("1s")
|
|
90
171
|
stop = pd.Timestamp(info.get("stop", "")).round("1s")
|
|
91
172
|
dscr = info.get("description", "")
|
|
92
|
-
|
|
173
|
+
created = pd.Timestamp(info.get("creation_time", "")).round("1s")
|
|
174
|
+
metrics = info.get("performance", {})
|
|
175
|
+
author = info.get("author", "")
|
|
176
|
+
_s = f"{yellow(str(info.get('idx')))} - {red(name)} ::: {magenta(created)} by {cyan(author)}"
|
|
177
|
+
|
|
178
|
+
_one_line_dscr = ""
|
|
93
179
|
if dscr:
|
|
94
|
-
|
|
180
|
+
dscr = dscr.split("\n")
|
|
181
|
+
for _d in dscr:
|
|
182
|
+
_s += f"\n\t{magenta('# ' + _d)}"
|
|
183
|
+
_one_line_dscr += " " + _d
|
|
184
|
+
|
|
95
185
|
_s += f"\n\tstrategy: {green(s_cls)}"
|
|
96
186
|
_s += f"\n\tinterval: {blue(start)} - {blue(stop)}"
|
|
97
187
|
_s += f"\n\tcapital: {blue(info.get('capital', ''))} {info.get('base_currency', '')} ({info.get('commissions', '')})"
|
|
@@ -106,14 +196,42 @@ class BacktestsResultsManager:
|
|
|
106
196
|
justify="left",
|
|
107
197
|
).split("\n"):
|
|
108
198
|
_s += f"\n\t | {yellow(i)}"
|
|
109
|
-
|
|
199
|
+
|
|
200
|
+
if not as_table:
|
|
201
|
+
print(_s)
|
|
110
202
|
|
|
111
203
|
if with_metrics:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
204
|
+
_m_repr = (
|
|
205
|
+
pd.DataFrame.from_dict(metrics, orient="index")
|
|
206
|
+
.T[["gain", "cagr", "sharpe", "qr", "max_dd_pct", "mdd_usd", "fees", "execs"]]
|
|
207
|
+
.astype(float)
|
|
208
|
+
)
|
|
209
|
+
_m_repr = _m_repr.round(3).to_string(index=False)
|
|
210
|
+
_h, _v = _m_repr.split("\n")
|
|
211
|
+
if not as_table:
|
|
212
|
+
print("\t " + red(_h))
|
|
213
|
+
print("\t " + cyan(_v))
|
|
214
|
+
|
|
215
|
+
if not as_table:
|
|
216
|
+
print()
|
|
217
|
+
else:
|
|
218
|
+
metrics = {
|
|
219
|
+
m: round(v, 3)
|
|
220
|
+
for m, v in metrics.items()
|
|
221
|
+
if m in ["gain", "cagr", "sharpe", "qr", "max_dd_pct", "mdd_usd", "fees", "execs"]
|
|
222
|
+
}
|
|
223
|
+
_t_rep.append(
|
|
224
|
+
{"Index": info.get("idx", ""), "Strategy": name}
|
|
225
|
+
| metrics
|
|
226
|
+
| {
|
|
227
|
+
"start": start,
|
|
228
|
+
"stop": stop,
|
|
229
|
+
"Created": created,
|
|
230
|
+
"Author": author,
|
|
231
|
+
"Description": _one_line_dscr,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if as_table:
|
|
236
|
+
_df = pd.DataFrame.from_records(_t_rep, index="Index")
|
|
237
|
+
return _df.sort_values(by="Created", ascending=False)
|
qubx/backtester/simulator.py
CHANGED
|
@@ -57,6 +57,7 @@ def simulate(
|
|
|
57
57
|
open_close_time_indent_secs=1,
|
|
58
58
|
debug: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "WARNING",
|
|
59
59
|
show_latency_report: bool = False,
|
|
60
|
+
parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
|
|
60
61
|
) -> list[TradingSessionResult]:
|
|
61
62
|
"""
|
|
62
63
|
Backtest utility for trading strategies or signals using historical data.
|
|
@@ -149,6 +150,7 @@ def simulate(
|
|
|
149
150
|
n_jobs=n_jobs,
|
|
150
151
|
silent=silent,
|
|
151
152
|
show_latency_report=show_latency_report,
|
|
153
|
+
parallel_backend=parallel_backend,
|
|
152
154
|
)
|
|
153
155
|
|
|
154
156
|
|
|
@@ -160,6 +162,7 @@ def _run_setups(
|
|
|
160
162
|
n_jobs: int = -1,
|
|
161
163
|
silent: bool = False,
|
|
162
164
|
show_latency_report: bool = False,
|
|
165
|
+
parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
|
|
163
166
|
) -> list[TradingSessionResult]:
|
|
164
167
|
# loggers don't work well with joblib and multiprocessing in general because they contain
|
|
165
168
|
# open file handlers that cannot be pickled. I found a solution which requires the usage of enqueue=True
|
|
@@ -170,7 +173,7 @@ def _run_setups(
|
|
|
170
173
|
n_jobs = 1 if _main_loop_silent else n_jobs
|
|
171
174
|
|
|
172
175
|
reports = ProgressParallel(
|
|
173
|
-
n_jobs=n_jobs, total=len(strategies_setups), silent=_main_loop_silent, backend=
|
|
176
|
+
n_jobs=n_jobs, total=len(strategies_setups), silent=_main_loop_silent, backend=parallel_backend
|
|
174
177
|
)(
|
|
175
178
|
delayed(_run_setup)(id, f"Simulated-{id}", setup, data_setup, start, stop, silent, show_latency_report)
|
|
176
179
|
for id, setup in enumerate(strategies_setups)
|
qubx/core/context.py
CHANGED
|
@@ -34,6 +34,7 @@ from qubx.core.interfaces import (
|
|
|
34
34
|
ITradingManager,
|
|
35
35
|
IUniverseManager,
|
|
36
36
|
PositionsTracker,
|
|
37
|
+
RemovalPolicy,
|
|
37
38
|
)
|
|
38
39
|
from qubx.core.loggers import StrategyLogging
|
|
39
40
|
from qubx.data.readers import DataReader
|
|
@@ -144,6 +145,7 @@ class StrategyContext(IStrategyContext):
|
|
|
144
145
|
account=self.account,
|
|
145
146
|
position_tracker=__position_tracker,
|
|
146
147
|
position_gathering=__position_gathering,
|
|
148
|
+
universe_manager=self._universe_manager,
|
|
147
149
|
cache=self._cache,
|
|
148
150
|
scheduler=self._scheduler,
|
|
149
151
|
is_simulation=self._data_provider.is_simulation,
|
|
@@ -325,8 +327,10 @@ class StrategyContext(IStrategyContext):
|
|
|
325
327
|
return self._trading_manager.cancel_orders(instrument)
|
|
326
328
|
|
|
327
329
|
# IUniverseManager delegation
|
|
328
|
-
def set_universe(
|
|
329
|
-
|
|
330
|
+
def set_universe(
|
|
331
|
+
self, instruments: list[Instrument], skip_callback: bool = False, if_has_position_then: RemovalPolicy = "close"
|
|
332
|
+
):
|
|
333
|
+
return self._universe_manager.set_universe(instruments, skip_callback, if_has_position_then)
|
|
330
334
|
|
|
331
335
|
def add_instruments(self, instruments: list[Instrument]):
|
|
332
336
|
return self._universe_manager.add_instruments(instruments)
|
qubx/core/helpers.py
CHANGED
|
@@ -14,7 +14,7 @@ from croniter import croniter
|
|
|
14
14
|
from qubx import logger
|
|
15
15
|
from qubx.core.basics import SW, CtrlChannel, DataType, Instrument, Timestamped
|
|
16
16
|
from qubx.core.series import OHLCV, Bar, OrderBook, Quote, Trade
|
|
17
|
-
from qubx.utils.time import convert_seconds_to_str, convert_tf_str_td64
|
|
17
|
+
from qubx.utils.time import convert_seconds_to_str, convert_tf_str_td64, interval_to_cron
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class CachedMarketDataHolder:
|
|
@@ -204,7 +204,7 @@ def _make_shift(_b, _w, _d, _h, _m, _s):
|
|
|
204
204
|
|
|
205
205
|
# return AS_TD(f'{_b*4}W') + AS_TD(f'{_w}W') + AS_TD(f'{_d}D') + AS_TD(f'{_h}h') + AS_TD(f'{_m}Min') + AS_TD(f'{_s}Sec')
|
|
206
206
|
for t in [
|
|
207
|
-
AS_TD(f"{_b*4}W"),
|
|
207
|
+
AS_TD(f"{_b * 4}W"),
|
|
208
208
|
AS_TD(f"{_w}W"),
|
|
209
209
|
AS_TD(f"{_d}D"),
|
|
210
210
|
AS_TD(f"{_h}h"),
|
|
@@ -218,12 +218,12 @@ def _make_shift(_b, _w, _d, _h, _m, _s):
|
|
|
218
218
|
return P, N
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
def _parse_schedule_spec(schedule: str) ->
|
|
221
|
+
def _parse_schedule_spec(schedule: str) -> dict[str, str]:
|
|
222
222
|
m = SPEC_REGEX.match(schedule)
|
|
223
223
|
return {k: v for k, v in m.groupdict().items() if v} if m else {}
|
|
224
224
|
|
|
225
225
|
|
|
226
|
-
def process_schedule_spec(spec_str: str | None) ->
|
|
226
|
+
def process_schedule_spec(spec_str: str | None) -> dict[str, Any]:
|
|
227
227
|
AS_INT = lambda d, k: int(d.get(k, 0)) # noqa: E731
|
|
228
228
|
S = lambda s: [x for x in re.split(r"[, ]", s) if x] # noqa: E731
|
|
229
229
|
config = {}
|
|
@@ -246,10 +246,16 @@ def process_schedule_spec(spec_str: str | None) -> Dict[str, Any]:
|
|
|
246
246
|
|
|
247
247
|
match _T:
|
|
248
248
|
case "cron":
|
|
249
|
-
if not _S
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
if not _S:
|
|
250
|
+
raise ValueError(f"Empty specification for cron: {spec_str}")
|
|
251
|
+
|
|
252
|
+
if not croniter.is_valid(_S):
|
|
253
|
+
_S = interval_to_cron(_S)
|
|
254
|
+
|
|
255
|
+
if not croniter.is_valid(_S):
|
|
256
|
+
raise ValueError(f"Wrong specification for cron: {spec_str}")
|
|
257
|
+
|
|
258
|
+
config = dict(type="cron", schedule=_S, spec=_S)
|
|
253
259
|
|
|
254
260
|
case "time":
|
|
255
261
|
for t in _t:
|
|
@@ -265,13 +271,20 @@ def process_schedule_spec(spec_str: str | None) -> Dict[str, Any]:
|
|
|
265
271
|
if croniter.is_valid(_S):
|
|
266
272
|
config = dict(type="cron", schedule=_S, spec=_S)
|
|
267
273
|
else:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
274
|
+
# - try convert to cron
|
|
275
|
+
_S = interval_to_cron(_S)
|
|
276
|
+
if croniter.is_valid(_S):
|
|
277
|
+
config = dict(type="cron", schedule=_S, spec=_S)
|
|
278
|
+
else:
|
|
279
|
+
if _has_intervals:
|
|
280
|
+
_F = (
|
|
281
|
+
convert_seconds_to_str(
|
|
282
|
+
int(_s_pos.as_unit("s").to_timedelta64().item().total_seconds())
|
|
283
|
+
)
|
|
284
|
+
if not _F
|
|
285
|
+
else _F
|
|
286
|
+
)
|
|
287
|
+
config = dict(type="bar", schedule=None, timeframe=_F, delay=_s_neg, spec=_S)
|
|
275
288
|
case _:
|
|
276
289
|
config = dict(type=_T, schedule=None, timeframe=_F, delay=_shift, spec=_S)
|
|
277
290
|
|
|
@@ -348,6 +361,7 @@ class BasicScheduler:
|
|
|
348
361
|
# - update next nearest time
|
|
349
362
|
self._next_times[event] = next_time
|
|
350
363
|
self._next_nearest_time = np.datetime64(int(min(self._next_times.values()) * 1000000000), "ns")
|
|
364
|
+
# logger.debug(f" >>> ({event}) task is scheduled at {self._next_nearest_time}")
|
|
351
365
|
|
|
352
366
|
return True
|
|
353
367
|
logger.debug(f"({event}) task is not scheduled")
|
qubx/core/interfaces.py
CHANGED
|
@@ -10,7 +10,7 @@ This module includes:
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import traceback
|
|
13
|
-
from typing import Any, Dict, List, Set, Tuple
|
|
13
|
+
from typing import Any, Dict, List, Literal, Set, Tuple
|
|
14
14
|
|
|
15
15
|
import numpy as np
|
|
16
16
|
import pandas as pd
|
|
@@ -36,6 +36,8 @@ from qubx.core.basics import (
|
|
|
36
36
|
from qubx.core.helpers import set_parameters_to_object
|
|
37
37
|
from qubx.core.series import OHLCV, Bar, Quote
|
|
38
38
|
|
|
39
|
+
RemovalPolicy = Literal["close", "wait_for_close", "wait_for_change"]
|
|
40
|
+
|
|
39
41
|
|
|
40
42
|
class IAccountViewer:
|
|
41
43
|
account_id: str
|
|
@@ -568,11 +570,18 @@ class ITradingManager:
|
|
|
568
570
|
class IUniverseManager:
|
|
569
571
|
"""Manages universe updates."""
|
|
570
572
|
|
|
571
|
-
def set_universe(
|
|
573
|
+
def set_universe(
|
|
574
|
+
self, instruments: list[Instrument], skip_callback: bool = False, if_has_position_then: RemovalPolicy = "close"
|
|
575
|
+
):
|
|
572
576
|
"""Set the trading universe.
|
|
573
577
|
|
|
574
578
|
Args:
|
|
575
579
|
instruments: List of instruments in the universe
|
|
580
|
+
skip_callback: Skip callback to the strategy
|
|
581
|
+
if_has_position_then: What to do if the instrument has a position
|
|
582
|
+
- “close” (default) - close position immediatelly and remove (unsubscribe) instrument from strategy
|
|
583
|
+
- “wait_for_close” - keep instrument and it’s position until it’s closed from strategy (or risk management), then remove instrument from strategy
|
|
584
|
+
- “wait_for_change” - keep instrument and position until strategy would try to change it - then close position and remove instrument
|
|
576
585
|
"""
|
|
577
586
|
...
|
|
578
587
|
|
|
@@ -584,11 +593,15 @@ class IUniverseManager:
|
|
|
584
593
|
"""
|
|
585
594
|
...
|
|
586
595
|
|
|
587
|
-
def remove_instruments(self, instruments: list[Instrument]):
|
|
596
|
+
def remove_instruments(self, instruments: list[Instrument], if_has_position_then: RemovalPolicy = "close"):
|
|
588
597
|
"""Remove instruments from the trading universe.
|
|
589
598
|
|
|
590
599
|
Args:
|
|
591
600
|
instruments: List of instruments to remove
|
|
601
|
+
if_has_position_then: What to do if the instrument has a position
|
|
602
|
+
- “close” (default) - close position immediatelly and remove (unsubscribe) instrument from strategy
|
|
603
|
+
- “wait_for_close” - keep instrument and it’s position until it’s closed from strategy (or risk management), then remove instrument from strategy
|
|
604
|
+
- “wait_for_change” - keep instrument and position until strategy would try to change it - then close position and remove instrument
|
|
592
605
|
"""
|
|
593
606
|
...
|
|
594
607
|
|
|
@@ -599,6 +612,19 @@ class IUniverseManager:
|
|
|
599
612
|
"""
|
|
600
613
|
...
|
|
601
614
|
|
|
615
|
+
def on_alter_position(self, instrument: Instrument) -> None:
|
|
616
|
+
"""
|
|
617
|
+
Called when the position of an instrument changes.
|
|
618
|
+
It can be used for postponed unsubscribed events
|
|
619
|
+
"""
|
|
620
|
+
...
|
|
621
|
+
|
|
622
|
+
def is_trading_allowed(self, instrument: Instrument) -> bool:
|
|
623
|
+
"""
|
|
624
|
+
Check if trading is allowed for an instrument because of the instrument's trading policy.
|
|
625
|
+
"""
|
|
626
|
+
...
|
|
627
|
+
|
|
602
628
|
|
|
603
629
|
class ISubscriptionManager:
|
|
604
630
|
"""Manages subscriptions."""
|
|
@@ -1003,6 +1029,18 @@ class PositionsTracker:
|
|
|
1003
1029
|
...
|
|
1004
1030
|
|
|
1005
1031
|
|
|
1032
|
+
def _unpickle_instance(chain: tuple[type], state: dict):
|
|
1033
|
+
"""
|
|
1034
|
+
chain is a tuple of the *original* classes, e.g. (A, B, C).
|
|
1035
|
+
Reconstruct a new ephemeral class that inherits from them.
|
|
1036
|
+
"""
|
|
1037
|
+
name = "_".join(cls.__name__ for cls in chain)
|
|
1038
|
+
# Reverse the chain to respect the typical left-to-right MRO
|
|
1039
|
+
inst = type(name, chain[::-1], {"__module__": "__main__"})()
|
|
1040
|
+
inst.__dict__.update(state)
|
|
1041
|
+
return inst
|
|
1042
|
+
|
|
1043
|
+
|
|
1006
1044
|
class Mixable(type):
|
|
1007
1045
|
"""
|
|
1008
1046
|
It's possible to create composite strategies dynamically by adding mixins with functionality.
|
|
@@ -1011,8 +1049,28 @@ class Mixable(type):
|
|
|
1011
1049
|
NewStrategy(....) can be used in simulation or live trading.
|
|
1012
1050
|
"""
|
|
1013
1051
|
|
|
1014
|
-
def __add__(cls
|
|
1015
|
-
|
|
1052
|
+
def __add__(cls, other_cls):
|
|
1053
|
+
# If we already have a _composition, combine them;
|
|
1054
|
+
# else treat cls itself as the start of the chain
|
|
1055
|
+
cls_chain = getattr(cls, "__composition__", (cls,))
|
|
1056
|
+
other_chain = getattr(other_cls, "__composition__", (other_cls,))
|
|
1057
|
+
|
|
1058
|
+
# Combine them into one chain. You can define your own order rules:
|
|
1059
|
+
new_chain = cls_chain + other_chain
|
|
1060
|
+
|
|
1061
|
+
# Create ephemeral class
|
|
1062
|
+
name = "_".join(c.__name__ for c in new_chain)
|
|
1063
|
+
|
|
1064
|
+
def __reduce__(self):
|
|
1065
|
+
# Just return the chain of *original real classes*
|
|
1066
|
+
return _unpickle_instance, (new_chain, self.__dict__)
|
|
1067
|
+
|
|
1068
|
+
new_cls = type(
|
|
1069
|
+
name,
|
|
1070
|
+
new_chain[::-1],
|
|
1071
|
+
{"__module__": cls.__module__, "__composition__": new_chain, "__reduce__": __reduce__},
|
|
1072
|
+
)
|
|
1073
|
+
return new_cls
|
|
1016
1074
|
|
|
1017
1075
|
|
|
1018
1076
|
class IStrategy(metaclass=Mixable):
|
qubx/core/metrics.py
CHANGED
|
@@ -609,6 +609,7 @@ class TradingSessionResult:
|
|
|
609
609
|
creation_time: pd.Timestamp | None = None # when result was created
|
|
610
610
|
author: str | None = None # who created the result
|
|
611
611
|
qubx_version: str | None = None # Qubx version used to create the result
|
|
612
|
+
_metrics: dict[str, float] | None = None # performance metrics
|
|
612
613
|
# fmt: on
|
|
613
614
|
|
|
614
615
|
def __init__(
|
|
@@ -649,6 +650,39 @@ class TradingSessionResult:
|
|
|
649
650
|
self.creation_time = pd.Timestamp(creation_time) if creation_time else pd.Timestamp.now()
|
|
650
651
|
self.author = author
|
|
651
652
|
self.qubx_version = version()
|
|
653
|
+
self._metrics = None
|
|
654
|
+
|
|
655
|
+
def performance(self) -> dict[str, float]:
|
|
656
|
+
"""
|
|
657
|
+
Calculate performance metrics for the trading session
|
|
658
|
+
"""
|
|
659
|
+
if not self._metrics:
|
|
660
|
+
# - caluclate short statistics
|
|
661
|
+
self._metrics = portfolio_metrics(
|
|
662
|
+
self.portfolio_log,
|
|
663
|
+
self.executions_log,
|
|
664
|
+
self.capital,
|
|
665
|
+
performance_statistics_period=DAILY_365,
|
|
666
|
+
account_transactions=True,
|
|
667
|
+
commission_factor=1,
|
|
668
|
+
)
|
|
669
|
+
# - convert timestamps to isoformat
|
|
670
|
+
for k, v in self._metrics.items():
|
|
671
|
+
match v:
|
|
672
|
+
case pd.Timestamp():
|
|
673
|
+
self._metrics[k] = v.isoformat()
|
|
674
|
+
case np.float64():
|
|
675
|
+
self._metrics[k] = float(v)
|
|
676
|
+
# fmt: off
|
|
677
|
+
for k in [
|
|
678
|
+
"equity", "drawdown_usd", "drawdown_pct",
|
|
679
|
+
"compound_returns", "returns_daily", "returns", "monthly_returns",
|
|
680
|
+
"rolling_sharpe", "long_value", "short_value",
|
|
681
|
+
]:
|
|
682
|
+
self._metrics.pop(k, None)
|
|
683
|
+
# fmt: on
|
|
684
|
+
|
|
685
|
+
return self._metrics
|
|
652
686
|
|
|
653
687
|
@property
|
|
654
688
|
def symbols(self) -> list[str]:
|
|
@@ -690,6 +724,7 @@ class TradingSessionResult:
|
|
|
690
724
|
"author": self.author,
|
|
691
725
|
"qubx_version": self.qubx_version,
|
|
692
726
|
"symbols": self.symbols,
|
|
727
|
+
"performance": dict(self.performance()),
|
|
693
728
|
}
|
|
694
729
|
|
|
695
730
|
def to_html(self, compound=True) -> HTML:
|
|
@@ -743,8 +778,42 @@ class TradingSessionResult:
|
|
|
743
778
|
"""
|
|
744
779
|
return HTML(_tmpl)
|
|
745
780
|
|
|
746
|
-
def to_file(
|
|
747
|
-
|
|
781
|
+
def to_file(
|
|
782
|
+
self,
|
|
783
|
+
name: str,
|
|
784
|
+
description: str | None = None,
|
|
785
|
+
compound=True,
|
|
786
|
+
archive=True,
|
|
787
|
+
suffix: str | None = None,
|
|
788
|
+
attachments: list[str] | None = None,
|
|
789
|
+
):
|
|
790
|
+
"""
|
|
791
|
+
Save the trading session results to files.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
name (str): Base name/path for saving the files
|
|
795
|
+
description (str | None, optional): Description to include in info file. Defaults to None.
|
|
796
|
+
compound (bool, optional): Whether to use compound returns in report. Defaults to True.
|
|
797
|
+
archive (bool, optional): Whether to zip the output files. Defaults to True.
|
|
798
|
+
suffix (str | None, optional): Optional suffix to append to filename. Defaults to None.
|
|
799
|
+
attachments (list[str] | None, optional): Additional files to include. Defaults to None.
|
|
800
|
+
|
|
801
|
+
The following files are saved:
|
|
802
|
+
- info.yml: Contains strategy configuration and metadata
|
|
803
|
+
- portfolio.csv: Portfolio state log
|
|
804
|
+
- executions.csv: Trade execution log
|
|
805
|
+
- signals.csv: Strategy signals log
|
|
806
|
+
- report.html: HTML performance report
|
|
807
|
+
- Any provided attachment files
|
|
808
|
+
|
|
809
|
+
If archive=True, all files are zipped into a single archive and the directory is removed.
|
|
810
|
+
"""
|
|
811
|
+
import shutil
|
|
812
|
+
|
|
813
|
+
if suffix is not None:
|
|
814
|
+
name = f"{name}{suffix}"
|
|
815
|
+
else:
|
|
816
|
+
name = (name + self.creation_time.strftime("%Y%m%d%H%M%S")) if self.creation_time else name
|
|
748
817
|
p = Path(makedirs(name))
|
|
749
818
|
with open(p / "info.yml", "w") as f:
|
|
750
819
|
info = self.info()
|
|
@@ -761,9 +830,13 @@ class TradingSessionResult:
|
|
|
761
830
|
with open(p / "report.html", "w") as f:
|
|
762
831
|
f.write(self.to_html(compound=compound).data)
|
|
763
832
|
|
|
764
|
-
|
|
765
|
-
|
|
833
|
+
# - save attachments
|
|
834
|
+
if attachments:
|
|
835
|
+
for a in attachments:
|
|
836
|
+
if (af := Path(a)).is_file():
|
|
837
|
+
shutil.copy(af, p / af.name)
|
|
766
838
|
|
|
839
|
+
if archive:
|
|
767
840
|
shutil.make_archive(name, "zip", p) # type: ignore
|
|
768
841
|
shutil.rmtree(p) # type: ignore
|
|
769
842
|
|
|
@@ -784,9 +857,11 @@ class TradingSessionResult:
|
|
|
784
857
|
# load result
|
|
785
858
|
_qbx_version = info.pop("qubx_version")
|
|
786
859
|
_decr = info.pop("description", None)
|
|
860
|
+
_perf = info.pop("performance", None)
|
|
787
861
|
info["instruments"] = info.pop("symbols")
|
|
788
862
|
tsr = TradingSessionResult(**info, portfolio_log=portfolio, executions_log=executions, signals_log=signals)
|
|
789
863
|
tsr.qubx_version = _qbx_version
|
|
864
|
+
tsr._metrics = _perf
|
|
790
865
|
return tsr
|
|
791
866
|
|
|
792
867
|
def __repr__(self) -> str:
|
|
@@ -796,10 +871,13 @@ class TradingSessionResult:
|
|
|
796
871
|
: QUBX: {self.qubx_version}
|
|
797
872
|
: Capital: {self.capital} {self.base_currency} ({self.commissions} @ {self.exchange})
|
|
798
873
|
: Instruments: [{",".join(self.symbols)}]
|
|
799
|
-
: Generated: {len(self.signals_log)} signals, {len(self.executions_log)} executions
|
|
800
|
-
: Strategy: {self.config(False)}
|
|
801
874
|
: Created: {self.creation_time} by {self.author}
|
|
802
|
-
|
|
875
|
+
: Strategy: {self.config(False)}
|
|
876
|
+
: Generated: {len(self.signals_log)} signals, {len(self.executions_log)} executions
|
|
877
|
+
"""
|
|
878
|
+
_perf = pd.DataFrame.from_dict(self.performance(), orient="index").T.to_string(index=None)
|
|
879
|
+
for _i, s in enumerate(_perf.split("\n")):
|
|
880
|
+
r += f" : {s}\n" if _i > 0 else f" `----: {s}\n"
|
|
803
881
|
return r
|
|
804
882
|
|
|
805
883
|
|
qubx/core/mixins/processing.py
CHANGED
|
@@ -28,6 +28,7 @@ from qubx.core.interfaces import (
|
|
|
28
28
|
IStrategyContext,
|
|
29
29
|
ISubscriptionManager,
|
|
30
30
|
ITimeProvider,
|
|
31
|
+
IUniverseManager,
|
|
31
32
|
PositionsTracker,
|
|
32
33
|
)
|
|
33
34
|
from qubx.core.loggers import StrategyLogging
|
|
@@ -48,6 +49,7 @@ class ProcessingManager(IProcessingManager):
|
|
|
48
49
|
_position_gathering: IPositionGathering
|
|
49
50
|
_cache: CachedMarketDataHolder
|
|
50
51
|
_scheduler: BasicScheduler
|
|
52
|
+
_universe_manager: IUniverseManager
|
|
51
53
|
|
|
52
54
|
_handlers: dict[str, Callable[["ProcessingManager", Instrument, str, Any], TriggerEvent | None]]
|
|
53
55
|
_strategy_name: str
|
|
@@ -72,6 +74,7 @@ class ProcessingManager(IProcessingManager):
|
|
|
72
74
|
account: IAccountProcessor,
|
|
73
75
|
position_tracker: PositionsTracker,
|
|
74
76
|
position_gathering: IPositionGathering,
|
|
77
|
+
universe_manager: IUniverseManager,
|
|
75
78
|
cache: CachedMarketDataHolder,
|
|
76
79
|
scheduler: BasicScheduler,
|
|
77
80
|
is_simulation: bool,
|
|
@@ -86,6 +89,7 @@ class ProcessingManager(IProcessingManager):
|
|
|
86
89
|
self._is_simulation = is_simulation
|
|
87
90
|
self._position_gathering = position_gathering
|
|
88
91
|
self._position_tracker = position_tracker
|
|
92
|
+
self._universe_manager = universe_manager
|
|
89
93
|
self._cache = cache
|
|
90
94
|
self._scheduler = scheduler
|
|
91
95
|
|
|
@@ -100,7 +104,7 @@ class ProcessingManager(IProcessingManager):
|
|
|
100
104
|
|
|
101
105
|
def set_fit_schedule(self, schedule: str) -> None:
|
|
102
106
|
rule = process_schedule_spec(schedule)
|
|
103
|
-
if rule
|
|
107
|
+
if rule.get("type") != "cron":
|
|
104
108
|
raise ValueError("Only cron type is supported for fit schedule")
|
|
105
109
|
self._scheduler.schedule_event(rule["schedule"], "fit")
|
|
106
110
|
|
|
@@ -228,8 +232,13 @@ class ProcessingManager(IProcessingManager):
|
|
|
228
232
|
) -> list[TargetPosition]:
|
|
229
233
|
if target_positions is None:
|
|
230
234
|
return []
|
|
235
|
+
|
|
231
236
|
if isinstance(target_positions, TargetPosition):
|
|
232
237
|
target_positions = [target_positions]
|
|
238
|
+
|
|
239
|
+
# - check if trading is allowed for each target position
|
|
240
|
+
target_positions = [t for t in target_positions if self._universe_manager.is_trading_allowed(t.instrument)]
|
|
241
|
+
|
|
233
242
|
self._logging.save_signals_targets(target_positions)
|
|
234
243
|
return target_positions
|
|
235
244
|
|
|
@@ -394,14 +403,17 @@ class ProcessingManager(IProcessingManager):
|
|
|
394
403
|
return order
|
|
395
404
|
|
|
396
405
|
@SW.watch("StrategyContext")
|
|
397
|
-
def _handle_deals(self, instrument: Instrument, event_type: str, deals: list[Deal]) -> TriggerEvent | None:
|
|
398
|
-
self._account.process_deals(instrument, deals)
|
|
399
|
-
self._logging.save_deals(instrument, deals)
|
|
406
|
+
def _handle_deals(self, instrument: Instrument | None, event_type: str, deals: list[Deal]) -> TriggerEvent | None:
|
|
400
407
|
if instrument is None:
|
|
401
408
|
logger.debug(
|
|
402
409
|
f"[<y>{self.__class__.__name__}</y>] :: Execution report for unknown instrument <r>{instrument}</r>"
|
|
403
410
|
)
|
|
404
|
-
return
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
# - process deals only for subscribed instruments
|
|
414
|
+
self._account.process_deals(instrument, deals)
|
|
415
|
+
self._logging.save_deals(instrument, deals)
|
|
416
|
+
|
|
405
417
|
for d in deals:
|
|
406
418
|
# - notify position gatherer and tracker
|
|
407
419
|
self._position_gathering.on_execution_report(self._context, instrument, d)
|
|
@@ -409,4 +421,8 @@ class ProcessingManager(IProcessingManager):
|
|
|
409
421
|
logger.debug(
|
|
410
422
|
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: executed <r>{d.order_id}</r> | {d.amount} @ {d.price}"
|
|
411
423
|
)
|
|
424
|
+
|
|
425
|
+
# - notify universe manager about position change
|
|
426
|
+
self._universe_manager.on_alter_position(instrument)
|
|
427
|
+
|
|
412
428
|
return None
|
qubx/core/mixins/universe.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from qubx.core.basics import DataType, Instrument, Position, TargetPosition
|
|
2
4
|
from qubx.core.helpers import CachedMarketDataHolder
|
|
3
5
|
from qubx.core.interfaces import (
|
|
4
6
|
IAccountProcessor,
|
|
@@ -11,6 +13,7 @@ from qubx.core.interfaces import (
|
|
|
11
13
|
ITimeProvider,
|
|
12
14
|
ITradingManager,
|
|
13
15
|
IUniverseManager,
|
|
16
|
+
RemovalPolicy,
|
|
14
17
|
)
|
|
15
18
|
from qubx.core.loggers import StrategyLogging
|
|
16
19
|
|
|
@@ -27,6 +30,7 @@ class UniverseManager(IUniverseManager):
|
|
|
27
30
|
_time_provider: ITimeProvider
|
|
28
31
|
_account: IAccountProcessor
|
|
29
32
|
_position_gathering: IPositionGathering
|
|
33
|
+
_removal_queue: dict[Instrument, tuple[RemovalPolicy, bool]]
|
|
30
34
|
|
|
31
35
|
def __init__(
|
|
32
36
|
self,
|
|
@@ -54,56 +58,132 @@ class UniverseManager(IUniverseManager):
|
|
|
54
58
|
self._account = account
|
|
55
59
|
self._position_gathering = position_gathering
|
|
56
60
|
self._instruments = []
|
|
61
|
+
self._removal_queue = {}
|
|
62
|
+
|
|
63
|
+
def _has_position(self, instrument: Instrument) -> bool:
|
|
64
|
+
return (
|
|
65
|
+
instrument in self._account.positions
|
|
66
|
+
and abs(self._account.positions[instrument].quantity) > instrument.min_size
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def set_universe(
|
|
70
|
+
self,
|
|
71
|
+
instruments: list[Instrument],
|
|
72
|
+
skip_callback: bool = False,
|
|
73
|
+
if_has_position_then: RemovalPolicy = "close",
|
|
74
|
+
) -> None:
|
|
75
|
+
assert if_has_position_then in (
|
|
76
|
+
"close",
|
|
77
|
+
"wait_for_close",
|
|
78
|
+
"wait_for_change",
|
|
79
|
+
), "Invalid if_has_position_then policy"
|
|
57
80
|
|
|
58
|
-
def set_universe(self, instruments: list[Instrument], skip_callback: bool = False) -> None:
|
|
59
81
|
new_set = set(instruments)
|
|
60
82
|
prev_set = set(self._instruments)
|
|
61
|
-
rm_instr = list(prev_set - new_set)
|
|
62
|
-
add_instr = list(new_set - prev_set)
|
|
63
83
|
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
# - determine instruments to remove depending on if_has_position_then policy
|
|
85
|
+
may_be_removed = list(prev_set - new_set)
|
|
86
|
+
|
|
87
|
+
# - split instruments into removable and keepable
|
|
88
|
+
to_remove, to_keep = self._get_what_can_be_removed_or_kept(may_be_removed, skip_callback, if_has_position_then)
|
|
89
|
+
|
|
90
|
+
to_add = list(new_set - prev_set)
|
|
91
|
+
self.__do_add_instruments(to_add)
|
|
92
|
+
self.__do_remove_instruments(to_remove)
|
|
93
|
+
|
|
94
|
+
# - cleanup removal queue
|
|
95
|
+
self.__cleanup_removal_queue(instruments)
|
|
66
96
|
|
|
67
|
-
if not skip_callback and (
|
|
68
|
-
self._strategy.on_universe_change(self._context,
|
|
97
|
+
if not skip_callback and (to_add or to_remove):
|
|
98
|
+
self._strategy.on_universe_change(self._context, to_add, to_remove)
|
|
69
99
|
|
|
70
100
|
self._subscription_manager.commit() # apply pending changes
|
|
71
101
|
|
|
72
102
|
# set new instruments
|
|
73
103
|
self._instruments.clear()
|
|
74
104
|
self._instruments.extend(instruments)
|
|
105
|
+
self._instruments.extend(to_keep)
|
|
106
|
+
|
|
107
|
+
def _get_what_can_be_removed_or_kept(
|
|
108
|
+
self, may_be_removed: list[Instrument], skip_callback: bool, if_has_position_then: RemovalPolicy
|
|
109
|
+
) -> tuple[list[Instrument], list[Instrument]]:
|
|
110
|
+
immediately_close = if_has_position_then == "close"
|
|
111
|
+
to_remove, to_keep = [], []
|
|
112
|
+
for instr in may_be_removed:
|
|
113
|
+
if immediately_close:
|
|
114
|
+
to_remove.append(instr)
|
|
115
|
+
else:
|
|
116
|
+
if self._has_position(instr):
|
|
117
|
+
self._removal_queue[instr] = (if_has_position_then, skip_callback)
|
|
118
|
+
to_keep.append(instr)
|
|
119
|
+
return to_remove, to_keep
|
|
120
|
+
|
|
121
|
+
def __cleanup_removal_queue(self, instruments: list[Instrument]):
|
|
122
|
+
for instr in instruments:
|
|
123
|
+
# - if it's still in the removal queue, remove it
|
|
124
|
+
if instr in self._removal_queue:
|
|
125
|
+
self._removal_queue.pop(instr)
|
|
75
126
|
|
|
76
127
|
def add_instruments(self, instruments: list[Instrument]):
|
|
77
|
-
self.
|
|
128
|
+
self.__do_add_instruments(instruments)
|
|
129
|
+
self.__cleanup_removal_queue(instruments)
|
|
78
130
|
self._strategy.on_universe_change(self._context, instruments, [])
|
|
79
131
|
self._subscription_manager.commit()
|
|
80
132
|
self._instruments.extend(instruments)
|
|
81
133
|
|
|
82
|
-
def remove_instruments(
|
|
83
|
-
self
|
|
84
|
-
|
|
134
|
+
def remove_instruments(
|
|
135
|
+
self,
|
|
136
|
+
instruments: list[Instrument],
|
|
137
|
+
if_has_position_then: RemovalPolicy = "close",
|
|
138
|
+
):
|
|
139
|
+
assert if_has_position_then in (
|
|
140
|
+
"close",
|
|
141
|
+
"wait_for_close",
|
|
142
|
+
"wait_for_change",
|
|
143
|
+
), "Invalid if_has_position_then policy"
|
|
144
|
+
|
|
145
|
+
# - split instruments into removable and keepable
|
|
146
|
+
to_remove, to_keep = self._get_what_can_be_removed_or_kept(instruments, False, if_has_position_then)
|
|
147
|
+
|
|
148
|
+
# - remove ones that can be removed immediately
|
|
149
|
+
self.__do_remove_instruments(to_remove)
|
|
150
|
+
self._strategy.on_universe_change(self._context, [], to_remove)
|
|
85
151
|
self._subscription_manager.commit()
|
|
86
|
-
|
|
152
|
+
|
|
153
|
+
# - update instruments list
|
|
154
|
+
self._instruments = list(set(self._instruments) - set(to_remove))
|
|
155
|
+
self._instruments.extend(to_keep)
|
|
87
156
|
|
|
88
157
|
@property
|
|
89
158
|
def instruments(self) -> list[Instrument]:
|
|
90
159
|
return self._instruments
|
|
91
160
|
|
|
92
|
-
def
|
|
161
|
+
def __do_remove_instruments(self, instruments: list[Instrument]):
|
|
93
162
|
"""
|
|
94
163
|
Remove symbols from universe. Steps:
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
164
|
+
- [v] cancel all open orders
|
|
165
|
+
- [v] close all open positions
|
|
166
|
+
- [v] unsubscribe from market data
|
|
167
|
+
- [v] remove from data cache
|
|
98
168
|
|
|
99
169
|
We are still keeping the symbols in the positions dictionary.
|
|
100
170
|
"""
|
|
171
|
+
if not instruments:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# - preprocess instruments and cancel all open orders
|
|
175
|
+
for instr in instruments:
|
|
176
|
+
# - remove instrument from the removal queue if it's there
|
|
177
|
+
self._removal_queue.pop(instr, None)
|
|
178
|
+
|
|
179
|
+
# - cancel all open orders
|
|
180
|
+
self._trading_manager.cancel_orders(instr)
|
|
181
|
+
|
|
101
182
|
# - close all open positions
|
|
102
183
|
exit_targets = [
|
|
103
184
|
TargetPosition.zero(self._context, instr.signal(0, group="Universe", comment="Universe change"))
|
|
104
185
|
for instr in instruments
|
|
105
|
-
if
|
|
106
|
-
and abs(self._account.positions[instr.symbol].quantity) > instr.min_size
|
|
186
|
+
if self._has_position(instr)
|
|
107
187
|
]
|
|
108
188
|
self._position_gathering.alter_positions(self._context, exit_targets)
|
|
109
189
|
|
|
@@ -121,11 +201,11 @@ class UniverseManager(IUniverseManager):
|
|
|
121
201
|
for instr in instruments:
|
|
122
202
|
self._cache.remove(instr)
|
|
123
203
|
|
|
124
|
-
def
|
|
204
|
+
def __do_add_instruments(self, instruments: list[Instrument]) -> None:
|
|
125
205
|
# - create positions for instruments
|
|
126
206
|
self._create_and_update_positions(instruments)
|
|
127
207
|
|
|
128
|
-
# -
|
|
208
|
+
# - initialize ohlcv for new instruments
|
|
129
209
|
for instr in instruments:
|
|
130
210
|
self._cache.init_ohlcv(instr)
|
|
131
211
|
|
|
@@ -153,3 +233,40 @@ class UniverseManager(IUniverseManager):
|
|
|
153
233
|
# instrument._aux_instrument = aux
|
|
154
234
|
# instruments.append(aux)
|
|
155
235
|
# _ = self._trading_service.get_position(aux)
|
|
236
|
+
|
|
237
|
+
def on_alter_position(self, instrument: Instrument) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Called when the position of an instrument changes.
|
|
240
|
+
It can be used for postponed unsubscribed events
|
|
241
|
+
"""
|
|
242
|
+
# - check if need to remove instrument from the universe
|
|
243
|
+
if instrument in self._removal_queue:
|
|
244
|
+
_, skip_callback = self._removal_queue[instrument]
|
|
245
|
+
|
|
246
|
+
# - if no position, remove instrument from the universe
|
|
247
|
+
if not self._has_position(instrument):
|
|
248
|
+
self.__do_remove_instruments([instrument])
|
|
249
|
+
|
|
250
|
+
if not skip_callback:
|
|
251
|
+
self._strategy.on_universe_change(self._context, [], [instrument])
|
|
252
|
+
|
|
253
|
+
# - commit changes and remove instrument from the universe
|
|
254
|
+
self._subscription_manager.commit()
|
|
255
|
+
self._instruments.remove(instrument)
|
|
256
|
+
|
|
257
|
+
def is_trading_allowed(self, instrument: Instrument) -> bool:
|
|
258
|
+
if instrument in self._removal_queue:
|
|
259
|
+
policy, skip_callback = self._removal_queue[instrument]
|
|
260
|
+
|
|
261
|
+
if policy == "wait_for_change":
|
|
262
|
+
self.__do_remove_instruments([instrument])
|
|
263
|
+
|
|
264
|
+
if not skip_callback:
|
|
265
|
+
self._strategy.on_universe_change(self._context, [], [instrument])
|
|
266
|
+
|
|
267
|
+
# - commit changes and remove instrument from the universe
|
|
268
|
+
self._subscription_manager.commit()
|
|
269
|
+
self._instruments.remove(instrument)
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
return True
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
qubx/trackers/sizers.py
CHANGED
|
@@ -173,3 +173,57 @@ class LongShortRatioPortfolioSizer(IPositionSizer):
|
|
|
173
173
|
t_pos.append(TargetPosition.create(ctx, signal, _p * _p_q))
|
|
174
174
|
|
|
175
175
|
return t_pos
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class FixedRiskSizerWithConstantCapital(IPositionSizer):
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
capital: float,
|
|
182
|
+
max_cap_in_risk: float,
|
|
183
|
+
max_allowed_position=np.inf,
|
|
184
|
+
divide_by_symbols: bool = True,
|
|
185
|
+
):
|
|
186
|
+
"""
|
|
187
|
+
Create fixed risk sizer calculator instance.
|
|
188
|
+
:param max_cap_in_risk: maximal risked capital (in percentage)
|
|
189
|
+
:param max_allowed_position: limitation for max position size in quoted currency (i.e. max 5000 in USDT)
|
|
190
|
+
:param reinvest_profit: if true use profit to reinvest
|
|
191
|
+
"""
|
|
192
|
+
self.capital = capital
|
|
193
|
+
assert self.capital > 0, f" >> {self.__class__.__name__}: Capital must be positive, got {self.capital}"
|
|
194
|
+
self.max_cap_in_risk = max_cap_in_risk / 100
|
|
195
|
+
self.max_allowed_position_quoted = max_allowed_position
|
|
196
|
+
self.divide_by_symbols = divide_by_symbols
|
|
197
|
+
|
|
198
|
+
def calculate_target_positions(self, ctx: IStrategyContext, signals: List[Signal]) -> List[TargetPosition]:
|
|
199
|
+
t_pos = []
|
|
200
|
+
for signal in signals:
|
|
201
|
+
target_position_size = 0
|
|
202
|
+
if signal.signal != 0:
|
|
203
|
+
if signal.stop and signal.stop > 0:
|
|
204
|
+
# - get signal entry price
|
|
205
|
+
if (_entry := self.get_signal_entry_price(ctx, signal)) is None:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# - just use same fixed capital
|
|
209
|
+
_cap = self.capital / (len(ctx.instruments) if self.divide_by_symbols else 1)
|
|
210
|
+
|
|
211
|
+
# fmt: off
|
|
212
|
+
_direction = np.sign(signal.signal)
|
|
213
|
+
target_position_size = (
|
|
214
|
+
_direction * min(
|
|
215
|
+
(_cap * self.max_cap_in_risk) / abs(signal.stop / _entry - 1),
|
|
216
|
+
self.max_allowed_position_quoted
|
|
217
|
+
) / _entry
|
|
218
|
+
)
|
|
219
|
+
# fmt: on
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
logger.warning(
|
|
223
|
+
f" >>> {self.__class__.__name__}: stop is not specified for {str(signal)} - can't calculate position !"
|
|
224
|
+
)
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
t_pos.append(TargetPosition.create(ctx, signal, target_position_size))
|
|
228
|
+
|
|
229
|
+
return t_pos
|
qubx/utils/runner/configs.py
CHANGED
|
@@ -55,6 +55,8 @@ class StrategySimulationConfig(BaseModel):
|
|
|
55
55
|
parameters: dict = Field(default_factory=dict)
|
|
56
56
|
data: dict = Field(default_factory=dict)
|
|
57
57
|
simulation: dict = Field(default_factory=dict)
|
|
58
|
+
description: str | list[str] | None = None
|
|
59
|
+
variate: dict = Field(default_factory=dict)
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
def load_simulation_config_from_yaml(path: Path | str) -> StrategySimulationConfig:
|
qubx/utils/runner/runner.py
CHANGED
|
@@ -4,8 +4,11 @@ import time
|
|
|
4
4
|
from functools import reduce
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
7
9
|
from qubx import formatter, logger, lookup
|
|
8
10
|
from qubx.backtester.account import SimulatedAccountProcessor
|
|
11
|
+
from qubx.backtester.optimization import variate
|
|
9
12
|
from qubx.backtester.simulator import SimulatedBroker, simulate
|
|
10
13
|
from qubx.connectors.ccxt.account import CcxtAccountProcessor
|
|
11
14
|
from qubx.connectors.ccxt.broker import CcxtBroker
|
|
@@ -18,7 +21,7 @@ from qubx.core.helpers import BasicScheduler
|
|
|
18
21
|
from qubx.core.interfaces import IAccountProcessor, IBroker, IDataProvider, IStrategyContext
|
|
19
22
|
from qubx.core.loggers import StrategyLogging
|
|
20
23
|
from qubx.data import DataReader
|
|
21
|
-
from qubx.utils.misc import class_import, makedirs
|
|
24
|
+
from qubx.utils.misc import blue, class_import, cyan, green, magenta, makedirs, red, yellow
|
|
22
25
|
from qubx.utils.runner.configs import ExchangeConfig, load_simulation_config_from_yaml, load_strategy_config_from_yaml
|
|
23
26
|
|
|
24
27
|
from .accounts import AccountConfigurationManager
|
|
@@ -379,6 +382,8 @@ def simulate_strategy(
|
|
|
379
382
|
|
|
380
383
|
cfg = load_simulation_config_from_yaml(config_file)
|
|
381
384
|
stg = cfg.strategy
|
|
385
|
+
simulation_name = config_file.stem
|
|
386
|
+
_v_id = pd.Timestamp("now").strftime("%Y%m%d%H%M%S")
|
|
382
387
|
|
|
383
388
|
match stg:
|
|
384
389
|
case list():
|
|
@@ -388,8 +393,24 @@ def simulate_strategy(
|
|
|
388
393
|
case _:
|
|
389
394
|
raise SimulationConfigError(f"Invalid strategy type: {stg}")
|
|
390
395
|
|
|
391
|
-
|
|
392
|
-
|
|
396
|
+
# - create simulation setup
|
|
397
|
+
if cfg.variate:
|
|
398
|
+
# - get conditions for variations if exists
|
|
399
|
+
cond = cfg.variate.pop("with", None)
|
|
400
|
+
conditions = []
|
|
401
|
+
dict2lambda = lambda a, d: eval(f"lambda {a}: {d}") # noqa: E731
|
|
402
|
+
if cond:
|
|
403
|
+
for a, c in cond.items():
|
|
404
|
+
conditions.append(dict2lambda(a, c))
|
|
405
|
+
|
|
406
|
+
experiments = variate(stg_cls, **(cfg.parameters | cfg.variate), conditions=conditions)
|
|
407
|
+
experiments = {f"{simulation_name}.{_v_id}.[{k}]": v for k, v in experiments.items()}
|
|
408
|
+
print(f"Parameters variation is configured. There are {len(experiments)} simulations to run.")
|
|
409
|
+
_n_jobs = -1
|
|
410
|
+
else:
|
|
411
|
+
strategy = stg_cls(**cfg.parameters)
|
|
412
|
+
experiments = {simulation_name: strategy}
|
|
413
|
+
_n_jobs = 1
|
|
393
414
|
|
|
394
415
|
data_i = {}
|
|
395
416
|
|
|
@@ -409,13 +430,39 @@ def simulate_strategy(
|
|
|
409
430
|
sim_params["stop"] = stop
|
|
410
431
|
logger.info(f"Stop date set to {stop}")
|
|
411
432
|
|
|
412
|
-
|
|
413
|
-
|
|
433
|
+
# - check for aux_data parameter
|
|
434
|
+
if "aux_data" in sim_params:
|
|
435
|
+
aux_data = sim_params.pop("aux_data")
|
|
436
|
+
if aux_data is not None:
|
|
437
|
+
try:
|
|
438
|
+
sim_params["aux_data"] = eval(aux_data)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
raise ValueError(f"Invalid aux_data parameter: {aux_data}") from e
|
|
414
441
|
|
|
415
|
-
|
|
416
|
-
|
|
442
|
+
# - run simulation
|
|
443
|
+
print(f" > Run simulation for [{red(simulation_name)}] ::: {sim_params['start']} - {sim_params['stop']}")
|
|
444
|
+
sim_params["n_jobs"] = sim_params.get("n_jobs", _n_jobs)
|
|
445
|
+
test_res = simulate(experiments, data=data_i, **sim_params)
|
|
417
446
|
|
|
418
|
-
|
|
419
|
-
|
|
447
|
+
_where_to_save = save_path if save_path is not None else Path("results/")
|
|
448
|
+
s_path = Path(makedirs(str(_where_to_save))) / simulation_name
|
|
449
|
+
|
|
450
|
+
# logger.info(f"Saving simulation results to <g>{s_path}</g> ...")
|
|
451
|
+
if cfg.description is not None:
|
|
452
|
+
_descr = cfg.description
|
|
453
|
+
if isinstance(cfg.description, list):
|
|
454
|
+
_descr = "\n".join(cfg.description)
|
|
455
|
+
else:
|
|
456
|
+
_descr = str(cfg.description)
|
|
457
|
+
|
|
458
|
+
if len(test_res) > 1:
|
|
459
|
+
# - TODO: think how to deal with variations !
|
|
460
|
+
s_path = s_path / f"variations.{_v_id}"
|
|
461
|
+
print(f" > Saving variations results to <g>{s_path}</g> ...")
|
|
462
|
+
for k, t in enumerate(test_res):
|
|
463
|
+
t.to_file(str(s_path), description=_descr, suffix=f".{k}", attachments=[str(config_file)])
|
|
464
|
+
else:
|
|
465
|
+
print(f" > Saving simulation results to <g>{s_path}</g> ...")
|
|
466
|
+
test_res[0].to_file(str(s_path), description=_descr, attachments=[str(config_file)])
|
|
420
467
|
|
|
421
468
|
return test_res
|
qubx/utils/time.py
CHANGED
|
@@ -225,3 +225,88 @@ def timedelta_to_crontab(td: pd.Timedelta) -> str:
|
|
|
225
225
|
return f"* * * * * */{seconds}"
|
|
226
226
|
|
|
227
227
|
raise ValueError("Timedelta must specify a non-zero period of days, hours, minutes or seconds")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def interval_to_cron(inv: str) -> str:
|
|
231
|
+
"""
|
|
232
|
+
Convert a custom schedule format to a cron expression.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
inv (str): Custom schedule format string. Can be either:
|
|
236
|
+
- A pandas Timedelta string (e.g. "4h", "2d", "1d12h")
|
|
237
|
+
- A custom schedule format "<interval>@<time>" where:
|
|
238
|
+
interval: Optional number + unit (Q=quarter, M=month, Y=year, D=day, SUN=Sunday, MON=Monday, etc.)
|
|
239
|
+
time: HH:MM or HH:MM:SS
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
str: Cron expression
|
|
243
|
+
|
|
244
|
+
Examples:
|
|
245
|
+
>>> interval_to_cron("4h") # Pandas Timedelta
|
|
246
|
+
'0 */4 * * *'
|
|
247
|
+
>>> interval_to_cron("2d") # Pandas Timedelta
|
|
248
|
+
'59 23 */2 * *'
|
|
249
|
+
>>> interval_to_cron("@10:30") # Daily at 10:30
|
|
250
|
+
'30 10 * * *'
|
|
251
|
+
>>> interval_to_cron("1M@15:00") # Monthly at 15:00
|
|
252
|
+
'0 15 1 */1 * *'
|
|
253
|
+
>>> interval_to_cron("2Q@09:30:15") # Every 2 quarters at 9:30:15
|
|
254
|
+
'30 9 1 */6 * 15'
|
|
255
|
+
>>> interval_to_cron("Y@00:00") # Annually at midnight
|
|
256
|
+
'0 0 1 1 * *'
|
|
257
|
+
>>> interval_to_cron("TUE @ 23:59")
|
|
258
|
+
'59 23 * * 2'
|
|
259
|
+
"""
|
|
260
|
+
# - first try parsing as pandas Timedelta
|
|
261
|
+
try:
|
|
262
|
+
_td_inv = pd.Timedelta(inv)
|
|
263
|
+
return timedelta_to_crontab(_td_inv)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
# - parse custom schedule format
|
|
268
|
+
try:
|
|
269
|
+
# - split into interval and time parts
|
|
270
|
+
interval, time = inv.split("@")
|
|
271
|
+
interval = interval.strip()
|
|
272
|
+
time = time.strip()
|
|
273
|
+
|
|
274
|
+
# - parse time
|
|
275
|
+
time_parts = time.split(":")
|
|
276
|
+
if len(time_parts) == 2:
|
|
277
|
+
hour, minute = time_parts
|
|
278
|
+
second = "0"
|
|
279
|
+
elif len(time_parts) == 3:
|
|
280
|
+
hour, minute, second = time_parts
|
|
281
|
+
else:
|
|
282
|
+
raise ValueError("Invalid time format")
|
|
283
|
+
|
|
284
|
+
# - parse interval
|
|
285
|
+
if not interval: # Default to 1 day if no interval specified
|
|
286
|
+
return f"{minute} {hour} * * * {second}"
|
|
287
|
+
|
|
288
|
+
match = re.match(r"^(\d+)?([A-Za-z]+)$", interval)
|
|
289
|
+
if not match:
|
|
290
|
+
raise ValueError(f"Invalid interval format: {interval}")
|
|
291
|
+
number = match.group(1) or "1"
|
|
292
|
+
unit = match.group(2).upper()
|
|
293
|
+
|
|
294
|
+
dow = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
|
|
295
|
+
|
|
296
|
+
# - convert to cron expression
|
|
297
|
+
match unit:
|
|
298
|
+
case "Q": # Quarter
|
|
299
|
+
return f"{minute} {hour} 1 */{3 * int(number)} * {second}"
|
|
300
|
+
case "M": # Month
|
|
301
|
+
return f"{minute} {hour} 1 */{number} * {second}"
|
|
302
|
+
case "Y": # Year
|
|
303
|
+
return f"{minute} {hour} 1 1 * {second}"
|
|
304
|
+
case "SUN" | "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT": # Day of Week
|
|
305
|
+
return f"{minute} {hour} * * {dow.index(unit)} {second}"
|
|
306
|
+
case "D": # Day
|
|
307
|
+
return f"{minute} {hour} */{number} * * {second}"
|
|
308
|
+
case _:
|
|
309
|
+
raise ValueError(f"Invalid interval unit: {unit}")
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise ValueError(f"Invalid schedule format: {inv}") from e
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
qubx/__init__.py,sha256=pfxwsCedCQJRMyLurWZGccVlUytAPNonVRa_cyBSbmw,7145
|
|
2
2
|
qubx/_nb_magic.py,sha256=35HLYGGuP0_hiSZ-onCyZQJXTnGuDMueYQATZPtAYVg,2801
|
|
3
3
|
qubx/backtester/__init__.py,sha256=2VP3RCc5y542IBYynV33rsZ4PD-zG-lijTbD3udD9Sg,114
|
|
4
|
-
qubx/backtester/account.py,sha256=
|
|
4
|
+
qubx/backtester/account.py,sha256=VBFiUMS3So1wVJCmQ3NtZ6Px1zMyi9hVjiq5Cn7sfm8,5838
|
|
5
5
|
qubx/backtester/broker.py,sha256=9Xm85OyLf-1hc2G1CcIPnatTMvFcdUTZSClJWc4quKU,2759
|
|
6
6
|
qubx/backtester/data.py,sha256=5t3e8PHPf2ZJ7ZsswvyOznyJx8PtqaOzaAy85aIENU0,11826
|
|
7
|
-
qubx/backtester/management.py,sha256=
|
|
7
|
+
qubx/backtester/management.py,sha256=zO9KZXxzEEZNHaEuLH3Yf-faNQ75idovFh0SyhqVb8U,9290
|
|
8
8
|
qubx/backtester/ome.py,sha256=zveRUXBKnjR3fqKD44NOKpJPAgPDjMXzdwRTFTbXOCw,11066
|
|
9
9
|
qubx/backtester/optimization.py,sha256=Bl-7zLpmpnRnc12iPxxQeKjITsoUegis5DgipQqF_4o,7618
|
|
10
10
|
qubx/backtester/simulated_data.py,sha256=MVAjmj6ocXr8gPMXDfFfYljI7HVnShTUnAaayl7TbG4,22183
|
|
11
|
-
qubx/backtester/simulator.py,sha256=
|
|
11
|
+
qubx/backtester/simulator.py,sha256=nkoCD3JQ6eUPxyo1e1lB7lLpd5_IwtB4oR7VpKaE0sI,14380
|
|
12
12
|
qubx/backtester/utils.py,sha256=V8ethHIUhs3o85q1ssWwJj1s6NplwVpdzOFIjluUd3A,30483
|
|
13
13
|
qubx/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
14
|
qubx/cli/commands.py,sha256=SMl7Zax3OiWjB5M4fFh5w0bIVI4OLJrYUDsamjCe7_w,2276
|
|
@@ -23,24 +23,24 @@ qubx/connectors/ccxt/utils.py,sha256=jxNd5f8_BjwP50L18ATX2kqyPRg-GAnca25FhCGGvzA
|
|
|
23
23
|
qubx/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
24
|
qubx/core/account.py,sha256=0tH4-RNiZFNhmh1OBgQUlACbXNhxyeZore8bFWAFexw,10215
|
|
25
25
|
qubx/core/basics.py,sha256=pFfNYRGroQ1K0x54oZC7503sJA5r1GlENKfKq4PFIdM,27789
|
|
26
|
-
qubx/core/context.py,sha256=
|
|
26
|
+
qubx/core/context.py,sha256=nOzB_FFzGwjtYo7o8lwBEnJASr1fkd9B_Cc3SCcy7Kw,15616
|
|
27
27
|
qubx/core/exceptions.py,sha256=Jidp6v8rF6bCGB4SDNPt5CMHltkd9tbVkHzOvM29KdU,477
|
|
28
|
-
qubx/core/helpers.py,sha256=
|
|
29
|
-
qubx/core/interfaces.py,sha256=
|
|
28
|
+
qubx/core/helpers.py,sha256=7nhO-CgleU6RTXpSwCdMwb0ZwLCYi5hJWnag8kfFDXo,17701
|
|
29
|
+
qubx/core/interfaces.py,sha256=giiZAZpNsj5cUCEJMRv9IY1LKqTkbKS41xYksAI24uE,34369
|
|
30
30
|
qubx/core/loggers.py,sha256=ytUJh7k2npS8XY3chM7p-j32qJpsBzTuoIbXEvr2YiE,17639
|
|
31
31
|
qubx/core/lookups.py,sha256=PNOym2sxRRa0xQHTso8YDTqlHQ_dbZ3ohoJrUMNuba8,14779
|
|
32
|
-
qubx/core/metrics.py,sha256=
|
|
32
|
+
qubx/core/metrics.py,sha256=TOu3XkSd-RaR4QfuMeRioNFBn7-URTXDTkCKzPbzSkA,56577
|
|
33
33
|
qubx/core/mixins/__init__.py,sha256=zdoxocPyKvdvs4N6HCDTfwli5n-f4MD5sDnoChboj7k,196
|
|
34
34
|
qubx/core/mixins/market.py,sha256=s1NQDUjex7LR_ShnbSA3VnPMZpP7NmCgax5cmHdTmh4,3251
|
|
35
|
-
qubx/core/mixins/processing.py,sha256=
|
|
35
|
+
qubx/core/mixins/processing.py,sha256=dOERoe2TkNPihuXpb1lhawU78gCIdUukZNWThc-JeJY,18097
|
|
36
36
|
qubx/core/mixins/subscription.py,sha256=J_SX0CNw2bPy4bhxe0vswvDXY4LCkwXSaj_1PepKRLY,8540
|
|
37
37
|
qubx/core/mixins/trading.py,sha256=CQQIp1t1LJiFph5CiHQR4k4vxTymjFqrkA0awKYn4Dw,3224
|
|
38
|
-
qubx/core/mixins/universe.py,sha256=
|
|
39
|
-
qubx/core/series.cpython-311-x86_64-linux-gnu.so,sha256=
|
|
38
|
+
qubx/core/mixins/universe.py,sha256=YRgud3dKU4C26hL4fr5bA81iHRf0_wDSxhPpuw-V6KY,10100
|
|
39
|
+
qubx/core/series.cpython-311-x86_64-linux-gnu.so,sha256=yIO7MIBD84vrWHyuUMMEls_H-tDL1DRXapcdFyDxGLA,816968
|
|
40
40
|
qubx/core/series.pxd,sha256=EqgYT41FrpVB274mDG3jpLCSqK_ykkL-d-1IH8DE1ik,3301
|
|
41
41
|
qubx/core/series.pyi,sha256=zBt8DQCiIdTU3MLJz_9MlrONo7UCVYh2xYUltAcAj6c,3247
|
|
42
42
|
qubx/core/series.pyx,sha256=4XCRdH3otXsU8EJ-g4_zLQfhqR8TVjtEq_e4oDz5mZ4,33836
|
|
43
|
-
qubx/core/utils.cpython-311-x86_64-linux-gnu.so,sha256=
|
|
43
|
+
qubx/core/utils.cpython-311-x86_64-linux-gnu.so,sha256=HHlmItv45pXUh0OMJgDAe91Lh-sn9OXuHKXSwWXKCfY,82504
|
|
44
44
|
qubx/core/utils.pyi,sha256=DAjyRVPJSxK4Em-9wui2F0yYHfP5tI5DjKavXNOnHa8,276
|
|
45
45
|
qubx/core/utils.pyx,sha256=k5QHfEFvqhqWfCob89ANiJDKNG8gGbOh-O4CVoneZ8M,1696
|
|
46
46
|
qubx/data/__init__.py,sha256=ZBIOlciDTD44xyCYAJOngxwqxrKSgwJYDpMQdecPUIQ,245
|
|
@@ -61,7 +61,7 @@ qubx/resources/instruments/symbols-bitfinex.json,sha256=CpzoVgWzGZRN6RpUNhtJVxa3
|
|
|
61
61
|
qubx/resources/instruments/symbols-kraken.f.json,sha256=lwNqml3H7lNUl1h3siySSyE1MRcGfqfhb6BcxLsiKr0,212258
|
|
62
62
|
qubx/resources/instruments/symbols-kraken.json,sha256=RjUTvkQuuu7V1HfSQREvnA4qqkdkB3-rzykDaQds2rQ,456544
|
|
63
63
|
qubx/ta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
64
|
-
qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so,sha256=
|
|
64
|
+
qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so,sha256=tvYCTlR745OMdCON34knmc9vwJXls021B_mjcSe913A,609640
|
|
65
65
|
qubx/ta/indicators.pxd,sha256=eCJ9paOxtxbDFx4U5CUhcgB1jjCQAfVqMF2FnbJ03Lo,4222
|
|
66
66
|
qubx/ta/indicators.pyi,sha256=NJlvN_774UV1U3_lvaYYbCEikLR8sOUo0TdcUGR5GBM,1940
|
|
67
67
|
qubx/ta/indicators.pyx,sha256=FVkv5ld04TpZMT3a_kR1MU3IUuWfijzjJnh_lG78JxM,26029
|
|
@@ -70,7 +70,7 @@ qubx/trackers/abvanced.py,sha256=vo4DuX6sYzsXLcp5z1UYuGowlJEE47vzmSoKsMLBPu4,103
|
|
|
70
70
|
qubx/trackers/composite.py,sha256=W-n1vd4l-RZEoojj6lICqvJ8EgTV2kE6JUUmZUkZ1cI,6339
|
|
71
71
|
qubx/trackers/rebalancers.py,sha256=5Dx39QZ67iZVx-cfpYx4IoMgDd7-fCHvGkwtezL7ofY,5269
|
|
72
72
|
qubx/trackers/riskctrl.py,sha256=CawDn6x3cEyJFbhbLl4yg3pnwzJbRT9UCy2L-W6wXek,26085
|
|
73
|
-
qubx/trackers/sizers.py,sha256=
|
|
73
|
+
qubx/trackers/sizers.py,sha256=tIo3kOvfdv9vG4v4_Cq1VfunFOghn08BhI8F2QPvJd0,9250
|
|
74
74
|
qubx/utils/__init__.py,sha256=pIS1ulI6Hj8btZlPd5P9To7DlyEY20bEVvFREAZkR0A,384
|
|
75
75
|
qubx/utils/_pyxreloader.py,sha256=FyqGzfSpZGYziB8JYS5AP3cLRAvJSIPAKgwQn0E4YQ0,12017
|
|
76
76
|
qubx/utils/charting/lookinglass.py,sha256=m7lWU8c0E8tXzGbkN0GB8CL-kd92MnH_wD8cATX067k,39232
|
|
@@ -91,10 +91,10 @@ qubx/utils/plotting/renderers/plotly.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMp
|
|
|
91
91
|
qubx/utils/runner/__init__.py,sha256=axs9MF78BYk30jhHBu0gSXIr-IN5ZOzoprlJ_N85yN8,77
|
|
92
92
|
qubx/utils/runner/_jupyter_runner.pyt,sha256=0SSc9F6caok_uRy9Qzy3L7hEuebZykH6U5QEM9YnhZU,2321
|
|
93
93
|
qubx/utils/runner/accounts.py,sha256=3D9bqqG4MWVRw2YJ5iT1RgmyGRdTEBr7BDk1UephIUo,3237
|
|
94
|
-
qubx/utils/runner/configs.py,sha256=
|
|
95
|
-
qubx/utils/runner/runner.py,sha256=
|
|
96
|
-
qubx/utils/time.py,sha256=
|
|
97
|
-
qubx-0.5.
|
|
98
|
-
qubx-0.5.
|
|
99
|
-
qubx-0.5.
|
|
100
|
-
qubx-0.5.
|
|
94
|
+
qubx/utils/runner/configs.py,sha256=nQXU1oqtSSGpGHw4cqk1dVpcojibj7bzjWZbDAHRxNc,1741
|
|
95
|
+
qubx/utils/runner/runner.py,sha256=6dtiOGoi9V2coOu_NVcLBaQYzsaCv-bx1MKHOw8D910,17799
|
|
96
|
+
qubx/utils/time.py,sha256=1Cvh077Uqf-XjcE5nWp_T9JzFVT6i39kU7Qz-ssHKIo,9630
|
|
97
|
+
qubx-0.5.6.dist-info/METADATA,sha256=FhOj-B0n4SoT8ov2rWRmLtONccn_2-cnmzFONMa6Oko,3575
|
|
98
|
+
qubx-0.5.6.dist-info/WHEEL,sha256=MLOa6LysROdjgj4FVxsHitAnIh8Be2D_c9ZSBHKrz2M,110
|
|
99
|
+
qubx-0.5.6.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
|
|
100
|
+
qubx-0.5.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|