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.
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/PKG-INFO +1 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/pyproject.toml +1 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/_version.py +2 -2
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/runner.py +1 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/tui.py +315 -100
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/connection_manager.py +7 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/ohlc.py +3 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/basics.py +5 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/interfaces.py +2 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/lookups.py +38 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/metrics.py +69 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/processing.py +2 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/composite.py +11 -9
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/base.py +4 -2
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/incremental.py +2 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/slack.py +5 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/redis_streams.py +3 -2
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/slack.py +4 -3
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/results.py +35 -23
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/factory.py +4 -7
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/runner.py +3 -1
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/.gitignore +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/LICENSE +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/README.md +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/hatch_build.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/_nb_magic.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/account.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/broker.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/data.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/iteratedstream.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/management.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/ome.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/optimization.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/sentinels.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulated_data.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulated_exchange.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/simulator.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/transfers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/backtester/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/commands.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/deploy.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/misc.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/release.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/cli/theme.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/config.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/account.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/adapters/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/adapters/polling_adapter.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/broker.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/data.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exceptions.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchange_manager.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/base.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/binance/broker.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/binance/exchange.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/bitfinex/bitfinex.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/bitfinex/bitfinex_account.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/gateio/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/gateio/gateio.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/account.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/exchanges/kraken/kraken.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/factory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/base.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/factory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/funding_rate.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/liquidation.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/open_interest.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/orderbook.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/quote.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/handlers/trade.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_config.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_manager.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/subscription_orchestrator.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/ccxt/warmup_service.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/registry.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/tardis/data.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/connectors/tardis/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/account.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/context.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/delisting.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/detectors/stale.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/errors.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/exceptions.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/helpers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/initializer.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/loggers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/market.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/subscription.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/trading.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/universe.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/mixins/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pxd +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pyi +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/series.pyx +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/utils.pyi +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/core/utils.pyx +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/cache.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/containers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/guards.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/registry.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storage.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/ccxt.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/csv.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/handy.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/multi.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/questdb.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/stub.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/storages/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/data/transformers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/base.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/composite.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/csv.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/indicator.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/inmemory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/prometheus.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/emitters/questdb.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/exporters/formatters/target_position.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/gathering/simplest.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/base.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/health/dummy.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/csv.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/factory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/inmemory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/loggers/mongo.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/composite.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/slack.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/notifications/throttler.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/stats.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/ta.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/pandaz/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/plugins/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/plugins/loader.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/_build.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/crypto-fees.ini +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/hyperliquid-spot.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/hyperliquid.f-perpetual.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance-spot.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.cm-future.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.cm-perpetual.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.um-future.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-binance.um-perpetual.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-bitfinex.f-perpetual.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken-spot.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken.f-future.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/resources/instruments/symbols-kraken.f-perpetual.json +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/state_resolvers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restarts/time_finders.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/balance.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/factory.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/interfaces.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/position.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/signal.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/state.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/restorers/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/dummy.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/state/redis.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pxd +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pyi +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/ta/indicators.pyx +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/base.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/accounts.toml.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/config.yml.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/jlive.sh.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/jpaper.sh.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/pyproject.toml.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/src/{{ strategy_name }}/__init__.py.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/src/{{ strategy_name }}/strategy.py.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/project/template.yml +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/__init__.py.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/accounts.toml.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/config.yml.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/jlive.sh.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/jpaper.sh.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/strategy.py.j2 +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/templates/simple/template.yml +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/advanced.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/composite.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/rebalancers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/riskctrl.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/trackers/sizers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/_pyxreloader.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/lookinglass.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/mpl_helpers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/charting/orderbook.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/collections.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/numba_utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/orderbook.pyi +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/hft/orderbook.pyx +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/binance.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/ccxt.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/marketdata/dukas.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/misc.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/nonce.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ntp.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/numbers_utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/orderbook.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/dashboard.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/data.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/interfaces.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/renderers/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/questdb.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/rate_limiter.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pxd +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pyi +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/ringbuffer.pyx +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/_jupyter_runner.pyt +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/accounts.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/configs.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/kernel_service.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/app.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/handlers.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/init_code.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/kernel.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/styles.tcss +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/__init__.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/command_input.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/debug_log.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/orders_table.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/positions_table.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/quotes_table.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/runner/textual/widgets/repl_output.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/slack.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/throttler.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/time.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/src/qubx/utils/websocket_manager.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/indicators/macd.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/models/macd_crossover.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/macd_crossover/src/macd_crossover/models/utils.py +0 -0
- {qubx-1.0.0.dev4 → qubx-1.0.0.dev6}/tests/strategies/obi_trader/src/obi_trader/models/obi_trader.py +0 -0
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 0, 0, '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
"""
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
491
|
-
# -
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
_e(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
f"{
|
|
500
|
-
f"{
|
|
501
|
-
f"{
|
|
502
|
-
f"{
|
|
503
|
-
f"{
|
|
504
|
-
f"{
|
|
505
|
-
f"{
|
|
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("
|
|
581
|
-
Binding("
|
|
582
|
-
Binding("
|
|
583
|
-
Binding("
|
|
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
|
-
|
|
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.
|
|
913
|
+
self._update_table()
|
|
914
|
+
self.query_one("#metrics-table", MetricsTable).focus()
|
|
780
915
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
"""
|
|
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()
|
|
910
|
-
elif isinstance(focused, DataTable):
|
|
911
|
-
focused.action_cursor_left()
|
|
1108
|
+
focused.action_cursor_parent()
|
|
912
1109
|
else:
|
|
913
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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.
|