Qubx 0.5.5__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.

@@ -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:
@@ -60,7 +60,20 @@ class BacktestsResultsManager:
60
60
 
61
61
  return self
62
62
 
63
- def load(self, name: str | int | list[int] | list[str]) -> TradingSessionResult | list[TradingSessionResult]:
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 list(self, regex: str = "", with_metrics=False, params=False):
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,12 +170,17 @@ 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
- _s = f"{yellow(str(info.get('idx')))} - {red(name)} ::: {magenta(pd.Timestamp(info.get('creation_time', '')).round('1s'))} by {cyan(info.get('author', ''))}"
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)}"
93
177
 
178
+ _one_line_dscr = ""
94
179
  if dscr:
95
180
  dscr = dscr.split("\n")
96
181
  for _d in dscr:
97
182
  _s += f"\n\t{magenta('# ' + _d)}"
183
+ _one_line_dscr += " " + _d
98
184
 
99
185
  _s += f"\n\tstrategy: {green(s_cls)}"
100
186
  _s += f"\n\tinterval: {blue(start)} - {blue(stop)}"
@@ -110,32 +196,42 @@ class BacktestsResultsManager:
110
196
  justify="left",
111
197
  ).split("\n"):
112
198
  _s += f"\n\t | {yellow(i)}"
113
- print(_s)
199
+
200
+ if not as_table:
201
+ print(_s)
114
202
 
115
203
  if with_metrics:
116
- r = TradingSessionResult.from_file(info["path"])
117
- metric = _pfl_metrics_prepare(r, True, 365)
118
- _m_repr = str(metric[0][["Gain", "Cagr", "Sharpe", "Max dd pct", "Qr", "Fees"]].round(3)).split("\n")[
119
- :-1
120
- ]
121
- for i in _m_repr:
122
- print("\t " + cyan(i))
123
- print()
124
-
125
- def delete(self, name: str | int):
126
- print(red(f" -> Danger zone - you are about to delete {name} ..."))
127
- for info in self.results.values():
128
- match name:
129
- case int():
130
- if info.get("idx", -1) == name:
131
- Path(info["path"]).unlink()
132
- print(f" -> Deleted {red(name)} ...")
133
- self.reload()
134
- return
135
- case str():
136
- if info.get("name", "") == name:
137
- Path(info["path"]).unlink()
138
- print(f" -> Deleted {red(name)} ...")
139
- self.reload()
140
- return
141
- print(f" -> No results found for {red(name)} !")
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/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(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)
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) -> 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")
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(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."""
@@ -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["type"] != "cron":
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
@@ -1,4 +1,6 @@
1
- from qubx.core.basics import DataType, Instrument, TargetPosition
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
- self.__add_instruments(add_instr)
65
- self.__remove_instruments(rm_instr)
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 (add_instr or rm_instr):
68
- self._strategy.on_universe_change(self._context, add_instr, rm_instr)
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.__add_instruments(instruments)
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(self, instruments: list[Instrument]):
83
- self.__remove_instruments(instruments)
84
- self._strategy.on_universe_change(self._context, [], instruments)
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
- self._instruments = list(set(self._instruments) - set(instruments))
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 __remove_instruments(self, instruments: list[Instrument]) -> None:
161
+ def __do_remove_instruments(self, instruments: list[Instrument]):
93
162
  """
94
163
  Remove symbols from universe. Steps:
95
- - close all open positions
96
- - unsubscribe from market data
97
- - remove from data cache
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 instr.symbol in self._account.positions
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 __add_instruments(self, instruments: list[Instrument]) -> None:
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
- # - get actual positions from exchange
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
@@ -405,7 +405,7 @@ def simulate_strategy(
405
405
 
406
406
  experiments = variate(stg_cls, **(cfg.parameters | cfg.variate), conditions=conditions)
407
407
  experiments = {f"{simulation_name}.{_v_id}.[{k}]": v for k, v in experiments.items()}
408
- print(f"Variation is enabled. There are {len(experiments)} simualtions to run.")
408
+ print(f"Parameters variation is configured. There are {len(experiments)} simulations to run.")
409
409
  _n_jobs = -1
410
410
  else:
411
411
  strategy = stg_cls(**cfg.parameters)
@@ -430,6 +430,15 @@ def simulate_strategy(
430
430
  sim_params["stop"] = stop
431
431
  logger.info(f"Stop date set to {stop}")
432
432
 
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
441
+
433
442
  # - run simulation
434
443
  print(f" > Run simulation for [{red(simulation_name)}] ::: {sim_params['start']} - {sim_params['stop']}")
435
444
  sim_params["n_jobs"] = sim_params.get("n_jobs", _n_jobs)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Qubx
3
- Version: 0.5.5
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,10 +1,10 @@
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=5lnLJq4i12vb8F2R-vuyXUs_UbapFSxLTTAL2IT1qGo,5807
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=qprAmiyu23bhtC1UEU45V0GpfrMzZSMQ8pcfwvu3iUo,5510
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
@@ -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=IGNLwS28OUdNjbeWyugy_e0ut-XwnXwpd791LKjyBFc,15461
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=h9sFpl_o0SZuringIik0oDWBO07graHDKvB9tvSLQts,17058
29
- qubx/core/interfaces.py,sha256=kkwf3Zy0LjKc2-WiZQ9dAR4OrEFVWAyI0j7bBo48O70,32663
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
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=_QmlPvB_cU3i98KayzybabqYPGhP_B21jFUPl6Cjmgc,17560
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=2N6wPoVjACQMK3D477KiE4UBg8cuVxFJzqblvBSMjWc,5749
39
- qubx/core/series.cpython-311-x86_64-linux-gnu.so,sha256=Dx11oQrgo1io5s_eceAyrrRPh3fvwzpEFQg4FMzuwoM,816968
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=ogh740NY72T9NMfjgT1aVrdx7QuHivdJW9ggukrVR00,82504
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=bvad4VoqwSg4l10caAqMib0TBDUMDvmC4djUeIaLQ7c,609640
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
@@ -92,9 +92,9 @@ qubx/utils/runner/__init__.py,sha256=axs9MF78BYk30jhHBu0gSXIr-IN5ZOzoprlJ_N85yN8
92
92
  qubx/utils/runner/_jupyter_runner.pyt,sha256=0SSc9F6caok_uRy9Qzy3L7hEuebZykH6U5QEM9YnhZU,2321
93
93
  qubx/utils/runner/accounts.py,sha256=3D9bqqG4MWVRw2YJ5iT1RgmyGRdTEBr7BDk1UephIUo,3237
94
94
  qubx/utils/runner/configs.py,sha256=nQXU1oqtSSGpGHw4cqk1dVpcojibj7bzjWZbDAHRxNc,1741
95
- qubx/utils/runner/runner.py,sha256=0Dp2piBStIYwx3BTCGBjnynZaXj_F8JfmP0Z1964mbE,17444
96
- qubx/utils/time.py,sha256=yYYAZvfXn79xE32nyoyJqBTdBbQum7-jzIiZf5xOi50,6612
97
- qubx-0.5.5.dist-info/METADATA,sha256=-Jd0-kM0OkoUsZLEc2MY152y2_4tWiNNZX9S4_uhQAY,3575
98
- qubx-0.5.5.dist-info/WHEEL,sha256=MLOa6LysROdjgj4FVxsHitAnIh8Be2D_c9ZSBHKrz2M,110
99
- qubx-0.5.5.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
100
- qubx-0.5.5.dist-info/RECORD,,
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