Qubx 0.5.3__tar.gz → 0.5.6__tar.gz

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.

Files changed (99) hide show
  1. {qubx-0.5.3 → qubx-0.5.6}/PKG-INFO +1 -1
  2. {qubx-0.5.3 → qubx-0.5.6}/pyproject.toml +1 -1
  3. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/account.py +4 -3
  4. qubx-0.5.6/src/qubx/backtester/management.py +237 -0
  5. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/simulator.py +4 -1
  6. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/context.py +6 -2
  7. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/helpers.py +29 -15
  8. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/interfaces.py +63 -5
  9. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/metrics.py +85 -7
  10. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/mixins/processing.py +21 -5
  11. qubx-0.5.6/src/qubx/core/mixins/universe.py +272 -0
  12. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/sizers.py +54 -0
  13. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/runner/configs.py +2 -0
  14. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/runner/runner.py +56 -9
  15. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/time.py +85 -0
  16. qubx-0.5.3/src/qubx/backtester/management.py +0 -119
  17. qubx-0.5.3/src/qubx/core/mixins/universe.py +0 -155
  18. {qubx-0.5.3 → qubx-0.5.6}/README.md +0 -0
  19. {qubx-0.5.3 → qubx-0.5.6}/build.py +0 -0
  20. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/__init__.py +0 -0
  21. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/_nb_magic.py +0 -0
  22. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/__init__.py +0 -0
  23. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/broker.py +0 -0
  24. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/data.py +0 -0
  25. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/ome.py +0 -0
  26. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/optimization.py +0 -0
  27. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/simulated_data.py +0 -0
  28. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/backtester/utils.py +0 -0
  29. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/cli/__init__.py +0 -0
  30. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/cli/commands.py +0 -0
  31. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/__init__.py +0 -0
  32. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/account.py +0 -0
  33. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/broker.py +0 -0
  34. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/customizations.py +0 -0
  35. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/data.py +0 -0
  36. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/exceptions.py +0 -0
  37. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/factory.py +0 -0
  38. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/connectors/ccxt/utils.py +0 -0
  39. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/__init__.py +0 -0
  40. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/account.py +0 -0
  41. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/basics.py +0 -0
  42. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/exceptions.py +0 -0
  43. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/loggers.py +0 -0
  44. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/lookups.py +0 -0
  45. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/mixins/__init__.py +0 -0
  46. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/mixins/market.py +0 -0
  47. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/mixins/subscription.py +0 -0
  48. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/mixins/trading.py +0 -0
  49. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/series.pxd +0 -0
  50. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/series.pyi +0 -0
  51. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/series.pyx +0 -0
  52. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/utils.pyi +0 -0
  53. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/core/utils.pyx +0 -0
  54. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/data/__init__.py +0 -0
  55. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/data/helpers.py +0 -0
  56. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/data/readers.py +0 -0
  57. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/data/tardis.py +0 -0
  58. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/gathering/simplest.py +0 -0
  59. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/math/__init__.py +0 -0
  60. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/math/stats.py +0 -0
  61. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/pandaz/__init__.py +0 -0
  62. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/pandaz/ta.py +0 -0
  63. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/pandaz/utils.py +0 -0
  64. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-binance.cm.json +0 -0
  65. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-binance.json +0 -0
  66. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-binance.um.json +0 -0
  67. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-bitfinex.f.json +0 -0
  68. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-bitfinex.json +0 -0
  69. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-kraken.f.json +0 -0
  70. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/resources/instruments/symbols-kraken.json +0 -0
  71. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/ta/__init__.py +0 -0
  72. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/ta/indicators.pxd +0 -0
  73. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/ta/indicators.pyi +0 -0
  74. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/ta/indicators.pyx +0 -0
  75. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/__init__.py +0 -0
  76. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/abvanced.py +0 -0
  77. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/composite.py +0 -0
  78. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/rebalancers.py +0 -0
  79. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/trackers/riskctrl.py +0 -0
  80. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/__init__.py +0 -0
  81. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/_pyxreloader.py +0 -0
  82. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/charting/lookinglass.py +0 -0
  83. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  84. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/marketdata/binance.py +0 -0
  85. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/marketdata/ccxt.py +0 -0
  86. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/marketdata/dukas.py +0 -0
  87. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/misc.py +0 -0
  88. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/ntp.py +0 -0
  89. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/numbers_utils.py +0 -0
  90. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/orderbook.py +0 -0
  91. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/__init__.py +0 -0
  92. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/dashboard.py +0 -0
  93. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/data.py +0 -0
  94. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/interfaces.py +0 -0
  95. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/renderers/__init__.py +0 -0
  96. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
  97. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/runner/__init__.py +0 -0
  98. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/runner/_jupyter_runner.pyt +0 -0
  99. {qubx-0.5.3 → qubx-0.5.6}/src/qubx/utils/runner/accounts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Qubx
3
- Version: 0.5.3
3
+ Version: 0.5.6
4
4
  Summary: Qubx - quantitative trading framework
5
5
  Home-page: https://github.com/dmarienko/Qubx
6
6
  Author: Dmitry Marienko
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "Qubx"
3
- version = "0.5.3"
3
+ version = "0.5.6"
4
4
  description = "Qubx - quantitative trading framework"
