Qubx 0.6.0__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (132) hide show
  1. {qubx-0.6.0 → qubx-0.6.3}/PKG-INFO +2 -1
  2. {qubx-0.6.0 → qubx-0.6.3}/pyproject.toml +30 -28
  3. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/__init__.py +1 -4
  4. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/account.py +25 -10
  5. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/data.py +1 -1
  6. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/ome.py +94 -38
  7. qubx-0.6.3/src/qubx/backtester/runner.py +248 -0
  8. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/simulated_data.py +15 -2
  9. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/simulator.py +26 -166
  10. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/utils.py +48 -3
  11. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/cli/commands.py +8 -4
  12. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/cli/release.py +12 -4
  13. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/account.py +6 -4
  14. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/data.py +8 -5
  15. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/account.py +6 -2
  16. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/basics.py +27 -5
  17. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/context.py +22 -1
  18. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/helpers.py +1 -1
  19. qubx-0.6.3/src/qubx/core/initializer.py +84 -0
  20. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/interfaces.py +263 -14
  21. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/loggers.py +47 -11
  22. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/metrics.py +2 -2
  23. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/processing.py +36 -7
  24. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/series.pxd +22 -1
  25. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/series.pyi +21 -5
  26. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/series.pyx +149 -3
  27. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/utils.pyi +3 -1
  28. qubx-0.6.3/src/qubx/data/composite.py +149 -0
  29. qubx-0.6.3/src/qubx/data/hft.py +779 -0
  30. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/data/readers.py +171 -7
  31. qubx-0.6.3/src/qubx/exporters/__init__.py +11 -0
  32. qubx-0.6.3/src/qubx/exporters/composite.py +83 -0
  33. qubx-0.6.3/src/qubx/exporters/formatters/__init__.py +12 -0
  34. qubx-0.6.3/src/qubx/exporters/formatters/base.py +161 -0
  35. qubx-0.6.3/src/qubx/exporters/formatters/incremental.py +103 -0
  36. qubx-0.6.3/src/qubx/exporters/formatters/slack.py +183 -0
  37. qubx-0.6.3/src/qubx/exporters/redis_streams.py +177 -0
  38. qubx-0.6.3/src/qubx/exporters/slack.py +174 -0
  39. qubx-0.6.3/src/qubx/features/__init__.py +14 -0
  40. qubx-0.6.3/src/qubx/features/core.py +250 -0
  41. qubx-0.6.3/src/qubx/features/orderbook.py +41 -0
  42. qubx-0.6.3/src/qubx/features/price.py +20 -0
  43. qubx-0.6.3/src/qubx/features/trades.py +105 -0
  44. qubx-0.6.3/src/qubx/features/utils.py +10 -0
  45. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/math/stats.py +6 -4
  46. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/pandaz/__init__.py +8 -0
  47. qubx-0.6.3/src/qubx/restarts/state_resolvers.py +66 -0
  48. qubx-0.6.3/src/qubx/restarts/time_finders.py +34 -0
  49. qubx-0.6.3/src/qubx/restorers/__init__.py +36 -0
  50. qubx-0.6.3/src/qubx/restorers/balance.py +120 -0
  51. qubx-0.6.3/src/qubx/restorers/factory.py +201 -0
  52. qubx-0.6.3/src/qubx/restorers/interfaces.py +80 -0
  53. qubx-0.6.3/src/qubx/restorers/position.py +137 -0
  54. qubx-0.6.3/src/qubx/restorers/signal.py +159 -0
  55. qubx-0.6.3/src/qubx/restorers/state.py +110 -0
  56. qubx-0.6.3/src/qubx/restorers/utils.py +42 -0
  57. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/ta/indicators.pyi +2 -2
  58. qubx-0.6.3/src/qubx/trackers/__init__.py +30 -0
  59. qubx-0.6.0/src/qubx/trackers/abvanced.py → qubx-0.6.3/src/qubx/trackers/advanced.py +69 -0
  60. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/trackers/rebalancers.py +5 -7
  61. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/trackers/sizers.py +5 -7
  62. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/marketdata/binance.py +132 -93
  63. qubx-0.6.3/src/qubx/utils/runner/__init__.py +0 -0
  64. qubx-0.6.3/src/qubx/utils/runner/_jupyter_runner.pyt +152 -0
  65. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/runner/configs.py +20 -3
  66. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/runner/runner.py +249 -34
  67. qubx-0.6.0/src/qubx/trackers/__init__.py +0 -3
  68. qubx-0.6.0/src/qubx/utils/runner/_jupyter_runner.pyt +0 -60
  69. {qubx-0.6.0 → qubx-0.6.3}/README.md +0 -0
  70. {qubx-0.6.0 → qubx-0.6.3}/build.py +0 -0
  71. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/_nb_magic.py +0 -0
  72. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/__init__.py +0 -0
  73. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/broker.py +0 -0
  74. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/management.py +0 -0
  75. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/backtester/optimization.py +0 -0
  76. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/cli/__init__.py +0 -0
  77. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/cli/deploy.py +0 -0
  78. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/cli/misc.py +0 -0
  79. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/__init__.py +0 -0
  80. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/broker.py +0 -0
  81. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/customizations.py +0 -0
  82. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/exceptions.py +0 -0
  83. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/factory.py +0 -0
  84. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/connectors/ccxt/utils.py +0 -0
  85. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/__init__.py +0 -0
  86. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/exceptions.py +0 -0
  87. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/lookups.py +0 -0
  88. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/__init__.py +0 -0
  89. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/market.py +0 -0
  90. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/subscription.py +0 -0
  91. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/trading.py +0 -0
  92. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/mixins/universe.py +0 -0
  93. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/core/utils.pyx +0 -0
  94. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/data/__init__.py +0 -0
  95. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/data/helpers.py +0 -0
  96. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/data/tardis.py +0 -0
  97. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/gathering/simplest.py +0 -0
  98. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/math/__init__.py +0 -0
  99. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/pandaz/ta.py +0 -0
  100. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/pandaz/utils.py +0 -0
  101. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-binance.cm.json +0 -0
  102. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-binance.json +0 -0
  103. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-binance.um.json +0 -0
  104. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-bitfinex.f.json +0 -0
  105. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-bitfinex.json +0 -0
  106. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-kraken.f.json +0 -0
  107. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/resources/instruments/symbols-kraken.json +0 -0
  108. {qubx-0.6.0/src/qubx/ta → qubx-0.6.3/src/qubx/restarts}/__init__.py +0 -0
  109. {qubx-0.6.0/src/qubx/utils/plotting → qubx-0.6.3/src/qubx/ta}/__init__.py +0 -0
  110. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/ta/indicators.pxd +0 -0
  111. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/ta/indicators.pyx +0 -0
  112. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/trackers/composite.py +0 -0
  113. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/trackers/riskctrl.py +0 -0
  114. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/__init__.py +0 -0
  115. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/_pyxreloader.py +0 -0
  116. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/charting/lookinglass.py +0 -0
  117. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  118. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/marketdata/ccxt.py +0 -0
  119. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/marketdata/dukas.py +0 -0
  120. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/misc.py +0 -0
  121. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/ntp.py +0 -0
  122. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/numbers_utils.py +0 -0
  123. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/orderbook.py +0 -0
  124. {qubx-0.6.0/src/qubx/utils/plotting/renderers → qubx-0.6.3/src/qubx/utils/plotting}/__init__.py +0 -0
  125. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/plotting/dashboard.py +0 -0
  126. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/plotting/data.py +0 -0
  127. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/plotting/interfaces.py +0 -0
  128. {qubx-0.6.0/src/qubx/utils/runner → qubx-0.6.3/src/qubx/utils/plotting/renderers}/__init__.py +0 -0
  129. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
  130. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/runner/accounts.py +0 -0
  131. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/time.py +0 -0
  132. {qubx-0.6.0 → qubx-0.6.3}/src/qubx/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: Qubx
