Qubx 1.0.0.dev4__tar.gz → 1.0.0.dev6__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.
Files changed (263) hide show
  1. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/PKG-INFO +1 -1
  2. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/pyproject.toml +1 -1
  3. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/_version.py +2 -2
  4. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/runner.py +1 -1
  5. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/tui.py +315 -100
  6. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/connection_manager.py +7 -1
  7. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/ohlc.py +3 -0
  8. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/basics.py +5 -0
  9. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/interfaces.py +2 -1
  10. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/lookups.py +38 -1
  11. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/metrics.py +69 -0
  12. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/processing.py +2 -0
  13. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/composite.py +11 -9
  14. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/base.py +4 -2
  15. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/incremental.py +2 -1
  16. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/slack.py +5 -1
  17. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/redis_streams.py +3 -2
  18. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/slack.py +4 -3
  19. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/results.py +35 -23
  20. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/factory.py +4 -7
  21. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/runner.py +3 -1
  22. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/.gitignore +0 -0
  23. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/LICENSE +0 -0
  24. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/README.md +0 -0
  25. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/hatch_build.py +0 -0
  26. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/__init__.py +0 -0
  27. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/_nb_magic.py +0 -0
  28. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/__init__.py +0 -0
  29. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/account.py +0 -0
  30. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/broker.py +0 -0
  31. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/data.py +0 -0
  32. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/iteratedstream.py +0 -0
  33. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/management.py +0 -0
  34. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/ome.py +0 -0
  35. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/optimization.py +0 -0
  36. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/sentinels.py +0 -0
  37. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulated_data.py +0 -0
  38. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulated_exchange.py +0 -0
  39. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulator.py +0 -0
  40. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/transfers.py +0 -0
  41. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/utils.py +0 -0
  42. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/__init__.py +0 -0
  43. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/commands.py +0 -0
  44. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/deploy.py +0 -0
  45. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/misc.py +0 -0
  46. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/release.py +0 -0
  47. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/theme.py +0 -0
  48. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/config.py +0 -0
  49. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/__init__.py +0 -0
  50. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/__init__.py +0 -0
  51. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/account.py +0 -0
  52. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/adapters/__init__.py +0 -0
  53. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/adapters/polling_adapter.py +0 -0
  54. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/broker.py +0 -0
  55. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/data.py +0 -0
  56. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exceptions.py +0 -0
  57. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchange_manager.py +0 -0
  58. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/__init__.py +0 -0
  59. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/base.py +0 -0
  60. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/binance/broker.py +0 -0
  61. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/binance/exchange.py +0 -0
  62. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/bitfinex/bitfinex.py +0 -0
  63. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/bitfinex/bitfinex_account.py +0 -0
  64. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/gateio/__init__.py +0 -0
  65. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/gateio/gateio.py +0 -0
  66. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +0 -0
  67. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/account.py +0 -0
  68. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +0 -0
  69. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +0 -0
  70. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/kraken/kraken.py +0 -0
  71. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/factory.py +0 -0
  72. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/__init__.py +0 -0
  73. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/base.py +0 -0
  74. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/factory.py +0 -0
  75. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/funding_rate.py +0 -0
  76. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/liquidation.py +0 -0
  77. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/open_interest.py +0 -0
  78. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/orderbook.py +0 -0
  79. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/quote.py +0 -0
  80. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/trade.py +0 -0
  81. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_config.py +0 -0
  82. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_manager.py +0 -0
  83. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_orchestrator.py +0 -0
  84. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/utils.py +0 -0
  85. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/warmup_service.py +0 -0
  86. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/registry.py +0 -0
  87. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/tardis/data.py +0 -0
  88. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/tardis/utils.py +0 -0
  89. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/__init__.py +0 -0
  90. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/account.py +0 -0
  91. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/context.py +0 -0
  92. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/__init__.py +0 -0
  93. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/delisting.py +0 -0
  94. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/stale.py +0 -0
  95. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/errors.py +0 -0
  96. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/exceptions.py +0 -0
  97. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/helpers.py +0 -0
  98. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/initializer.py +0 -0
  99. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/loggers.py +0 -0
  100. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/__init__.py +0 -0
  101. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/market.py +0 -0
  102. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/subscription.py +0 -0
  103. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/trading.py +0 -0
  104. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/universe.py +0 -0
  105. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/utils.py +0 -0
  106. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pxd +0 -0
  107. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pyi +0 -0
  108. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pyx +0 -0
  109. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/utils.pyi +0 -0
  110. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/utils.pyx +0 -0
  111. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/__init__.py +0 -0
  112. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/cache.py +0 -0
  113. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/containers.py +0 -0
  114. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/guards.py +0 -0
  115. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/registry.py +0 -0
  116. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storage.py +0 -0
  117. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/ccxt.py +0 -0
  118. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/csv.py +0 -0
  119. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/handy.py +0 -0
  120. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/multi.py +0 -0
  121. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/questdb.py +0 -0
  122. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/stub.py +0 -0
  123. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/utils.py +0 -0
  124. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/transformers.py +0 -0
  125. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/__init__.py +0 -0
  126. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/base.py +0 -0
  127. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/composite.py +0 -0
  128. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/csv.py +0 -0
  129. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/indicator.py +0 -0
  130. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/inmemory.py +0 -0
  131. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/prometheus.py +0 -0
  132. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/questdb.py +0 -0
  133. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/__init__.py +0 -0
  134. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/__init__.py +0 -0
  135. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/target_position.py +0 -0
  136. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/gathering/simplest.py +0 -0
  137. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/__init__.py +0 -0
  138. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/base.py +0 -0
  139. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/dummy.py +0 -0
  140. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/__init__.py +0 -0
  141. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/csv.py +0 -0
  142. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/factory.py +0 -0
  143. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/inmemory.py +0 -0
  144. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/mongo.py +0 -0
  145. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/__init__.py +0 -0
  146. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/composite.py +0 -0
  147. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/slack.py +0 -0
  148. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/throttler.py +0 -0
  149. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/__init__.py +0 -0
  150. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/stats.py +0 -0
  151. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/ta.py +0 -0
  152. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/utils.py +0 -0
  153. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/plugins/__init__.py +0 -0
  154. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/plugins/loader.py +0 -0
  155. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/_build.py +0 -0
  156. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/crypto-fees.ini +0 -0
  157. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/hyperliquid-spot.json +0 -0
  158. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/hyperliquid.f-perpetual.json +0 -0
  159. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance-spot.json +0 -0
  160. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.cm-future.json +0 -0
  161. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.cm-perpetual.json +0 -0
  162. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.um-future.json +0 -0
  163. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.um-perpetual.json +0 -0
  164. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-bitfinex.f-perpetual.json +0 -0
  165. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken-spot.json +0 -0
  166. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken.f-future.json +0 -0
  167. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken.f-perpetual.json +0 -0
  168. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/__init__.py +0 -0
  169. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/state_resolvers.py +0 -0
  170. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/time_finders.py +0 -0
  171. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/__init__.py +0 -0
  172. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/balance.py +0 -0
  173. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/factory.py +0 -0
  174. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/interfaces.py +0 -0
  175. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/position.py +0 -0
  176. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/signal.py +0 -0
  177. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/state.py +0 -0
  178. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/utils.py +0 -0
  179. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/__init__.py +0 -0
  180. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/dummy.py +0 -0
  181. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/redis.py +0 -0
  182. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/__init__.py +0 -0
  183. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pxd +0 -0
  184. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pyi +0 -0
  185. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pyx +0 -0
  186. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/__init__.py +0 -0
  187. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/base.py +0 -0
  188. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/accounts.toml.j2 +0 -0
  189. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/config.yml.j2 +0 -0
  190. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/jlive.sh.j2 +0 -0
  191. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/jpaper.sh.j2 +0 -0
  192. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/pyproject.toml.j2 +0 -0
  193. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/src/{{ strategy_name }}/__init__.py.j2 +0 -0
  194. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/src/{{ strategy_name }}/strategy.py.j2 +0 -0
  195. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/template.yml +0 -0
  196. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/__init__.py.j2 +0 -0
  197. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/accounts.toml.j2 +0 -0
  198. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/config.yml.j2 +0 -0
  199. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/jlive.sh.j2 +0 -0
  200. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/jpaper.sh.j2 +0 -0
  201. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/strategy.py.j2 +0 -0
  202. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/template.yml +0 -0
  203. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/__init__.py +0 -0
  204. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/advanced.py +0 -0
  205. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/composite.py +0 -0
  206. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/rebalancers.py +0 -0
  207. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/riskctrl.py +0 -0
  208. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/sizers.py +0 -0
  209. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/__init__.py +0 -0
  210. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/_pyxreloader.py +0 -0
  211. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/lookinglass.py +0 -0
  212. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  213. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/orderbook.py +0 -0
  214. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/collections.py +0 -0
  215. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/__init__.py +0 -0
  216. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/numba_utils.py +0 -0
  217. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/orderbook.pyi +0 -0
  218. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/orderbook.pyx +0 -0
  219. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/binance.py +0 -0
  220. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/ccxt.py +0 -0
  221. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/dukas.py +0 -0
  222. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/misc.py +0 -0
  223. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/nonce.py +0 -0
  224. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ntp.py +0 -0
  225. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/numbers_utils.py +0 -0
  226. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/orderbook.py +0 -0
  227. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/__init__.py +0 -0
  228. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/dashboard.py +0 -0
  229. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/data.py +0 -0
  230. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/interfaces.py +0 -0
  231. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/renderers/__init__.py +0 -0
  232. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
  233. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/questdb.py +0 -0
  234. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/rate_limiter.py +0 -0
  235. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pxd +0 -0
  236. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pyi +0 -0
  237. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pyx +0 -0
  238. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/__init__.py +0 -0
  239. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/_jupyter_runner.pyt +0 -0
  240. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/accounts.py +0 -0
  241. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/configs.py +0 -0
  242. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/kernel_service.py +0 -0
  243. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/__init__.py +0 -0
  244. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/app.py +0 -0
  245. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/handlers.py +0 -0
  246. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/init_code.py +0 -0
  247. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/kernel.py +0 -0
  248. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/styles.tcss +0 -0
  249. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/__init__.py +0 -0
  250. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/command_input.py +0 -0
  251. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/debug_log.py +0 -0
  252. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/orders_table.py +0 -0
  253. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/positions_table.py +0 -0
  254. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/quotes_table.py +0 -0
  255. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/repl_output.py +0 -0
  256. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/slack.py +0 -0
  257. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/throttler.py +0 -0
  258. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/time.py +0 -0
  259. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/websocket_manager.py +0 -0
  260. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/indicators/macd.py +0 -0
  261. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/models/macd_crossover.py +0 -0
  262. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/models/utils.py +0 -0
  263. {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/obi_trader/src/obi_trader/models/obi_trader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Qubx
3
- Version: 1.0.0.dev4
3
+ Version: 1.0.0.dev6
4
4
  Summary: Qubx - Quantitative Trading Framework
5
5
  Project-URL: homepage, https://xlydian.com
6
6
  Project-URL: repository, https://github.com/xLydianSoftware/Qubx
@@ -180,5 +180,5 @@ dev = [
180
180
  "mongomock>=4.3.0,<5",
181
181
  "pytest-textual-snapshot>=1.1.0,<2",
182
182
  "git-cliff>=2.0.0",
183
- "duckdb>=1.0.0"
183
+ "duckdb>=1.0.0",
184
184
  ]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.0.0.dev4'
32
- __version_tuple__ = version_tuple = (1, 0, 0, 'dev4')
31
+ __version__ = version = '1.0.0.dev6'
32
+ __version_tuple__ = version_tuple = (1, 0, 0, 'dev6')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -229,7 +229,7 @@ class SimulationRunner:
229
229
  assert isinstance(_data_provider, SimulatedDataProvider)
230
230
 
231
231
  if not is_hist:
232
- if t >= (_next_exp_time := self.scheduler.next_expected_event_time()):
232
+ while t >= (_next_exp_time := self.scheduler.next_expected_event_time()):
233
233
  # - we use exact event's time
234
234
  self.time_provider.set_time(_next_exp_time)
235
235
  self.scheduler.check_and_run_tasks()
@@ -1,10 +1,12 @@
1
1
  import json
2
+ import re
2
3
  from typing import Any
3
4
 
4
5
  import pandas as pd
5
6
  from rich.console import RenderableType
6
7
  from rich.table import Table
7
- from textual import on
8
+ from rich.text import Text as RichText
9
+ from textual import on, work
8
10
  from textual.app import App, ComposeResult
9
11
  from textual.binding import Binding
10
12
  from textual.containers import Horizontal, Vertical
@@ -23,6 +25,56 @@ from qubx import logger
23
25
  from qubx.backtester import BacktestStorage
24
26
  from qubx.cli.theme import QUBX_DARK
25
27
  from qubx.core.metrics import TradingSessionResult, get_cum_pnl
28
+ from qubx.utils.results import SimulationResultsSaver
29
+
30
+ # - matches YYYYMMDD_HHMMSS timestamp directory names produced by SimulationResultsSaver
31
+ _TS_DIR_RE = re.compile(r"^\d{8}_\d{6}$")
32
+
33
+
34
+ def _fv(v: Any, default: float = 0.0) -> float:
35
+ """
36
+ Safely coerce a metadata value to float, substituting ``default`` for
37
+ ``None``, ``NaN``, and anything that cannot be parsed as a number.
38
+
39
+ ``float(nan) or 0`` does NOT work — NaN is truthy in Python, so the
40
+ ``or 0`` fallback is never reached. This helper handles that correctly.
41
+ """
42
+ import math
43
+
44
+ try:
45
+ f = float(v)
46
+ return default if math.isnan(f) else f
47
+ except (TypeError, ValueError):
48
+ return default
49
+
50
+
51
+ def _fmt_variation_params(result: dict[str, Any]) -> str:
52
+ """
53
+ Format variation parameters for compact table display.
54
+
55
+ Resolution order:
56
+ 1. ``variation_params`` JSON field → ``fp=6, sp=34``
57
+ 2. ``[bracketed]`` content inside ``name`` → ``xxx_(fp=6,sp=34)``
58
+ 3. ``variation_id`` as last resort → ``var_003``
59
+ """
60
+ raw = str(result.get("variation_params") or "").strip()
61
+ if raw:
62
+ try:
63
+ params = json.loads(raw)
64
+ if isinstance(params, dict) and params:
65
+ return _e(", ".join(f"{k}={v}" for k, v in params.items()))
66
+ except Exception:
67
+ pass
68
+
69
+ # - fallback: extract the [bracketed] part from the name field
70
+ # - e.g. "strat_fff_ggg.[xxx_(fp=6,sp=34)]" → "xxx_(fp=6,sp=34)"
71
+ name = str(result.get("name") or "")
72
+ m = re.search(r"\[([^\]]+)\]", name)
73
+ if m:
74
+ return _e(m.group(1))
75
+
76
+ # - last resort: raw variation_id
77
+ return _e(str(result.get("variation_id") or ""))
26
78
 
27
79
 
28
80
  def _e(text: object) -> str:
@@ -375,6 +427,8 @@ class BacktestTreeNode:
375
427
  self.children: list[BacktestTreeNode] = []
376
428
  self.backtest_results: list[dict[str, Any]] = []
377
429
  self.is_leaf = False
430
+ # - backtest_ids pending lazy metadata load (populated at tree build, cleared after first load)
431
+ self.pending_ids: list[str] = []
378
432
 
379
433
  def add_child(self, child: "BacktestTreeNode"):
380
434
  self.children.append(child)
@@ -390,59 +444,119 @@ class BacktestResultsTree(Tree[BacktestTreeNode]):
390
444
  def __init__(self, root_path: str):
391
445
  self.root_path = root_path
392
446
  self.storage = BacktestStorage(root_path)
447
+ self._root_data = BacktestTreeNode("Backtest Results", root_path, self.storage)
448
+ super().__init__(self._root_data.name, data=self._root_data)
393
449
 
394
- root_node = BacktestTreeNode("Backtest Results", root_path, self.storage)
395
- self._build_tree_structure(root_node)
396
-
397
- super().__init__(root_node.name, data=root_node)
398
- self._populate_tree(self.root, root_node)
450
+ def on_mount(self) -> None:
451
+ # - show placeholder immediately so user sees the tree is loading
452
+ self.root.add_leaf("⏳ Loading…")
453
+ self._load_data()
454
+
455
+ @work(thread=True)
456
+ def _load_data(self) -> None:
457
+ # - runs in background thread — does the expensive storage.search()
458
+ self._build_tree_structure(self._root_data)
459
+ # - marshal back to the UI thread to update the widget
460
+ self.app.call_from_thread(self._finish_loading)
461
+
462
+ def _finish_loading(self) -> None:
463
+ # - called on the UI thread once background load completes
464
+ self.root.remove_children()
465
+ self._populate_tree(self.root, self._root_data)
466
+ self.root.expand()
399
467
 
400
468
  def _build_tree_structure(self, root_node: BacktestTreeNode):
401
- """Build tree structure from BacktestStorage search results."""
402
- df = self.storage.search()
403
- if df.empty:
469
+ """
470
+ Build 2-level tree structure by listing file paths only — zero parquet reads.
471
+
472
+ Uses DuckDB glob() TABLE function to enumerate filesystem/S3 entries
473
+ without reading any parquet data. Metadata loads lazily on first leaf node
474
+ selection (see BacktestBrowserApp._load_node_metadata).
475
+
476
+ Path layout (new format)::
477
+
478
+ {base}/{ShortClass}/{config-name}/{YYYYMMDD_HHMMSS}/_metadata.parquet
479
+ {base}/{ShortClass}/{config-name}/{YYYYMMDD_HHMMSS}/var_XXX/_metadata.parquet (old var)
480
+ {base}/{ShortClass}/{config-name}/{YYYYMMDD_HHMMSS}/_metadata.parquet (new var, N rows)
481
+
482
+ Tree structure produced::
483
+
484
+ Root
485
+ ├── ShortClass (parent node, expandable)
486
+ │ ├── config-name-A (N runs) ← leaf, lazy-loads on click
487
+ │ └── config-name-B (M runs) ← leaf, lazy-loads on click
488
+ └── AnotherClass
489
+ └── another-config (K runs)
490
+
491
+ Variation sets are deduplicated to a single timestamp-level entry.
492
+ Falls back to flat display when paths have fewer than 2 components
493
+ before the timestamp (e.g. old-format backtests).
494
+ """
495
+ glob_pat = self.storage._glob(SimulationResultsSaver.METADATA_FILE)
496
+ try:
497
+ # - glob() is a DuckDB TABLE function: zero parquet I/O, just path listing
498
+ paths_df = self.storage._conn.execute(f"SELECT file FROM glob('{glob_pat}')").df()
499
+ except Exception as e:
500
+ logger.warning(f"Failed to list backtest paths: {e}")
501
+ return
502
+
503
+ if paths_df.empty:
404
504
  return
405
505
 
406
- # - deduplicate: glob may match _metadata.parquet at both the set level and
407
- # inside each var_XXX/ subdir, producing duplicate rows per backtest_id
408
- df = df.drop_duplicates(subset=["backtest_id"])
409
-
410
- # - separate regular runs and variations
411
- is_var = df["is_variation"].fillna(False)
412
- regular = df[~is_var]
413
- variations = df[is_var]
414
-
415
- # - group regular results by short strategy class name
416
- strategy_groups: dict[str, list[dict]] = {}
417
- for _, row in regular.iterrows():
418
- row_dict = row.to_dict()
419
- # - for regular runs load_id == backtest_id
420
- row_dict["load_id"] = row_dict["backtest_id"]
421
- cls = str(row_dict.get("strategy_class", "Unknown")).split(".")[-1]
422
- strategy_groups.setdefault(cls, []).append(row_dict)
423
-
424
- for strategy_name, results in strategy_groups.items():
425
- strategy_node = BacktestTreeNode(f"{strategy_name} ({len(results)} results)", strategy_name, self.storage)
426
- root_node.add_child(strategy_node)
427
- for result in results:
428
- strategy_node.add_backtest(result)
429
-
430
- # - group variations by variation_name (the parent run dir, e.g. "strat/Cls/20240301_120000")
431
- # - NOTE: backtest_id for variation rows already contains the var_XXX suffix,
432
- # so load_id == backtest_id directly (no extra appending needed)
433
- var_groups: dict[str, list[dict]] = {}
434
- for _, row in variations.iterrows():
435
- row_dict = row.to_dict()
436
- row_dict["load_id"] = row_dict["backtest_id"]
437
- parent_key = str(row_dict.get("variation_name", row_dict.get("backtest_id", "")))
438
- var_groups.setdefault(parent_key, []).append(row_dict)
439
-
440
- for parent_key, var_list in var_groups.items():
441
- name = var_list[0].get("name", parent_key).split(".")[0]
442
- var_node = BacktestTreeNode(f"📊 {name} ({len(var_list)} variations)", name, self.storage)
443
- root_node.add_child(var_node)
444
- for variation in var_list:
445
- var_node.add_backtest(variation)
506
+ base = self.storage.base_path # - always ends with /
507
+
508
+ # - 2-level grouping: class_key → {config_name → set[run_id]}
509
+ groups: dict[str, dict[str, set[str]]] = {}
510
+
511
+ for file_path in paths_df["file"]:
512
+ rel = str(file_path).removeprefix(base).strip("/")
513
+ parts = rel.split("/")
514
+
515
+ # - find the rightmost YYYYMMDD_HHMMSS component this is the run root
516
+ ts_idx = next(
517
+ (i for i in range(len(parts) - 1, -1, -1) if _TS_DIR_RE.match(parts[i])),
518
+ None,
519
+ )
520
+ if ts_idx is None:
521
+ continue
522
+
523
+ # - run_id = up to and including the timestamp dir (deduplicates var_XXX entries)
524
+ run_id = "/".join(parts[: ts_idx + 1])
525
+
526
+ if ts_idx >= 2:
527
+ # - new format: ...prefix/ShortClass/config-name/timestamp
528
+ # - config_name is immediately before the timestamp dir
529
+ config_name = parts[ts_idx - 1]
530
+ # - class_key is everything before config_name (may include extra path prefix)
531
+ class_key = "/".join(parts[: ts_idx - 1])
532
+ elif ts_idx == 1:
533
+ # - only one component before timestamp — treat as flat (no class prefix)
534
+ config_name = parts[0]
535
+ class_key = ""
536
+ else:
537
+ # - timestamp at root level — shouldn't happen, skip
538
+ continue
539
+
540
+ groups.setdefault(class_key, {}).setdefault(config_name, set()).add(run_id)
541
+
542
+ for class_key, config_groups in sorted(groups.items()):
543
+ # - display name for the class node = last component of class_key (the ShortClass)
544
+ class_display = class_key.split("/")[-1] if class_key else "root"
545
+ total_runs = sum(len(run_ids) for run_ids in config_groups.values())
546
+
547
+ class_node = BacktestTreeNode(f"{class_display} ({total_runs})", class_key, self.storage)
548
+ # - class nodes are not leaves — they expand to show config names
549
+ class_node.is_leaf = False
550
+
551
+ for config_name, run_ids in sorted(config_groups.items()):
552
+ leaf_path = f"{class_key}/{config_name}" if class_key else config_name
553
+ leaf = BacktestTreeNode(f"{config_name} ({len(run_ids)})", leaf_path, self.storage)
554
+ leaf.is_leaf = True
555
+ # - newest-first; timestamp dirs are zero-padded so lexicographic sort works
556
+ leaf.pending_ids = sorted(run_ids, reverse=True)
557
+ class_node.add_child(leaf)
558
+
559
+ root_node.add_child(class_node)
446
560
 
447
561
  def _populate_tree(self, tree_node, data_node: BacktestTreeNode):
448
562
  """Populate the textual tree with data nodes"""
@@ -468,11 +582,12 @@ class MetricsTable(DataTable):
468
582
 
469
583
  self.clear(columns=True)
470
584
 
471
- columns = [
472
- "Name",
473
- "Strategy",
474
- "Created",
475
- "Author",
585
+ # - detect whether this result set contains variation rows;
586
+ # - if so, prepend a Params column instead of Name/Strategy
587
+ # - (Name and Strategy are already visible in the left tree)
588
+ has_variations = any(r.get("is_variation") for r in results_data)
589
+
590
+ columns: list[str] = [
476
591
  "Gain",
477
592
  "CAGR",
478
593
  "Sharpe",
@@ -481,29 +596,38 @@ class MetricsTable(DataTable):
481
596
  "MDD USD",
482
597
  "Fees",
483
598
  "Executions",
599
+ "Created",
600
+ "Author",
484
601
  ]
602
+ if has_variations:
603
+ columns.append("Params")
485
604
 
486
605
  for col in columns:
487
606
  self.add_column(col, key=col.lower().replace(" ", "_"))
488
607
 
489
608
  for result in results_data:
490
- # - metrics are flat columns in BacktestStorage rows (not nested under 'performance')
491
- # - user-data fields (name, strategy_class, author) must be _e()'d:
492
- # variation names contain [...] which Textual parses as markup tags
493
- row_data = [
494
- _e(str(result.get("name", result.get("backtest_id", "Unknown")))),
495
- _e(str(result.get("strategy_class", "")).split(".")[-1]),
496
- pd.Timestamp(result.get("creation_time", "")).strftime("%Y-%m-%d %H:%M"),
497
- _e(str(result.get("author", ""))),
498
- f"{float(result.get('gain') or 0):.3f}",
499
- f"{float(result.get('cagr') or 0):.3f}",
500
- f"{float(result.get('sharpe') or 0):.3f}",
501
- f"{float(result.get('qr') or 0):.3f}",
502
- f"{float(result.get('mdd_pct') or 0):.3f}",
503
- f"{float(result.get('mdd_usd') or 0):.3f}",
504
- f"{float(result.get('fees') or 0):.3f}",
505
- f"{float(result.get('execs') or 0):.0f}",
609
+ gain_val = _fv(result.get("gain"))
610
+ # - negative gain dim red row; positive → default style
611
+ row_style = "red" if gain_val < 0 else ""
612
+
613
+ def _c(s: str) -> RichText:
614
+ # - RichText doesn't parse markup so no _e() escaping needed
615
+ return RichText(s, style=row_style)
616
+
617
+ row_data: list[RichText] = [
618
+ _c(f"{gain_val:.3f}"),
619
+ _c(f"{_fv(result.get('cagr')):.3f}"),
620
+ _c(f"{_fv(result.get('sharpe')):.3f}"),
621
+ _c(f"{_fv(result.get('qr')):.3f}"),
622
+ _c(f"{_fv(result.get('mdd_pct')):.3f}"),
623
+ _c(f"{_fv(result.get('mdd_usd')):.3f}"),
624
+ _c(f"{_fv(result.get('fees')):.3f}"),
625
+ _c(f"{_fv(result.get('execs')):.0f}"),
626
+ _c(pd.Timestamp(result.get("creation_time", "")).strftime("%Y-%m-%d %H:%M")),
627
+ _c(str(result.get("author", ""))),
506
628
  ]
629
+ if has_variations:
630
+ row_data.append(_c(_fmt_variation_params(result)))
507
631
  # - row key is load_id so tearsheet loading doesn't need a lookup by name
508
632
  self.add_row(*row_data, key=result.get("load_id", result.get("backtest_id", "")))
509
633
 
@@ -577,10 +701,12 @@ class BacktestBrowserApp(App):
577
701
  BINDINGS = [
578
702
  Binding("j", "vim_down", "Down", show=False),
579
703
  Binding("k", "vim_up", "Up", show=False),
580
- Binding("h", "vim_left", "Collapse/Left", show=False),
581
- Binding("l", "vim_right", "Expand/Right", show=False),
582
- Binding("G", "vim_end", "End", show=False),
583
- Binding("g", "vim_home", "Home", show=False),
704
+ Binding("g", "vim_home", "Top", show=False),
705
+ Binding("h", "vim_left", "◀ Tree", show=True),
706
+ Binding("l", "vim_right", "▶ Table", show=True),
707
+ Binding("S", "sort_by_key_sharpe", "Sort Sharpe", show=True),
708
+ Binding("C", "sort_by_key_cagr", "Sort CAGR", show=True),
709
+ Binding("G", "vim_end", "Sort Gain", show=True), # - Tree: scroll end; Table: sort gain
584
710
  ]
585
711
 
586
712
  CSS = """
@@ -737,6 +863,8 @@ class BacktestBrowserApp(App):
737
863
  self.current_results: list[dict[str, Any]] = []
738
864
  self.current_storage: BacktestStorage | None = None
739
865
  self._selected_result: dict[str, Any] | None = None
866
+ # - tracks which node is currently being loaded to discard stale callbacks
867
+ self._loading_node: BacktestTreeNode | None = None
740
868
 
741
869
  def compose(self) -> ComposeResult:
742
870
  """Compose the TUI layout"""
@@ -772,16 +900,26 @@ class BacktestBrowserApp(App):
772
900
 
773
901
  @on(Tree.NodeSelected)
774
902
  def handle_tree_selection(self, event: Tree.NodeSelected) -> None:
775
- """Handle tree node selection"""
776
- if event.node.data:
777
- node_data = event.node.data
903
+ """Handle tree node selection — show cached results or trigger lazy metadata load."""
904
+ if not event.node.data:
905
+ return
906
+
907
+ node_data: BacktestTreeNode = event.node.data
908
+ self.current_storage = node_data.storage
909
+
910
+ if node_data.backtest_results:
911
+ # - already loaded: show immediately (cached after first click)
778
912
  self.current_results = node_data.backtest_results
779
- self.current_storage = node_data.storage
913
+ self._update_table()
914
+ self.query_one("#metrics-table", MetricsTable).focus()
780
915
 
781
- if self.current_results:
782
- self._update_table()
783
- # - move focus to the table so arrow keys / vim keys work immediately
784
- self.query_one("#metrics-table", MetricsTable).focus()
916
+ elif node_data.pending_ids:
917
+ # - first selection: show spinner on the table then kick off background load
918
+ self._loading_node = node_data
919
+ table = self.query_one("#metrics-table", MetricsTable)
920
+ table.clear(columns=True)
921
+ table.loading = True # - built-in Textual spinner overlay on the table widget
922
+ self._load_node_metadata(node_data)
785
923
 
786
924
  @on(DataTable.RowSelected)
787
925
  async def handle_row_selection(self, event: DataTable.RowSelected) -> None:
@@ -821,6 +959,66 @@ class BacktestBrowserApp(App):
821
959
  logger.error(f"Failed to build preview: {e}")
822
960
  await preview.update(f"*Preview error: {e}*")
823
961
 
962
+ @work(thread=True)
963
+ def _load_node_metadata(self, node_data: BacktestTreeNode) -> None:
964
+ """
965
+ Background worker: read parquet metadata for this group only.
966
+
967
+ Uses a group-scoped glob (ShortClass/name/**/_metadata.parquet) so DuckDB
968
+ scans only this config's files — never the full storage tree.
969
+
970
+ Handles both layouts transparently:
971
+ - Regular run: timestamp/_metadata.parquet (1 row each)
972
+ - Old variation: timestamp/var_XXX/_metadata.parquet (1 row per var)
973
+ - New variation: timestamp/_metadata.parquet (N rows)
974
+ """
975
+ storage = node_data.storage
976
+ if not storage:
977
+ return
978
+
979
+ # - targeted glob scoped to this group's subdirectory only
980
+ group_glob = f"{storage.base_path}{node_data.path}/**/{SimulationResultsSaver.METADATA_FILE}"
981
+
982
+ try:
983
+ df = storage._conn.execute(
984
+ f"SELECT * FROM read_parquet('{group_glob}', union_by_name=true) ORDER BY creation_time DESC"
985
+ ).df()
986
+ except Exception as e:
987
+ logger.warning(f"Failed to load metadata for '{node_data.path}': {e}")
988
+ df = pd.DataFrame()
989
+
990
+ results: list[dict[str, Any]] = []
991
+ if not df.empty and "backtest_id" in df.columns:
992
+ df = df.drop_duplicates(subset=["backtest_id"])
993
+ for _, row in df.iterrows():
994
+ row_dict: dict[str, Any] = {str(k): v for k, v in row.items()}
995
+ row_dict["load_id"] = row_dict["backtest_id"]
996
+ results.append(row_dict)
997
+
998
+ node_data.backtest_results = results
999
+ node_data.pending_ids = [] # - free memory; signals "already loaded"
1000
+ self.app.call_from_thread(self._on_node_metadata_loaded, node_data)
1001
+
1002
+ def _on_node_metadata_loaded(self, node_data: BacktestTreeNode) -> None:
1003
+ """UI-thread callback: update table after metadata load completes."""
1004
+ # - discard if user already navigated to a different node
1005
+ if node_data is not self._loading_node:
1006
+ return
1007
+ self._loading_node = None
1008
+
1009
+ table = self.query_one("#metrics-table", MetricsTable)
1010
+ table.loading = False # - dismiss spinner regardless of outcome
1011
+
1012
+ self.current_results = node_data.backtest_results
1013
+ if self.current_results:
1014
+ self._update_table()
1015
+ table.focus()
1016
+ else:
1017
+ self.call_later(
1018
+ self.query_one("#preview-pane", Markdown).update,
1019
+ "*No completed results found for this group.*",
1020
+ )
1021
+
824
1022
  @on(Button.Pressed, "#sort-sharpe")
825
1023
  def sort_by_sharpe(self) -> None:
826
1024
  """Sort results by Sharpe ratio"""
@@ -864,16 +1062,17 @@ class BacktestBrowserApp(App):
864
1062
  logger.error(f"Failed to refresh data: {e}")
865
1063
 
866
1064
  def _sort_table(self, metric: str) -> None:
867
- """Sort table by specified metric"""
1065
+ """
1066
+ Sort current results by metric (descending) and repopulate the table.
1067
+
1068
+ Always sorts the underlying dict list rather than using Textual's
1069
+ built-in DataTable.sort() — table cells are formatted strings so the
1070
+ built-in sort would compare lexicographically and cannot handle NaN.
1071
+ ``_fv`` substitutes 0.0 for NaN / None so those rows sink to the bottom.
1072
+ """
868
1073
  if self.current_results:
869
- table = self.query_one("#metrics-table", MetricsTable)
870
- try:
871
- table.sort(metric, reverse=True)
872
- except Exception as e:
873
- logger.warning(f"Failed to sort by {metric}: {e}")
874
- # - fallback: sort results dict and repopulate
875
- self.current_results.sort(key=lambda x: float(x.get(metric) or 0), reverse=True)
876
- self._update_table()
1074
+ self.current_results.sort(key=lambda x: _fv(x.get(metric)), reverse=True)
1075
+ self._update_table()
877
1076
 
878
1077
  def _update_table(self) -> None:
879
1078
  """Update the metrics table"""
@@ -901,36 +1100,42 @@ class BacktestBrowserApp(App):
901
1100
  focused.scroll_up(animate=False)
902
1101
 
903
1102
  def action_vim_left(self) -> None:
904
- """Collapse tree node / move to parent; scroll left elsewhere."""
1103
+ """In tree: collapse / go to parent. In right pane: move focus back to tree."""
905
1104
  focused = self.focused
906
1105
  if focused is None:
907
1106
  return
908
1107
  if isinstance(focused, Tree):
909
- focused.action_cursor_parent() # - move to parent node (vim: h = go up/left)
910
- elif isinstance(focused, DataTable):
911
- focused.action_cursor_left()
1108
+ focused.action_cursor_parent()
912
1109
  else:
913
- focused.scroll_left(animate=False)
1110
+ # - h from DataTable, Markdown preview, or any other right-pane widget
1111
+ self.query_one(BacktestResultsTree).focus()
914
1112
 
915
1113
  def action_vim_right(self) -> None:
916
- """Expand/toggle tree node; scroll right elsewhere."""
1114
+ """Tree: expand node (l). Right pane: cursor right / scroll right."""
917
1115
  focused = self.focused
918
1116
  if focused is None:
919
1117
  return
920
1118
  if isinstance(focused, Tree):
921
- focused.action_toggle_node() # - expand/collapse node (vim: l = go into)
1119
+ # - if the node is a leaf (has results / pending), move focus to the table;
1120
+ # - otherwise expand/collapse the class-level parent node
1121
+ node = focused.cursor_node
1122
+ if node and node.data and (node.data.pending_ids or node.data.backtest_results):
1123
+ self.query_one("#metrics-table", MetricsTable).focus()
1124
+ else:
1125
+ focused.action_toggle_node()
922
1126
  elif isinstance(focused, DataTable):
923
1127
  focused.action_cursor_right()
924
1128
  else:
925
1129
  focused.scroll_right(animate=False)
926
1130
 
927
1131
  def action_vim_end(self) -> None:
928
- """Jump to last row / scroll to end (G)."""
1132
+ """Tree: scroll to end (G). Table: sort by gain (G = Shift+G)."""
929
1133
  focused = self.focused
930
1134
  if focused is None:
931
1135
  return
932
1136
  if isinstance(focused, DataTable):
933
- focused.action_scroll_bottom()
1137
+ # - G in right pane → sort by gain (more useful than scroll-to-bottom)
1138
+ self._sort_table("gain")
934
1139
  elif isinstance(focused, Tree):
935
1140
  focused.action_scroll_end()
936
1141
  else:
@@ -948,6 +1153,16 @@ class BacktestBrowserApp(App):
948
1153
  else:
949
1154
  focused.scroll_home(animate=False)
950
1155
 
1156
+ def action_sort_by_key_sharpe(self) -> None:
1157
+ """Sort table by Sharpe ratio (S — right pane only)."""
1158
+ if not isinstance(self.focused, Tree):
1159
+ self._sort_table("sharpe")
1160
+
1161
+ def action_sort_by_key_cagr(self) -> None:
1162
+ """Sort table by CAGR (C — right pane only)."""
1163
+ if not isinstance(self.focused, Tree):
1164
+ self._sort_table("cagr")
1165
+
951
1166
  # ── tearsheet ────────────────────────────────────────────────────────────
952
1167
 
953
1168
  def _generate_html_tearsheet(self, result_info: dict[str, Any]) -> None:
@@ -12,7 +12,7 @@ from asyncio.exceptions import CancelledError
12
12
  from collections import defaultdict
13
13
  from typing import Awaitable, Callable
14
14
 
15
- from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
15
+ from ccxt import BadSymbol, ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
16
16
  from ccxt.async_support.base.ws.client import Client as _WsClient
17
17
  from ccxt.pro import Exchange
18
18
 
@@ -133,6 +133,12 @@ class ConnectionManager:
133
133
  # Connection closed by us, stop gracefully
134
134
  logger.info(f"<yellow>{self._exchange_id}</yellow> {stream_name} listening has been stopped")
135
135
  break
136
+ except BadSymbol as e:
137
+ # Bad symbol is a permanent error - retrying will never succeed
138
+ logger.error(
139
+ f"<yellow>{self._exchange_id}</yellow> BadSymbol :: {stream_name} : {e} - stopping stream"
140
+ )
141
+ break
136
142
  except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
137
143
  # Network/exchange errors - retry after short delay
138
144
  logger.error(
@@ -8,6 +8,7 @@ import asyncio
8
8
  from typing import Set
9
9
 
10
10
  import pandas as pd
11
+ from ccxt import BadSymbol
11
12
 
12
13
  from qubx import logger
13
14
  from qubx.core.basics import CtrlChannel, DataType, Instrument
@@ -274,6 +275,8 @@ class OhlcDataHandler(BaseDataTypeHandler):
274
275
  # Use private processing method to avoid duplication
275
276
  self._process_ohlcv_bar(oh, inst, sub_type, channel, timeframe, ohlcv_data)
276
277
 
278
+ except BadSymbol:
279
+ raise # Permanent error, let ConnectionManager handle it
277
280
  except Exception as e:
278
281
  logger.error(
279
282
  f"<yellow>{exchange_id}</yellow> Error in individual OHLCV subscription for {inst.symbol}: {e}"
@@ -1455,3 +1455,8 @@ class InstrumentsLookup:
1455
1455
 
1456
1456
  class FeesLookup:
1457
1457
  def find_fees(self, exchange: str, spec: str | None) -> TransactionCostsCalculator: ...
1458
+
1459
+
1460
+ class AccountsLookup:
1461
+ def get_credentials(self, exchange: str): ...
1462
+ def get_settings(self, exchange: str): ...
@@ -148,7 +148,8 @@ class ITradeDataExport:
148
148
  pass
149
149
 
150
150
  def export_position_changes(
151
- self, time: dt_64, instrument: Instrument, price: float, account: "IAccountViewer"
151
+ self, time: dt_64, instrument: Instrument, price: float, account: "IAccountViewer",
152
+ metadata: dict[str, Any] | None = None,
152
153
  ) -> None:
153
154
  """
154
155
  Export position changes to an external system.