5
5
  authors = [
6
6
  "Dmitry Marienko <dmitry@gmail.com>",
@@ -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) -> list[Order]:
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
- return [o for ome in self.ome.values() for o in ome.get_open_orders()]
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:
@@ -0,0 +1,237 @@
1
+ import re
2
+ import zipfile
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import yaml
8
+
9
+ from qubx.core.metrics import TradingSessionResult, _pfl_metrics_prepare
10
+ from qubx.utils.misc import blue, cyan, green, magenta, red, yellow
11
+
12
+
13
+ class BacktestsResultsManager:
14
+ """
15
+ Manager class for handling backtesting results.
16
+
17
+ This class provides functionality to load, list and manage backtesting results stored in zip files.
18
+ Each result contains trading session information and metrics that can be loaded and analyzed.
19
+
20
+ Parameters
21
+ ----------
22
+ path : str
23
+ Path to directory containing backtesting result zip files
24
+
25
+ Methods
26
+ -------
27
+ reload()
28
+ Reloads all backtesting results from the specified path
29
+ list(regex="", with_metrics=False)
30
+ Lists all backtesting results, optionally filtered by regex and including metrics
31
+ load(name)
32
+ Loads a specific backtesting result by name
33
+ """
34
+
35
+ def __init__(self, path: str):
36
+ self.path = path
37
+ self.reload()
38
+
39
+ def reload(self) -> "BacktestsResultsManager":
40
+ self.results = {}
41
+ names = defaultdict(lambda: 0)
42
+ for p in Path(self.path).glob("**/*.zip"):
43
+ with zipfile.ZipFile(p, "r") as zip_ref:
44
+ try:
45
+ info = yaml.safe_load(zip_ref.read("info.yml"))
46
+ info["path"] = str(p)
47
+ n = info.get("name", "")
48
+ _new_name = n if names[n] == 0 else f"{n}.{names[n]}"
49
+ names[n] += 1
50
+ info["name"] = _new_name
51
+ self.results[_new_name] = info
52
+ except Exception:
53
+ pass
54
+
55
+ # - reindex
56
+ _idx = 1
57
+ for n in sorted(self.results.keys()):
58
+ self.results[n]["idx"] = _idx
59
+ _idx += 1
60
+
61
+ return self
62
+
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
+
77
+ for info in self.results.values():
78
+ match name:
79
+ case int():
80
+ if info.get("idx", -1) == name:
81
+ return TradingSessionResult.from_file(info["path"])
82
+ case str():
83
+ if info.get("name", "") == name:
84
+ return TradingSessionResult.from_file(info["path"])
85
+ raise ValueError(f"No result found for {name}")
86
+
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 = []
159
+ for n in sorted(self.results.keys()):
160
+ info = self.results[n]
161
+ s_cls = info.get("strategy_class", "").split(".")[-1]
162
+
163
+ if regex:
164
+ if not re.match(regex, n, re.IGNORECASE):
165
+ if not re.match(regex, s_cls, re.IGNORECASE):
166
+ continue
167
+
168
+ name = info.get("name", "")
169
+ smbs = ", ".join(info.get("symbols", list()))
170
+ start = pd.Timestamp(info.get("start", "")).round("1s")
171
+ stop = pd.Timestamp(info.get("stop", "")).round("1s")
172
+ dscr = info.get("description", "")
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 = ""
179
+ if dscr:
180
+ dscr = dscr.split("\n")
181
+ for _d in dscr:
182
+ _s += f"\n\t{magenta('# ' + _d)}"
183
+ _one_line_dscr += " " + _d
184
+
185
+ _s += f"\n\tstrategy: {green(s_cls)}"
186
+ _s += f"\n\tinterval: {blue(start)} - {blue(stop)}"
187
+ _s += f"\n\tcapital: {blue(info.get('capital', ''))} {info.get('base_currency', '')} ({info.get('commissions', '')})"
188
+ _s += f"\n\tinstruments: {blue(smbs)}"
189
+ if params:
190
+ formats = ["{" + f":<{i}" + "}" for i in [50]]
191
+ _p = pd.DataFrame.from_dict(info.get("parameters", {}), orient="index")
192
+ for i in _p.to_string(
193
+ max_colwidth=30,
194
+ header=False,
195
+ formatters=[(lambda x: cyan(fmt.format(str(x)))) for fmt in formats],
196
+ justify="left",
197
+ ).split("\n"):
198
+ _s += f"\n\t | {yellow(i)}"
199
+
200
+ if not as_table:
201
+ print(_s)
202
+
203
+ if with_metrics:
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)
@@ -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="multiprocessing"
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)
@@ -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(self, instruments: list[Instrument], skip_callback: bool = False):
329
- return self._universe_manager.set_universe(instruments, skip_callback)
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)
@@ -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) -> Dict[str, 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) -> Dict[str, Any]:
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 or croniter.is_valid(_S):
250
- config = dict(type="cron", schedule=_S, spec=_S)
251
- else:
252
- raise ValueError(f"Wrong specification for cron type: {_S}")
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
- if _has_intervals:
269
- _F = (
270
- convert_seconds_to_str(int(_s_pos.as_unit("s").to_timedelta64().item().total_seconds()))
271
- if not _F
272
- else _F
273
- )
274
- config = dict(type="bar", schedule=None, timeframe=_F, delay=_s_neg, spec=_S)
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")
@@ -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(self, instruments: list[Instrument], skip_callback: bool = False):
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: type, other_cls: type):
1015
- return type(cls)(f"{cls.__name__}_{other_cls.__name__}", (other_cls, cls), {"__module__": cls.__module__})
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):