3
- Version: 0.6.0
3
+ Version: 0.6.3
4
4
  Summary: Qubx - Quantitative Trading Framework
5
5
  Author: Dmitry Marienko
6
6
  Author-email: dmitry.marienko@xlydian.com
@@ -37,6 +37,7 @@ Requires-Dist: pymongo (>=4.6.1,<5.0.0)
37
37
  Requires-Dist: python-binance (>=1.0.19,<2.0.0)
38
38
  Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
39
39
  Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
40
+ Requires-Dist: redis (>=5.2.1,<6.0.0)
40
41
  Requires-Dist: scikit-learn (>=1.4.2,<2.0.0)
41
42
  Requires-Dist: scipy (>=1.12.0,<2.0.0)
42
43
  Requires-Dist: sortedcontainers (>=2.4.0,<3.0.0)
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "Qubx"
7
- version = "0.6.0"
7
+ version = "0.6.3"
8
8
  description = "Qubx - Quantitative Trading Framework"
9
9
  authors = [ "Dmitry Marienko <dmitry.marienko@xlydian.com>", "Yuriy Arabskyy <yuriy.arabskyy@xlydian.com>",]
10
10
  readme = "README.md"
@@ -24,50 +24,51 @@ format = "wheel"
24
24
  [tool.ruff]
25
25
  line-length = 120
26
26
 
27
+ [tool.poetry.build]
28
+ script = "build.py"
29
+ generate-setup-file = false
30
+
31
+ [tool.poetry.scripts]
32
+ qubx = "qubx.cli.commands:main"
33
+
27
34
  [tool.poetry.dependencies]
28
35
  python = ">=3.10,<4.0"
29
36
  numpy = "^1.26.3"
