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.

@@ -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,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
- _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)}"
177
+
178
+ _one_line_dscr = ""
93
179
  if dscr:
94
- _s += f"\n\t{magenta(dscr)}"
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
- print(_s)
199
+
200
+ if not as_table:
201
+ print(_s)
110
202
 
111
203
  if with_metrics:
112
- r = TradingSessionResult.from_file(info["path"])
113
- metric = _pfl_metrics_prepare(r, True, 365)
114
- _m_repr = str(metric[0][["Gain", "Cagr", "Sharpe", "Max dd pct", "Qr", "Fees"]].round(3)).split("\n")[
115
- :-1
116
- ]
117
- for i in _m_repr:
118
- print("\t " + cyan(i))
119
- print()
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)
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."""
@@ -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):
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(self, name: str, description: str | None = None, compound=True, archive=True):
747
- name = (name + self.creation_time.strftime("%Y%m%d%H%M%S")) if self.creation_time else name
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
- if archive:
765
- import shutil
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
 
@@ -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
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
@@ -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:
@@ -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
- strategy = stg_cls(**cfg.parameters)
392
- exp_name = config_file.stem
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
- test_res = simulate({exp_name: strategy}, data=data_i, **sim_params)
413
- logger.info(f"<g>Simulation Results:</g>\n{str(test_res[0])}")
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
- _where_to_save = save_path if save_path is not None else Path("results/")
416
- s_path = Path(makedirs(str(_where_to_save))) / exp_name
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
- logger.info(f"Saving results to <g>{s_path}</g> ...")
419
- test_res[0].to_file(str(s_path))
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,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,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=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=L5t1Xc5_j6ulSraRK-RC6hWiVU2KtTSycod5RATk8E0,4660
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=2LCCZwEIQkRmYyBA8DAWfGwmX_zBcmqBWbxwIUMc97I,14182
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=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=dtIq8QWhOAWdIkVcVkXIc2ykecjiYcjGYoZ_z_el1z0,31533
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=qMoUD7Y4XT-RIBQkkD6rXDuy1hQN9OO59072CXMgMNQ,53472
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=ZcQ4ztHMtQURn-JVOlzeQAEPOfIB9n7ifkXs91bc4ik,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=Tek9kkT7PvVWHHoeOLqOxnosivqOIJYgRQxKCTcuN0Y,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=6OkgN8qfi-kPSCRVHRm8T-XO7E--cMCytGpP-8EhKiA,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
@@ -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=T4szog1x8QFksWQdOAIPXliTwtn_GJhWX_kSbfOnnZU,7018
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=EsqN-ZBGOvMruSGZe1plYx7zoLWmlNHOQsnoZLFxgAA,1646
95
- qubx/utils/runner/runner.py,sha256=dHzr8dDinYrh09nYO59cAax2jlZiGNTJU9Cql8UVHv4,15693
96
- qubx/utils/time.py,sha256=yYYAZvfXn79xE32nyoyJqBTdBbQum7-jzIiZf5xOi50,6612
97
- qubx-0.5.3.dist-info/METADATA,sha256=CC5_d274APOOQihoP83ByaJL4zZetoy31AGMMKBqWq0,3575
98
- qubx-0.5.3.dist-info/WHEEL,sha256=MLOa6LysROdjgj4FVxsHitAnIh8Be2D_c9ZSBHKrz2M,110
99
- qubx-0.5.3.dist-info/entry_points.txt,sha256=VqilDTe8mVuV9SbR-yVlZJBTjbkHIL2JBgXfQw076HY,47
100
- qubx-0.5.3.dist-info/RECORD,,
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