37
+ pandas = "^2.2.2"
38
+ pyarrow = "^15.0.0"
39
+ scipy = "^1.12.0"
40
+ scikit-learn = "^1.4.2"
41
+ statsmodels = "^0.14.2"
42
+ numba = "^0.59.1"
43
+ sortedcontainers = "^2.4.0"
30
44
  ntplib = "^0.4.0"
45
+ python-binance = "^1.0.19"
46
+ ccxt = "^4.2.68"
47
+ pymongo = "^4.6.1"
48
+ redis = "^5.2.1"
49
+ psycopg = "^3.1.18"
50
+ psycopg-binary = "^3.1.19"
51
+ psycopg-pool = "^3.2.2"
52
+ matplotlib = "^3.8.4"
53
+ plotly = "^5.22.0"
54
+ dash = "^2.18.2"
55
+ dash-bootstrap-components = "^1.6.0"
31
56
  loguru = "^0.7.2"
32
57
  tqdm = "*"
33
58
  importlib-metadata = "*"
34
59
  stackprinter = "^0.2.10"
35
- pymongo = "^4.6.1"
36
60
  pydantic = "^2.9.2"
37
61
  python-dotenv = "^1.0.0"
38
- python-binance = "^1.0.19"
39
- pyarrow = "^15.0.0"
40
- scipy = "^1.12.0"
41
62
  cython = "3.0.8"
42
- ccxt = "^4.2.68"
43
63
  croniter = "^2.0.5"
44
- psycopg = "^3.1.18"
45
- pandas = "^2.2.2"
46
- statsmodels = "^0.14.2"
47
- matplotlib = "^3.8.4"
48
- numba = "^0.59.1"
49
- scikit-learn = "^1.4.2"
50
- plotly = "^5.22.0"
51
- psycopg-binary = "^3.1.19"
52
- psycopg-pool = "^3.2.2"
53
- sortedcontainers = "^2.4.0"
54
64
  msgspec = "^0.18.6"
55
65
  pyyaml = "^6.0.2"
56
- dash = "^2.18.2"
57
- dash-bootstrap-components = "^1.6.0"
58
66
  tabulate = "^0.9.0"
59
- jupyter-console = "^6.6.3"
60
67
  toml = "^0.10.2"
61
68
  gitpython = "^3.1.44"
62
- ipywidgets = "^8.1.5"
63
69
  jupyter = "^1.1.1"
64
-
65
- [tool.poetry.build]
66
- script = "build.py"
67
- generate-setup-file = false
68
-
69
- [tool.poetry.scripts]
70
- qubx = "qubx.cli.commands:main"
70
+ jupyter-console = "^6.6.3"
71
+ ipywidgets = "^8.1.5"
71
72
 
72
73
  [tool.ruff.lint]
73
74
  extend-select = [ "I",]
@@ -77,7 +78,7 @@ ignore = [ "E731", "E722", "E741",]
77
78
  asyncio_mode = "auto"
78
79
  asyncio_default_fixture_loop_scope = "function"
79
80
  pythonpath = [ "src",]
80
- markers = [ "integration: mark a test as an integration test",]
81
+ markers = [ "integration: mark test as requiring external services like Redis", "e2e: mark test as requiring external exchange connections and API credentials",]
81
82
  addopts = "--disable-warnings"
82
83
  filterwarnings = [ "ignore:.*Jupyter is migrating.*:DeprecationWarning",]
83
84
 
@@ -104,6 +105,7 @@ jinja2 = "3.1.5"
104
105
  mike = "2.1.3"
105
106
  mkdocs-jupyter = "0.25.1"
106
107
  debugpy = "^1.8.12"
108
+ hftbacktest = "^2.2.0"
107
109
 
108
110
  [tool.poetry.group.test.dependencies]
109
111
  pytest-asyncio = "^0.24.0"
@@ -68,10 +68,6 @@ class QubxLogConfig:
68
68
  def setup_logger(level: str | None = None, custom_formatter: Callable | None = None):
69
69
  global logger
70
70
 
71
- # First, remove all existing handlers to prevent resource leaks
72
- # Use a safer approach that doesn't rely on internal attributes
73
- logger.remove()
74
-
75
71
  config = {
76
72
  "handlers": [
77
73
  {"sink": sys.stdout, "format": "{time} - {message}"},
@@ -79,6 +75,7 @@ class QubxLogConfig:
79
75
  "extra": {"user": "someone"},
80
76
  }
81
77
  logger.configure(**config)
78
+ logger.remove(None)
82
79
 
83
80
  level = level or QubxLogConfig.get_log_level()
84
81
  # Add stdout handler with enqueue=True for thread/process safety
@@ -12,7 +12,8 @@ from qubx.core.basics import (
12
12
  dt_64,
13
13
  )
14
14
  from qubx.core.interfaces import ITimeProvider
15
- from qubx.core.series import Bar, OrderBook, Quote, Trade
15
+ from qubx.core.series import Bar, OrderBook, Quote, Trade, TradeArray
16
+ from qubx.restorers import RestoredState
16
17
 
17
18
 
18
19
  class SimulatedAccountProcessor(BasicAccountProcessor):
@@ -32,6 +33,7 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
32
33
  time_provider: ITimeProvider,
33
34
  tcc: TransactionCostsCalculator = ZERO_COSTS,
34
35
  accurate_stop_orders_execution: bool = False,
36
+ restored_state: RestoredState | None = None,
35
37
  ) -> None:
36
38
  super().__init__(
37
39
  account_id=account_id,
@@ -48,6 +50,12 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
48
50
  if self._fill_stop_order_at_price:
49
51
  logger.info(f"[<y>{self.__class__.__name__}</y>] :: emulates stop orders executions at exact price")
50
52
 
53
+ if restored_state is not None:
54
+ self._balances.update(restored_state.balances)
55
+ for instrument, position in restored_state.positions.items():
56
+ _pos = self.get_position(instrument)
57
+ _pos.reset_by_position(position)
58
+
51
59
  def get_orders(self, instrument: Instrument | None = None) -> dict[str, Order]:
52
60
  if instrument is not None:
53
61
  ome = self.ome.get(instrument)
@@ -76,20 +84,27 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
76
84
  self.attach_positions(position)
77
85
  return self.positions[instrument]
78
86
 
79
- def update_position_price(self, time: dt_64, instrument: Instrument, price: float) -> None:
80
- super().update_position_price(time, instrument, price)
87
+ def update_position_price(self, time: dt_64, instrument: Instrument, update: float | Timestamped) -> None:
88
+ super().update_position_price(time, instrument, update)
81
89
 
82
90
  # - first we need to update OME with new quote.
83
91
  # - if update is not a quote we need 'emulate' it.
84
92
  # - actually if SimulatedExchangeService is used in backtesting mode it will recieve only quotes
85
93
  # - case when we need that - SimulatedExchangeService is used for paper trading and data provider configured to listen to OHLC or TAS.
86
94
  # - probably we need to subscribe to quotes in real data provider in any case and then this emulation won't be needed.
87
- quote = price if isinstance(price, Quote) else self.emulate_quote_from_data(instrument, time, price)
95
+ quote = update if isinstance(update, Quote) else self.emulate_quote_from_data(instrument, time, update)
88
96
  if quote is None:
89
97
  return
90
98
 
91
- # - process new quote
92
- self._process_new_quote(instrument, quote)
99
+ # - process new data
100
+ self._process_new_data(instrument, quote)
101
+
102
+ def process_market_data(self, time: dt_64, instrument: Instrument, update: Timestamped) -> None:
103
+ if isinstance(update, (TradeArray, Quote, Trade, OrderBook)):
104
+ # - process new data
105
+ self._process_new_data(instrument, update)
106
+
107
+ super().process_market_data(time, instrument, update)
93
108
 
94
109
  def process_order(self, order: Order, update_locked_value: bool = True) -> None:
95
110
  _new = order.status == "NEW"
@@ -113,7 +128,7 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
113
128
 
114
129
  elif isinstance(data, Trade):
115
130
  _ts2 = self._half_tick_size[instrument]
116
- if data.taker: # type: ignore
131
+ if data.side == 1: # type: ignore
117
132
  return Quote(timestamp, data.price - _ts2 * 2, data.price, 0, 0) # type: ignore
118
133
  else:
119
134
  return Quote(timestamp, data.price, data.price + _ts2 * 2, 0, 0) # type: ignore
@@ -132,12 +147,12 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
132
147
  else:
133
148
  return None
134
149
 
135
- def _process_new_quote(self, instrument: Instrument, data: Quote) -> None:
150
+ def _process_new_data(self, instrument: Instrument, data: Quote | OrderBook | Trade | TradeArray) -> None:
136
151
  ome = self.ome.get(instrument)
137
152
  if ome is None:
138
- logger.warning("ExchangeService:update :: No OME configured for '{symbol}' yet !")
153
+ logger.warning(f"ExchangeService:update :: No OME configured for '{instrument}' yet !")
139
154
  return
140
- for r in ome.update_bbo(data):
155
+ for r in ome.process_market_data(data):
141
156
  if r.exec is not None:
142
157
  self.order_to_instrument.pop(r.order.id)
143
158
  # - process methods will be called from stg context
@@ -151,7 +151,7 @@ class SimulatedDataProvider(IDataProvider):
151
151
  self._last_quotes[i] = last_quote
152
152
 
153
153
  # - also need to pass this quote to OME !
154
- self._account._process_new_quote(i, last_quote)
154
+ self._account._process_new_data(i, last_quote)
155
155
 
156
156
  logger.debug(f" | subscribed {subscription_type} {i} -> {last_quote}")
157
157
 
@@ -19,8 +19,9 @@ from qubx.core.basics import (
19
19
  from qubx.core.exceptions import (
20
20
  ExchangeError,
21
21
  InvalidOrder,
22
+ SimulationError,
22
23
  )
23
- from qubx.core.series import Quote
24
+ from qubx.core.series import OrderBook, Quote, Trade, TradeArray
24
25
 
25
26
 
26
27
  @dataclass
@@ -31,13 +32,18 @@ class OmeReport:
31
32
 
32
33
 
33
34
  class OrdersManagementEngine:
35
+ """
36
+ Orders Management Engine (OME) is a simple implementation of a management of orders for simulation of a limit order book.
37
+ """
38
+
34
39
  instrument: Instrument
35
40
  time_service: ITimeProvider
36
41
  active_orders: dict[str, Order]
37
42
  stop_orders: dict[str, Order]
38
43
  asks: SortedDict[float, list[str]]
39
44
  bids: SortedDict[float, list[str]]
40
- bbo: Quote | None # current best bid/ask order book (simplest impl)
45
+ bbo: Quote | None # - current best bid/ask order book
46
+ __prev_bbo: Quote | None # - previous best bid/ask order book
41
47
  __order_id: int
42
48
  __trade_id: int
43
49
  _fill_stops_at_price: bool
@@ -73,46 +79,80 @@ class OrdersManagementEngine:
73
79
  return "SIM-EXEC-" + self.instrument.symbol + "-" + str(self.__trade_id)
74
80
 
75
81
  def get_quote(self) -> Quote:
76
- return self.bbo
82
+ return self.bbo # type: ignore
77
83
 
78
84
  def get_open_orders(self) -> list[Order]:
79
85
  return list(self.active_orders.values()) + list(self.stop_orders.values())
80
86
 
81
- def update_bbo(self, quote: Quote) -> list[OmeReport]:
87
+ def process_market_data(self, mdata: Quote | OrderBook | Trade | TradeArray) -> list[OmeReport]:
88
+ """
89
+ Processes the new market data (quote, trade or trades array) and simulates the execution of pending orders.
90
+ """
82
91
  timestamp = self.time_service.time()
83
- rep = []
84
-
85
- if self.bbo is not None:
86
- if quote.bid >= self.bbo.ask:
87
- for level in self.asks.irange(0, quote.bid):
88
- for order_id in self.asks[level]:
89
- order = self.active_orders.pop(order_id)
90
- rep.append(self._execute_order(timestamp, order.price, order, False))
91
- self.asks.pop(level)
92
-
93
- if quote.ask <= self.bbo.bid:
94
- for level in self.bids.irange(np.inf, quote.ask):
95
- for order_id in self.bids[level]:
96
- order = self.active_orders.pop(order_id)
97
- rep.append(self._execute_order(timestamp, order.price, order, False))
98
- self.bids.pop(level)
99
-
100
- # - processing stop orders
101
- for soid in list(self.stop_orders.keys()):
102
- so = self.stop_orders[soid]
103
- _emulate_price_exec = self._fill_stops_at_price or so.options.get(OPTION_FILL_AT_SIGNAL_PRICE, False)
104
-
105
- if so.side == "BUY" and quote.ask >= so.price:
106
- _exec_price = quote.ask if not _emulate_price_exec else so.price
107
- self.stop_orders.pop(soid)
108
- rep.append(self._execute_order(timestamp, _exec_price, so, True))
109
- elif so.side == "SELL" and quote.bid <= so.price:
110
- _exec_price = quote.bid if not _emulate_price_exec else so.price
111
- self.stop_orders.pop(soid)
112
- rep.append(self._execute_order(timestamp, _exec_price, so, True))
113
-
114
- self.bbo = quote
115
- return rep
92
+ _exec_report = []
93
+
94
+ # - new quote
95
+ if isinstance(mdata, Quote):
96
+ _b, _a = mdata.bid, mdata.ask
97
+ _bs, _as = _b, _a
98
+
99
+ # - update BBO by new quote
100
+ self.__prev_bbo = self.bbo
101
+ self.bbo = mdata
102
+
103
+ # - bunch of trades
104
+ elif isinstance(mdata, TradeArray):
105
+ _b = mdata.max_buy_price
106
+ _a = mdata.min_sell_price
107
+ _bs, _as = _a, _b
108
+
109
+ # - single trade
110
+ elif isinstance(mdata, Trade):
111
+ _b, _a = mdata.price, mdata.price
112
+ _bs, _as = _b, _a
113
+
114
+ # - order book
115
+ elif isinstance(mdata, OrderBook):
116
+ _b, _a = mdata.top_bid, mdata.top_ask
117
+ _bs, _as = _b, _a
118
+
119
+ else:
120
+ raise SimulationError(f"Invalid market data type: {type(mdata)} for update OME({self.instrument.symbol})")
121
+
122
+ # - when new quote bid is higher than the lowest ask order execute all affected orders
123
+ if self.asks and _b >= self.asks.keys()[0]:
124
+ _asks_to_execute = list(self.asks.irange(0, _b))
125
+ for level in _asks_to_execute:
126
+ for order_id in self.asks[level]:
127
+ order = self.active_orders.pop(order_id)
128
+ _exec_report.append(self._execute_order(timestamp, order.price, order, False))
129
+ self.asks.pop(level)
130
+
131
+ # - when new quote ask is lower than the highest bid order execute all affected orders
132
+ if self.bids and _a <= self.bids.keys()[0]:
133
+ _bids_to_execute = list(self.bids.irange(np.inf, _a))
134
+ for level in _bids_to_execute:
135
+ for order_id in self.bids[level]:
136
+ order = self.active_orders.pop(order_id)
137
+ _exec_report.append(self._execute_order(timestamp, order.price, order, False))
138
+ self.bids.pop(level)
139
+
140
+ # - processing stop orders
141
+ for soid in list(self.stop_orders.keys()):
142
+ so = self.stop_orders[soid]
143
+ _emulate_price_exec = self._fill_stops_at_price or so.options.get(OPTION_FILL_AT_SIGNAL_PRICE, False)
144
+
145
+ if so.side == "BUY" and _as >= so.price:
146
+ _exec_price = _as if not _emulate_price_exec else so.price
147
+ self.stop_orders.pop(soid)
148
+ _exec_report.append(self._execute_order(timestamp, _exec_price, so, True))
149
+
150
+ elif so.side == "SELL" and _bs <= so.price:
151
+ _exec_price = _bs if not _emulate_price_exec else so.price
152
+ self.stop_orders.pop(soid)
153
+ _exec_report.append(self._execute_order(timestamp, _exec_price, so, True))
154
+
155
+ return _exec_report
116
156
 
117
157
  def place_order(
118
158
  self,
@@ -163,7 +203,23 @@ class OrdersManagementEngine:
163
203
  _need_update_book = False
164
204
 
165
205
  if order.type == "MARKET":
166
- exec_price = c_ask if buy_side else c_bid
206
+ if exec_price is None:
207
+ exec_price = c_ask if buy_side else c_bid
208
+
209
+ # - special case - fill at signal price for market order
210
+ # - only for simulation
211
+ # - only if this is valid price: market crossed this desired price on last update
212
+ if order.options.get(OPTION_FILL_AT_SIGNAL_PRICE, False) and order.price and self.__prev_bbo:
213
+ _desired_fill_price = order.price
214
+
215
+ if (buy_side and self.__prev_bbo.ask < _desired_fill_price <= c_ask) or (
216
+ not buy_side and self.__prev_bbo.bid > _desired_fill_price >= c_bid
217
+ ):
218
+ exec_price = _desired_fill_price
219
+ else:
220
+ raise SimulationError(
221
+ f"Special execution price at {_desired_fill_price} for market order {order.id} cannot be filled because market didn't cross this price on last update !"
222
+ )
167
223
 
168
224
  elif order.type == "LIMIT":
169
225
  _need_update_book = True
@@ -0,0 +1,248 @@
1
+ from typing import Any
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ from qubx import logger
7
+ from qubx.core.basics import SW, DataType
8
+ from qubx.core.context import StrategyContext
9
+ from qubx.core.exceptions import SimulationConfigError, SimulationError
10
+ from qubx.core.helpers import extract_parameters_from_object, full_qualified_class_name
11
+ from qubx.core.interfaces import IStrategy, IStrategyContext
12
+ from qubx.core.loggers import InMemoryLogsWriter, StrategyLogging
13
+ from qubx.core.lookups import lookup
14
+ from qubx.pandaz.utils import _frame_to_str
15
+
16
+ from .account import SimulatedAccountProcessor
17
+ from .broker import SimulatedBroker
18
+ from .data import SimulatedDataProvider
19
+ from .utils import (
20
+ SetupTypes,
21
+ SignalsProxy,
22
+ SimulatedCtrlChannel,
23
+ SimulatedScheduler,
24
+ SimulatedTimeProvider,
25
+ SimulationDataConfig,
26
+ SimulationSetup,
27
+ )
28
+
29
+
30
+ class SimulationRunner:
31
+ """
32
+ A wrapper around the StrategyContext that encapsulates the simulation logic.
33
+ This class is responsible for running a backtest context from a start time to an end time.
34
+ """
35
+
36
+ setup: SimulationSetup
37
+ data_config: SimulationDataConfig
38
+ start: pd.Timestamp
39
+ stop: pd.Timestamp
40
+ account_id: str
41
+ portfolio_log_freq: str
42
+ ctx: IStrategyContext
43
+ data_provider: SimulatedDataProvider
44
+ logs_writer: InMemoryLogsWriter
45
+
46
+ strategy_params: dict[str, Any]
47
+ strategy_class: str
48
+
49
+ # adjusted times
50
+ _stop: pd.Timestamp | None = None
51
+
52
+ def __init__(
53
+ self,
54
+ setup: SimulationSetup,
55
+ data_config: SimulationDataConfig,
56
+ start: pd.Timestamp | str,
57
+ stop: pd.Timestamp | str,
58
+ account_id: str = "SimulatedAccount",
59
+ portfolio_log_freq: str = "5Min",
60
+ ):
61
+ """
62
+ Initialize the BacktestContextRunner with a strategy context.
63
+
64
+ Args:
65
+ setup (SimulationSetup): The setup to run.
66
+ data_config (SimulationDataConfig): The data setup to use.
67
+ start (pd.Timestamp): The start time of the simulation.
68
+ stop (pd.Timestamp): The end time of the simulation.
69
+ account_id (str): The account id to use.
70
+ portfolio_log_freq (str): The portfolio log frequency to use.
71
+ """
72
+ self.setup = setup
73
+ self.data_config = data_config
74
+ self.start = pd.Timestamp(start)
75
+ self.stop = pd.Timestamp(stop)
76
+ self.account_id = account_id
77
+ self.portfolio_log_freq = portfolio_log_freq
78
+ self.ctx = self._create_backtest_context()
79
+
80
+ # - get strategy parameters BEFORE simulation start
81
+ # potentially strategy may change it's parameters during simulation
82
+ self.strategy_params = {}
83
+ self.strategy_class = ""
84
+ if self.setup.setup_type in [SetupTypes.STRATEGY, SetupTypes.STRATEGY_AND_TRACKER]:
85
+ self.strategy_params = extract_parameters_from_object(self.setup.generator)
86
+ self.strategy_class = full_qualified_class_name(self.setup.generator)
87
+
88
+ def run(self, silent: bool = False):
89
+ """
90
+ Run the backtest from start to stop.
91
+
92
+ Args:
93
+ start (pd.Timestamp | str): The start time of the simulation.
94
+ stop (pd.Timestamp | str): The end time of the simulation.
95
+ silent (bool, optional): Whether to suppress progress output. Defaults to False.
96
+ """
97
+ logger.debug(f"[<y>BacktestContextRunner</y>] :: Running simulation from {self.start} to {self.stop}")
98
+
99
+ # Start the context
100
+ self.ctx.start()
101
+
102
+ # Apply default warmup periods if strategy didn't set them
103
+ for s in self.ctx.get_subscriptions():
104
+ if not self.ctx.get_warmup(s) and (_d_wt := self.data_config.default_warmups.get(s)):
105
+ logger.debug(
106
+ f"[<y>BacktestContextRunner</y>] :: Strategy didn't set warmup period for <c>{s}</c> so default <c>{_d_wt}</c> will be used"
107
+ )
108
+ self.ctx.set_warmup({s: _d_wt})
109
+
110
+ # Subscribe to any custom data types if needed
111
+ def _is_known_type(t: str):
112
+ try:
113
+ DataType(t)
114
+ return True
115
+ except: # noqa: E722
116
+ return False
117
+
118
+ for t, r in self.data_config.data_providers.items():
119
+ if not _is_known_type(t) or t in [
120
+ DataType.TRADE,
121
+ DataType.OHLC_TRADES,
122
+ DataType.OHLC_QUOTES,
123
+ DataType.QUOTE,
124
+ DataType.ORDERBOOK,
125
+ ]:
126
+ logger.debug(f"[<y>BacktestContextRunner</y>] :: Subscribing to: {t}")
127
+ self.ctx.subscribe(t, self.ctx.instruments)
128
+
129
+ stop = self._stop or self.stop
130
+
131
+ try:
132
+ # Run the data provider
133
+ self.data_provider.run(self.start, stop, silent=silent)
134
+ except KeyboardInterrupt:
135
+ logger.error("Simulated trading interrupted by user!")
136
+ finally:
137
+ # Stop the context
138
+ self.ctx.stop()
139
+
140
+ def print_latency_report(self) -> None:
141
+ _l_r = SW.latency_report()
142
+ if _l_r is not None:
143
+ logger.info(
144
+ "<BLUE> Time spent in simulation report </BLUE>\n<r>"
145
+ + _frame_to_str(
146
+ _l_r.sort_values("latency", ascending=False).reset_index(drop=True), "simulation", -1, -1, False
147
+ )
148
+ + "</r>"
149
+ )
150
+
151
+ def _create_backtest_context(self) -> IStrategyContext:
152
+ tcc = lookup.fees.find(self.setup.exchange.lower(), self.setup.commissions)
153
+ if tcc is None:
154
+ raise SimulationConfigError(
155
+ f"Can't find transaction costs calculator for '{self.setup.exchange}' for specification '{self.setup.commissions}' !"
156
+ )
157
+
158
+ channel = SimulatedCtrlChannel("databus", sentinel=(None, None, None, None))
159
+ simulated_clock = SimulatedTimeProvider(np.datetime64(self.start, "ns"))
160
+
161
+ logger.debug(
162
+ f"[<y>simulator</y>] :: Preparing simulated trading on <g>{self.setup.exchange.upper()}</g> for {self.setup.capital} {self.setup.base_currency}..."
163
+ )
164
+
165
+ account = SimulatedAccountProcessor(
166
+ account_id=self.account_id,
167
+ channel=channel,
168
+ base_currency=self.setup.base_currency,
169
+ initial_capital=self.setup.capital,
170
+ time_provider=simulated_clock,
171
+ tcc=tcc,
172
+ accurate_stop_orders_execution=self.setup.accurate_stop_orders_execution,
173
+ )
174
+ scheduler = SimulatedScheduler(channel, lambda: simulated_clock.time().item())
175
+ broker = SimulatedBroker(channel, account, self.setup.exchange)
176
+ data_provider = SimulatedDataProvider(
177
+ exchange_id=self.setup.exchange,
178
+ channel=channel,
179
+ scheduler=scheduler,
180
+ time_provider=simulated_clock,
181
+ account=account,
182
+ readers=self.data_config.data_providers,
183
+ open_close_time_indent_secs=self.data_config.adjusted_open_close_time_indent_secs,
184
+ )
185
+ # - get aux data provider
186
+ _aux_data = self.data_config.get_timeguarded_aux_reader(simulated_clock)
187
+ # - it will store simulation results into memory
188
+ logs_writer = InMemoryLogsWriter(self.account_id, self.setup.name, "0")
189
+
190
+ # - it will store simulation results into memory
191
+ strat: IStrategy | None = None
192
+
193
+ match self.setup.setup_type:
194
+ case SetupTypes.STRATEGY:
195
+ strat = self.setup.generator # type: ignore
196
+
197
+ case SetupTypes.STRATEGY_AND_TRACKER:
198
+ strat = self.setup.generator # type: ignore
199
+ strat.tracker = lambda ctx: self.setup.tracker # type: ignore
200
+
201
+ case SetupTypes.SIGNAL:
202
+ strat = SignalsProxy(timeframe=self.setup.signal_timeframe)
203
+ data_provider.set_generated_signals(self.setup.generator) # type: ignore
204
+
205
+ # - we don't need any unexpected triggerings
206
+ self._stop = min(self.setup.generator.index[-1], self.stop) # type: ignore
207
+
208
+ case SetupTypes.SIGNAL_AND_TRACKER:
209
+ strat = SignalsProxy(timeframe=self.setup.signal_timeframe)
210
+ strat.tracker = lambda ctx: self.setup.tracker
211
+ data_provider.set_generated_signals(self.setup.generator) # type: ignore
212
+
213
+ # - we don't need any unexpected triggerings
214
+ self._stop = min(self.setup.generator.index[-1], self.stop) # type: ignore
215
+
216
+ case _:
217
+ raise SimulationError(f"Unsupported setup type: {self.setup.setup_type} !")
218
+
219
+ if not isinstance(strat, IStrategy):
220
+ raise SimulationConfigError(f"Strategy should be an instance of IStrategy, but got {strat} !")
221
+
222
+ ctx = StrategyContext(
223
+ strategy=strat,
224
+ broker=broker,
225
+ data_provider=data_provider,
226
+ account=account,
227
+ scheduler=scheduler,
228
+ time_provider=simulated_clock,
229
+ instruments=self.setup.instruments,
230
+ logging=StrategyLogging(logs_writer, portfolio_log_freq=self.portfolio_log_freq),
231
+ aux_data_provider=_aux_data,
232
+ )
233
+
234
+ # - setup base subscription from spec
235
+ if ctx.get_base_subscription() == DataType.NONE:
236
+ logger.debug(
237
+ f"[<y>simulator</y>] :: Setting up default base subscription: {self.data_config.default_base_subscription}"
238
+ )
239
+ ctx.set_base_subscription(self.data_config.default_base_subscription)
240
+
241
+ # - set default on_event schedule if detected and strategy didn't set it's own schedule
242
+ if not ctx.get_event_schedule("time") and self.data_config.default_trigger_schedule:
243
+ logger.debug(f"[<y>simulator</y>] :: Setting default schedule: {self.data_config.default_trigger_schedule}")
244
+ ctx.set_event_schedule(self.data_config.default_trigger_schedule)
245
+
246
+ self.data_provider = data_provider
247
+ self.logs_writer = logs_writer
248
+ return ctx