Qubx 0.6.84__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.87__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/management.py +3 -2
- qubx/backtester/runner.py +1 -1
- qubx/cli/commands.py +46 -1
- qubx/connectors/ccxt/account.py +1 -0
- qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +17 -9
- qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +1 -1
- qubx/connectors/ccxt/handlers/funding_rate.py +3 -3
- qubx/connectors/ccxt/handlers/orderbook.py +8 -6
- qubx/connectors/ccxt/handlers/trade.py +116 -20
- qubx/connectors/ccxt/reader.py +3 -2
- qubx/core/helpers.py +9 -3
- qubx/core/interfaces.py +7 -6
- qubx/core/metrics.py +74 -14
- qubx/core/mixins/subscription.py +7 -1
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +3 -2
- qubx/core/series.pyi +3 -2
- qubx/core/series.pyx +30 -5
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/emitters/base.py +23 -14
- qubx/emitters/composite.py +13 -0
- qubx/emitters/csv.py +2 -1
- qubx/emitters/indicator.py +4 -2
- qubx/emitters/inmemory.py +5 -4
- qubx/emitters/prometheus.py +2 -2
- qubx/emitters/questdb.py +16 -10
- qubx/exporters/formatters/__init__.py +8 -1
- qubx/exporters/formatters/target_position.py +78 -0
- qubx/health/base.py +7 -10
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/runner/configs.py +120 -17
- qubx/utils/runner/runner.py +6 -6
- {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/METADATA +1 -1
- {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/RECORD +37 -36
- {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/WHEEL +0 -0
- {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/entry_points.txt +0 -0
- {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/licenses/LICENSE +0 -0
qubx/backtester/management.py
CHANGED
|
@@ -327,10 +327,11 @@ class BacktestsResultsManager:
|
|
|
327
327
|
if not as_table:
|
|
328
328
|
print(_s)
|
|
329
329
|
|
|
330
|
+
dd_column = "max_dd_pct" if "max_dd_pct" in metrics else "mdd_pct"
|
|
330
331
|
if with_metrics:
|
|
331
332
|
_m_repr = (
|
|
332
333
|
pd.DataFrame.from_dict(metrics, orient="index")
|
|
333
|
-
.T[["gain", "cagr", "sharpe", "qr",
|
|
334
|
+
.T[["gain", "cagr", "sharpe", "qr", dd_column, "mdd_usd", "fees", "execs"]]
|
|
334
335
|
.astype(float)
|
|
335
336
|
)
|
|
336
337
|
_m_repr = _m_repr.round(3).to_string(index=False)
|
|
@@ -345,7 +346,7 @@ class BacktestsResultsManager:
|
|
|
345
346
|
metrics = {
|
|
346
347
|
m: round(v, 3)
|
|
347
348
|
for m, v in metrics.items()
|
|
348
|
-
if m in ["gain", "cagr", "sharpe", "qr",
|
|
349
|
+
if m in ["gain", "cagr", "sharpe", "qr", dd_column, "mdd_usd", "fees", "execs"]
|
|
349
350
|
}
|
|
350
351
|
_t_rep.append(
|
|
351
352
|
{"Index": info.get("idx", ""), "Strategy": name}
|
qubx/backtester/runner.py
CHANGED
qubx/cli/commands.py
CHANGED
|
@@ -137,6 +137,51 @@ def ls(directory: str):
|
|
|
137
137
|
ls_strats(directory)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
@main.command()
|
|
141
|
+
@click.argument("config-file", type=Path, required=True)
|
|
142
|
+
@click.option(
|
|
143
|
+
"--no-check-imports",
|
|
144
|
+
is_flag=True,
|
|
145
|
+
default=False,
|
|
146
|
+
help="Skip checking if strategy class can be imported",
|
|
147
|
+
show_default=True,
|
|
148
|
+
)
|
|
149
|
+
def validate(config_file: Path, no_check_imports: bool):
|
|
150
|
+
"""
|
|
151
|
+
Validates a strategy configuration file without running it.
|
|
152
|
+
|
|
153
|
+
Checks for:
|
|
154
|
+
- Valid YAML syntax
|
|
155
|
+
- Required configuration fields
|
|
156
|
+
- Strategy class exists and can be imported (unless --no-check-imports)
|
|
157
|
+
- Exchange configurations are valid
|
|
158
|
+
- Simulation parameters are valid (if present)
|
|
159
|
+
|
|
160
|
+
Returns exit code 0 if valid, 1 if invalid.
|
|
161
|
+
"""
|
|
162
|
+
from qubx.utils.runner.configs import validate_strategy_config
|
|
163
|
+
|
|
164
|
+
result = validate_strategy_config(config_file, check_imports=not no_check_imports)
|
|
165
|
+
|
|
166
|
+
if result.valid:
|
|
167
|
+
click.echo(click.style("✓ Configuration is valid", fg="green", bold=True))
|
|
168
|
+
if result.warnings:
|
|
169
|
+
click.echo(click.style("\nWarnings:", fg="yellow", bold=True))
|
|
170
|
+
for warning in result.warnings:
|
|
171
|
+
click.echo(click.style(f" - {warning}", fg="yellow"))
|
|
172
|
+
raise SystemExit(0)
|
|
173
|
+
else:
|
|
174
|
+
click.echo(click.style("✗ Configuration is invalid", fg="red", bold=True))
|
|
175
|
+
click.echo(click.style("\nErrors:", fg="red", bold=True))
|
|
176
|
+
for error in result.errors:
|
|
177
|
+
click.echo(click.style(f" - {error}", fg="red"))
|
|
178
|
+
if result.warnings:
|
|
179
|
+
click.echo(click.style("\nWarnings:", fg="yellow", bold=True))
|
|
180
|
+
for warning in result.warnings:
|
|
181
|
+
click.echo(click.style(f" - {warning}", fg="yellow"))
|
|
182
|
+
raise SystemExit(1)
|
|
183
|
+
|
|
184
|
+
|
|
140
185
|
@main.command()
|
|
141
186
|
@click.argument(
|
|
142
187
|
"directory",
|
|
@@ -358,7 +403,7 @@ def init(
|
|
|
358
403
|
The generated strategy can be run immediately with:
|
|
359
404
|
poetry run qubx run --config config.yml --paper
|
|
360
405
|
"""
|
|
361
|
-
from qubx.templates import
|
|
406
|
+
from qubx.templates import TemplateError, TemplateManager
|
|
362
407
|
|
|
363
408
|
try:
|
|
364
409
|
manager = TemplateManager()
|
qubx/connectors/ccxt/account.py
CHANGED
|
@@ -130,6 +130,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
130
130
|
|
|
131
131
|
if not self.exchange_manager.exchange.isSandboxModeEnabled:
|
|
132
132
|
# - start polling tasks
|
|
133
|
+
self._loop.submit(self.exchange_manager.exchange.load_markets()).result()
|
|
133
134
|
self._polling_tasks["balance"] = self._loop.submit(
|
|
134
135
|
self._poller("balance", self._update_balance, self.balance_interval)
|
|
135
136
|
)
|
|
@@ -9,7 +9,7 @@ from qubx.core.exceptions import BadRequest
|
|
|
9
9
|
class HyperliquidCcxtBroker(CcxtBroker):
|
|
10
10
|
"""
|
|
11
11
|
HyperLiquid-specific broker that handles market order slippage requirements.
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
HyperLiquid requires a price even for market orders to calculate max slippage.
|
|
14
14
|
This broker automatically calculates slippage-protected prices for market orders.
|
|
15
15
|
"""
|
|
@@ -17,7 +17,7 @@ class HyperliquidCcxtBroker(CcxtBroker):
|
|
|
17
17
|
def __init__(
|
|
18
18
|
self,
|
|
19
19
|
*args,
|
|
20
|
-
market_order_slippage: float = 0.
|
|
20
|
+
market_order_slippage: float = 0.01, # 5% default slippage
|
|
21
21
|
**kwargs,
|
|
22
22
|
):
|
|
23
23
|
super().__init__(*args, **kwargs)
|
|
@@ -38,21 +38,29 @@ class HyperliquidCcxtBroker(CcxtBroker):
|
|
|
38
38
|
if order_type.lower() == "market" and price is None:
|
|
39
39
|
quote = self.data_provider.get_quote(instrument)
|
|
40
40
|
if quote is None:
|
|
41
|
-
logger.warning(
|
|
42
|
-
|
|
41
|
+
logger.warning(
|
|
42
|
+
f"[<y>{instrument.symbol}</y>] :: Quote is not available for market order slippage calculation."
|
|
43
|
+
)
|
|
44
|
+
raise BadRequest(
|
|
45
|
+
f"Quote is not available for market order slippage calculation for {instrument.symbol}"
|
|
46
|
+
)
|
|
43
47
|
|
|
44
48
|
# Get slippage from options or use default
|
|
45
49
|
slippage = options.get("slippage", self.market_order_slippage)
|
|
46
|
-
|
|
50
|
+
|
|
47
51
|
# Calculate slippage-protected price
|
|
48
52
|
if order_side.upper() == "BUY":
|
|
49
53
|
# For buy orders, add slippage to ask price to ensure execution
|
|
50
54
|
price = quote.ask * (1 + slippage)
|
|
51
|
-
logger.debug(
|
|
55
|
+
logger.debug(
|
|
56
|
+
f"[<y>{instrument.symbol}</y>] :: Market BUY order: using slippage-protected price {price:.6f} (ask: {quote.ask:.6f}, slippage: {slippage:.1%})"
|
|
57
|
+
)
|
|
52
58
|
else: # SELL
|
|
53
59
|
# For sell orders, subtract slippage from bid price to ensure execution
|
|
54
60
|
price = quote.bid * (1 - slippage)
|
|
55
|
-
logger.debug(
|
|
61
|
+
logger.debug(
|
|
62
|
+
f"[<y>{instrument.symbol}</y>] :: Market SELL order: using slippage-protected price {price:.6f} (bid: {quote.bid:.6f}, slippage: {slippage:.1%})"
|
|
63
|
+
)
|
|
56
64
|
|
|
57
65
|
# Call parent implementation with calculated price
|
|
58
66
|
payload = super()._prepare_order_payload(
|
|
@@ -64,6 +72,6 @@ class HyperliquidCcxtBroker(CcxtBroker):
|
|
|
64
72
|
if "slippage" in options:
|
|
65
73
|
# HyperLiquid accepts slippage as a percentage (e.g., 0.05 for 5%)
|
|
66
74
|
params["px"] = price # Explicit price for slippage calculation
|
|
67
|
-
|
|
75
|
+
|
|
68
76
|
payload["params"] = params
|
|
69
|
-
return payload
|
|
77
|
+
return payload
|
|
@@ -8,7 +8,7 @@ from ...adapters.polling_adapter import PollingConfig, PollingToWebSocketAdapter
|
|
|
8
8
|
from ..base import CcxtFuturePatchMixin
|
|
9
9
|
|
|
10
10
|
# Constants
|
|
11
|
-
FUNDING_RATE_DEFAULT_POLL_MINUTES =
|
|
11
|
+
FUNDING_RATE_DEFAULT_POLL_MINUTES = 1
|
|
12
12
|
FUNDING_RATE_HOUR_MS = 60 * 60 * 1000 # 1 hour in milliseconds
|
|
13
13
|
|
|
14
14
|
|
|
@@ -71,7 +71,7 @@ class FundingRateDataHandler(BaseDataTypeHandler):
|
|
|
71
71
|
channel.send((instrument, DataType.FUNDING_RATE, funding_rate, False))
|
|
72
72
|
|
|
73
73
|
# Emit payment if funding interval changed
|
|
74
|
-
if self._should_emit_payment(instrument, funding_rate):
|
|
74
|
+
if self._should_emit_payment(instrument, funding_rate, current_time):
|
|
75
75
|
payment = self._create_funding_payment(instrument)
|
|
76
76
|
channel.send((instrument, DataType.FUNDING_PAYMENT, payment, False))
|
|
77
77
|
|
|
@@ -101,7 +101,7 @@ class FundingRateDataHandler(BaseDataTypeHandler):
|
|
|
101
101
|
stream_name=name,
|
|
102
102
|
)
|
|
103
103
|
|
|
104
|
-
def _should_emit_payment(self, instrument: Instrument, rate: FundingRate) -> bool:
|
|
104
|
+
def _should_emit_payment(self, instrument: Instrument, rate: FundingRate, current_time: dt_64) -> bool:
|
|
105
105
|
"""
|
|
106
106
|
Determine if a funding payment should be emitted.
|
|
107
107
|
|
|
@@ -132,7 +132,7 @@ class FundingRateDataHandler(BaseDataTypeHandler):
|
|
|
132
132
|
return False
|
|
133
133
|
|
|
134
134
|
# Emit if next_funding_time has advanced (new funding period started)
|
|
135
|
-
if rate.next_funding_time > last_info["payment_time"]:
|
|
135
|
+
if rate.next_funding_time > last_info["payment_time"] and current_time > last_info["payment_time"]:
|
|
136
136
|
# Store payment info for _create_funding_payment
|
|
137
137
|
self._pending_funding_rates[f"{key}_payment"] = {
|
|
138
138
|
"rate": last_info["rate"].rate,
|
|
@@ -65,7 +65,7 @@ class OrderBookDataHandler(BaseDataTypeHandler):
|
|
|
65
65
|
|
|
66
66
|
# Notify all listeners
|
|
67
67
|
self._data_provider.notify_data_arrival(sub_type, dt_64(ob.time, "ns"))
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
channel.send((instrument, sub_type, ob, False))
|
|
70
70
|
return True
|
|
71
71
|
|
|
@@ -150,7 +150,7 @@ class OrderBookDataHandler(BaseDataTypeHandler):
|
|
|
150
150
|
) -> SubscriptionConfiguration:
|
|
151
151
|
"""
|
|
152
152
|
Prepare subscription configuration for individual instruments.
|
|
153
|
-
|
|
153
|
+
|
|
154
154
|
Creates separate subscriber functions for each instrument to enable independent
|
|
155
155
|
WebSocket streams without waiting for all instruments. This follows the same
|
|
156
156
|
pattern as the OHLC handler for proper individual stream management.
|
|
@@ -169,10 +169,10 @@ class OrderBookDataHandler(BaseDataTypeHandler):
|
|
|
169
169
|
try:
|
|
170
170
|
# Watch orderbook for single instrument
|
|
171
171
|
ccxt_ob = await self._exchange_manager.exchange.watch_order_book(symbol)
|
|
172
|
-
|
|
172
|
+
|
|
173
173
|
# Use private processing method to avoid duplication
|
|
174
174
|
self._process_orderbook(ccxt_ob, inst, sub_type, channel, depth, tick_size_pct)
|
|
175
|
-
|
|
175
|
+
|
|
176
176
|
except Exception as e:
|
|
177
177
|
logger.error(
|
|
178
178
|
f"<yellow>{exchange_id}</yellow> Error in individual orderbook subscription for {inst.symbol}: {e}"
|
|
@@ -186,13 +186,15 @@ class OrderBookDataHandler(BaseDataTypeHandler):
|
|
|
186
186
|
# Create individual unsubscriber if exchange supports it
|
|
187
187
|
un_watch_method = getattr(self._exchange_manager.exchange, "un_watch_order_book", None)
|
|
188
188
|
if un_watch_method is not None and callable(un_watch_method):
|
|
189
|
-
|
|
189
|
+
|
|
190
190
|
def create_individual_unsubscriber(symbol=ccxt_symbol, exchange_id=self._exchange_id):
|
|
191
191
|
async def individual_unsubscriber():
|
|
192
192
|
try:
|
|
193
193
|
await self._exchange_manager.exchange.un_watch_order_book(symbol)
|
|
194
194
|
except Exception as e:
|
|
195
|
-
logger.error(
|
|
195
|
+
logger.error(
|
|
196
|
+
f"<yellow>{exchange_id}</yellow> Error unsubscribing orderbook for {symbol}: {e}"
|
|
197
|
+
)
|
|
196
198
|
|
|
197
199
|
return individual_unsubscriber
|
|
198
200
|
|
|
@@ -27,6 +27,40 @@ class TradeDataHandler(BaseDataTypeHandler):
|
|
|
27
27
|
def data_type(self) -> str:
|
|
28
28
|
return "trade"
|
|
29
29
|
|
|
30
|
+
def _process_trade(self, trades: list, instrument: Instrument, sub_type: str, channel: CtrlChannel):
|
|
31
|
+
"""
|
|
32
|
+
Process trades with synthetic quote generation.
|
|
33
|
+
|
|
34
|
+
This method handles the common logic for processing trade data that's shared between
|
|
35
|
+
bulk and individual subscription approaches.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
trades: List of CCXT trade dictionaries
|
|
39
|
+
instrument: Instrument these trades belong to
|
|
40
|
+
sub_type: Subscription type string
|
|
41
|
+
channel: Control channel to send data through
|
|
42
|
+
"""
|
|
43
|
+
for trade in trades:
|
|
44
|
+
converted_trade = ccxt_convert_trade(trade)
|
|
45
|
+
|
|
46
|
+
# Notify all listeners
|
|
47
|
+
self._data_provider.notify_data_arrival(sub_type, dt_64(converted_trade.time, "ns"))
|
|
48
|
+
|
|
49
|
+
channel.send((instrument, sub_type, converted_trade, False))
|
|
50
|
+
|
|
51
|
+
# Generate synthetic quote if no quote/orderbook subscription exists
|
|
52
|
+
if len(trades) > 0 and not (
|
|
53
|
+
self._data_provider.has_subscription(instrument, DataType.ORDERBOOK)
|
|
54
|
+
or self._data_provider.has_subscription(instrument, DataType.QUOTE)
|
|
55
|
+
):
|
|
56
|
+
last_trade = trades[-1]
|
|
57
|
+
converted_trade = ccxt_convert_trade(last_trade)
|
|
58
|
+
_price = converted_trade.price
|
|
59
|
+
_time = converted_trade.time
|
|
60
|
+
_s2 = instrument.tick_size / 2.0
|
|
61
|
+
_bid, _ask = _price - _s2, _price + _s2
|
|
62
|
+
self._data_provider._last_quotes[instrument] = Quote(_time, _bid, _ask, 0.0, 0.0)
|
|
63
|
+
|
|
30
64
|
def prepare_subscription(
|
|
31
65
|
self, name: str, sub_type: str, channel: CtrlChannel, instruments: Set[Instrument], **params
|
|
32
66
|
) -> SubscriptionConfiguration:
|
|
@@ -42,6 +76,21 @@ class TradeDataHandler(BaseDataTypeHandler):
|
|
|
42
76
|
Returns:
|
|
43
77
|
SubscriptionConfiguration with subscriber and unsubscriber functions
|
|
44
78
|
"""
|
|
79
|
+
# Use exchange-specific approach based on capabilities
|
|
80
|
+
if self._exchange_manager.exchange.has.get("watchTradesForSymbols", False):
|
|
81
|
+
return self._prepare_subscription_for_instruments(name, sub_type, channel, instruments)
|
|
82
|
+
else:
|
|
83
|
+
# Fall back to individual instrument subscriptions
|
|
84
|
+
return self._prepare_subscription_for_individual_instruments(name, sub_type, channel, instruments)
|
|
85
|
+
|
|
86
|
+
def _prepare_subscription_for_instruments(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
sub_type: str,
|
|
90
|
+
channel: CtrlChannel,
|
|
91
|
+
instruments: Set[Instrument],
|
|
92
|
+
) -> SubscriptionConfiguration:
|
|
93
|
+
"""Prepare subscription configuration for multiple instruments using bulk API."""
|
|
45
94
|
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
46
95
|
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
47
96
|
|
|
@@ -52,31 +101,13 @@ class TradeDataHandler(BaseDataTypeHandler):
|
|
|
52
101
|
exch_symbol = trades[0]["symbol"]
|
|
53
102
|
instrument = ccxt_find_instrument(exch_symbol, self._exchange_manager.exchange, _symbol_to_instrument)
|
|
54
103
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Notify all listeners
|
|
59
|
-
self._data_provider.notify_data_arrival(sub_type, dt_64(converted_trade.time, "ns"))
|
|
60
|
-
|
|
61
|
-
channel.send((instrument, sub_type, converted_trade, False))
|
|
62
|
-
|
|
63
|
-
if len(trades) > 0 and not (
|
|
64
|
-
self._data_provider.has_subscription(instrument, DataType.ORDERBOOK)
|
|
65
|
-
or self._data_provider.has_subscription(instrument, DataType.QUOTE)
|
|
66
|
-
):
|
|
67
|
-
last_trade = trades[-1]
|
|
68
|
-
converted_trade = ccxt_convert_trade(last_trade)
|
|
69
|
-
_price = converted_trade.price
|
|
70
|
-
_time = converted_trade.time
|
|
71
|
-
_s2 = instrument.tick_size / 2.0
|
|
72
|
-
_bid, _ask = _price - _s2, _price + _s2
|
|
73
|
-
self._data_provider._last_quotes[instrument] = Quote(_time, _bid, _ask, 0.0, 0.0)
|
|
104
|
+
# Use private processing method to avoid duplication
|
|
105
|
+
self._process_trade(trades, instrument, sub_type, channel)
|
|
74
106
|
|
|
75
107
|
async def un_watch_trades(instruments_batch: list[Instrument]):
|
|
76
108
|
symbols = [_instr_to_ccxt_symbol[i] for i in instruments_batch]
|
|
77
109
|
await self._exchange_manager.exchange.un_watch_trades_for_symbols(symbols)
|
|
78
110
|
|
|
79
|
-
# Return subscription configuration instead of calling _listen_to_stream directly
|
|
80
111
|
return SubscriptionConfiguration(
|
|
81
112
|
subscription_type=sub_type,
|
|
82
113
|
subscriber_func=create_market_type_batched_subscriber(watch_trades, instruments),
|
|
@@ -85,6 +116,71 @@ class TradeDataHandler(BaseDataTypeHandler):
|
|
|
85
116
|
requires_market_type_batching=True,
|
|
86
117
|
)
|
|
87
118
|
|
|
119
|
+
def _prepare_subscription_for_individual_instruments(
|
|
120
|
+
self,
|
|
121
|
+
name: str,
|
|
122
|
+
sub_type: str,
|
|
123
|
+
channel: CtrlChannel,
|
|
124
|
+
instruments: Set[Instrument],
|
|
125
|
+
) -> SubscriptionConfiguration:
|
|
126
|
+
"""
|
|
127
|
+
Prepare subscription configuration for individual instruments.
|
|
128
|
+
|
|
129
|
+
Creates separate subscriber functions for each instrument to enable independent
|
|
130
|
+
WebSocket streams without waiting for all instruments. This follows the same
|
|
131
|
+
pattern as the orderbook handler for proper individual stream management.
|
|
132
|
+
"""
|
|
133
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
134
|
+
|
|
135
|
+
individual_subscribers = {}
|
|
136
|
+
individual_unsubscribers = {}
|
|
137
|
+
|
|
138
|
+
for instrument in instruments:
|
|
139
|
+
ccxt_symbol = _instr_to_ccxt_symbol[instrument]
|
|
140
|
+
|
|
141
|
+
# Create individual subscriber for this instrument using closure
|
|
142
|
+
def create_individual_subscriber(inst=instrument, symbol=ccxt_symbol, exchange_id=self._exchange_id):
|
|
143
|
+
async def individual_subscriber():
|
|
144
|
+
try:
|
|
145
|
+
# Watch trades for single instrument
|
|
146
|
+
trades = await self._exchange_manager.exchange.watch_trades(symbol)
|
|
147
|
+
|
|
148
|
+
# Use private processing method to avoid duplication
|
|
149
|
+
self._process_trade(trades, inst, sub_type, channel)
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(
|
|
153
|
+
f"<yellow>{exchange_id}</yellow> Error in individual trade subscription for {inst.symbol}: {e}"
|
|
154
|
+
)
|
|
155
|
+
raise # Let connection manager handle retries
|
|
156
|
+
|
|
157
|
+
return individual_subscriber
|
|
158
|
+
|
|
159
|
+
individual_subscribers[instrument] = create_individual_subscriber()
|
|
160
|
+
|
|
161
|
+
# Create individual unsubscriber if exchange supports it
|
|
162
|
+
un_watch_method = getattr(self._exchange_manager.exchange, "un_watch_trades", None)
|
|
163
|
+
if un_watch_method is not None and callable(un_watch_method):
|
|
164
|
+
|
|
165
|
+
def create_individual_unsubscriber(symbol=ccxt_symbol, exchange_id=self._exchange_id):
|
|
166
|
+
async def individual_unsubscriber():
|
|
167
|
+
try:
|
|
168
|
+
await self._exchange_manager.exchange.un_watch_trades(symbol)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"<yellow>{exchange_id}</yellow> Error unsubscribing trades for {symbol}: {e}")
|
|
171
|
+
|
|
172
|
+
return individual_unsubscriber
|
|
173
|
+
|
|
174
|
+
individual_unsubscribers[instrument] = create_individual_unsubscriber()
|
|
175
|
+
|
|
176
|
+
return SubscriptionConfiguration(
|
|
177
|
+
subscription_type=sub_type,
|
|
178
|
+
instrument_subscribers=individual_subscribers,
|
|
179
|
+
instrument_unsubscribers=individual_unsubscribers if individual_unsubscribers else None,
|
|
180
|
+
stream_name=name,
|
|
181
|
+
requires_market_type_batching=False,
|
|
182
|
+
)
|
|
183
|
+
|
|
88
184
|
async def warmup(self, instruments: Set[Instrument], channel: CtrlChannel, warmup_period: str, **params) -> None:
|
|
89
185
|
"""
|
|
90
186
|
Fetch historical trade data for warmup during backtesting.
|
qubx/connectors/ccxt/reader.py
CHANGED
|
@@ -20,7 +20,7 @@ from .utils import ccxt_find_instrument, instrument_to_ccxt_symbol
|
|
|
20
20
|
|
|
21
21
|
@reader("ccxt")
|
|
22
22
|
class CcxtDataReader(DataReader):
|
|
23
|
-
SUPPORTED_DATA_TYPES = {"ohlc"
|
|
23
|
+
SUPPORTED_DATA_TYPES = {"ohlc"}
|
|
24
24
|
|
|
25
25
|
_exchanges: dict[str, Exchange]
|
|
26
26
|
_loop: AsyncThreadLoop
|
|
@@ -74,7 +74,8 @@ class CcxtDataReader(DataReader):
|
|
|
74
74
|
if instrument is None:
|
|
75
75
|
return []
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
timeframe = timeframe or "1m"
|
|
78
|
+
_timeframe = pd.Timedelta(timeframe)
|
|
78
79
|
_start, _stop = self._get_start_stop(start, stop, _timeframe)
|
|
79
80
|
|
|
80
81
|
if _start > _stop:
|
qubx/core/helpers.py
CHANGED
|
@@ -233,10 +233,16 @@ class CachedMarketDataHolder:
|
|
|
233
233
|
if series:
|
|
234
234
|
total_vol = trade.size
|
|
235
235
|
bought_vol = total_vol if trade.side == 1 else 0.0
|
|
236
|
+
volume_quote = trade.price * trade.size
|
|
237
|
+
bought_volume_quote = volume_quote if trade.side == 1 else 0.0
|
|
236
238
|
for ser in series.values():
|
|
237
|
-
if len(ser) > 0
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
if len(ser) > 0:
|
|
240
|
+
current_bar_start = floor_t64(np.datetime64(ser[0].time, 'ns'), np.timedelta64(ser.timeframe, 'ns'))
|
|
241
|
+
trade_bar_start = floor_t64(np.datetime64(trade.time, 'ns'), np.timedelta64(ser.timeframe, 'ns'))
|
|
242
|
+
if trade_bar_start < current_bar_start:
|
|
243
|
+
# Trade belongs to a previous bar - skip it
|
|
244
|
+
continue
|
|
245
|
+
ser.update(trade.time, trade.price, total_vol, bought_vol, volume_quote, bought_volume_quote, 1)
|
|
240
246
|
|
|
241
247
|
def finalize_ohlc_for_instruments(self, time: dt_64, instruments: list[Instrument]):
|
|
242
248
|
"""
|
qubx/core/interfaces.py
CHANGED
|
@@ -2050,7 +2050,7 @@ class IMetricEmitter:
|
|
|
2050
2050
|
self,
|
|
2051
2051
|
name: str,
|
|
2052
2052
|
value: float,
|
|
2053
|
-
tags: dict[str,
|
|
2053
|
+
tags: dict[str, Any] | None = None,
|
|
2054
2054
|
timestamp: dt_64 | None = None,
|
|
2055
2055
|
instrument: Instrument | None = None,
|
|
2056
2056
|
) -> None:
|
|
@@ -2092,15 +2092,16 @@ class IMetricEmitter:
|
|
|
2092
2092
|
"""
|
|
2093
2093
|
pass
|
|
2094
2094
|
|
|
2095
|
-
def
|
|
2095
|
+
def set_context(self, context: "IStrategyContext") -> None:
|
|
2096
2096
|
"""
|
|
2097
|
-
Set the
|
|
2097
|
+
Set the strategy context for the metric emitter.
|
|
2098
2098
|
|
|
2099
|
-
This method is used to set the
|
|
2100
|
-
|
|
2099
|
+
This method is used to set the context that provides access to time and simulation state.
|
|
2100
|
+
The context is used to automatically add is_live tag and get timestamps when no explicit
|
|
2101
|
+
timestamp is provided in the emit method.
|
|
2101
2102
|
|
|
2102
2103
|
Args:
|
|
2103
|
-
|
|
2104
|
+
context: The strategy context to use
|
|
2104
2105
|
"""
|
|
2105
2106
|
pass
|
|
2106
2107
|
|
qubx/core/metrics.py
CHANGED
|
@@ -175,7 +175,7 @@ def cagr(returns, periods=DAILY):
|
|
|
175
175
|
|
|
176
176
|
cumrets = (returns + 1).cumprod(axis=0)
|
|
177
177
|
years = len(cumrets) / float(periods)
|
|
178
|
-
return (cumrets.iloc[-1] ** (1.0 / years)) - 1.0
|
|
178
|
+
return ((cumrets.iloc[-1] ** (1.0 / years)) - 1.0) * 100
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
def calmar_ratio(returns, periods=DAILY):
|
|
@@ -747,6 +747,11 @@ class TradingSessionResult:
|
|
|
747
747
|
"""Get number of executions"""
|
|
748
748
|
return len(self.executions_log)
|
|
749
749
|
|
|
750
|
+
@property
|
|
751
|
+
def turnover(self) -> float:
|
|
752
|
+
"""Get average daily turnover as percentage of equity"""
|
|
753
|
+
return self.performance().get("avg_daily_turnover", 0.0)
|
|
754
|
+
|
|
750
755
|
@property
|
|
751
756
|
def leverage(self) -> pd.Series:
|
|
752
757
|
"""Get leverage over time"""
|
|
@@ -779,7 +784,7 @@ class TradingSessionResult:
|
|
|
779
784
|
for k in [
|
|
780
785
|
"equity", "drawdown_usd", "drawdown_pct",
|
|
781
786
|
"compound_returns", "returns_daily", "returns", "monthly_returns",
|
|
782
|
-
"rolling_sharpe", "long_value", "short_value",
|
|
787
|
+
"rolling_sharpe", "long_value", "short_value", "turnover",
|
|
783
788
|
]:
|
|
784
789
|
self._metrics.pop(k, None)
|
|
785
790
|
# fmt: on
|
|
@@ -1381,16 +1386,21 @@ def portfolio_metrics(
|
|
|
1381
1386
|
execs = len(executions_log)
|
|
1382
1387
|
mdd_pct = 100 * dd_data / equity.cummax() if execs > 0 else pd.Series(0, index=equity.index)
|
|
1383
1388
|
sheet["equity"] = equity
|
|
1384
|
-
sheet["gain"] = sheet["equity"].iloc[-1] - sheet["equity"].iloc[0]
|
|
1385
|
-
sheet["cagr"] = cagr(returns_daily, performance_statistics_period)
|
|
1386
1389
|
sheet["sharpe"] = sharpe_ratio(returns_daily, risk_free, performance_statistics_period)
|
|
1390
|
+
sheet["cagr"] = cagr(returns_daily, performance_statistics_period)
|
|
1391
|
+
|
|
1392
|
+
# turnover calculation
|
|
1393
|
+
symbols = list(set(portfolio_log.columns.str.split("_").str.get(0).values))
|
|
1394
|
+
turnover_series = calculate_turnover(portfolio_log, symbols, equity, resample="1d")
|
|
1395
|
+
sheet["turnover"] = turnover_series
|
|
1396
|
+
sheet["daily_turnover"] = turnover_series.mean() if len(turnover_series) > 0 else 0.0
|
|
1397
|
+
|
|
1387
1398
|
sheet["qr"] = qr(equity) if execs > 0 else 0
|
|
1388
|
-
sheet["
|
|
1399
|
+
sheet["mdd_pct"] = max(mdd_pct)
|
|
1389
1400
|
sheet["drawdown_pct"] = mdd_pct
|
|
1401
|
+
sheet["drawdown_usd"] = dd_data
|
|
1390
1402
|
# 25-May-2019: MDE fixed Max DD pct calculations
|
|
1391
|
-
sheet["max_dd_pct"] = max(mdd_pct)
|
|
1392
1403
|
# sheet["max_dd_pct_on_init"] = 100 * mdd / init_cash
|
|
1393
|
-
sheet["mdd_usd"] = mdd
|
|
1394
1404
|
sheet["mdd_start"] = equity.index[ddstart]
|
|
1395
1405
|
sheet["mdd_peak"] = equity.index[ddpeak]
|
|
1396
1406
|
sheet["mdd_recover"] = equity.index[ddrecover]
|
|
@@ -1403,12 +1413,12 @@ def portfolio_metrics(
|
|
|
1403
1413
|
)
|
|
1404
1414
|
sheet["calmar"] = calmar_ratio(returns_daily, performance_statistics_period)
|
|
1405
1415
|
# sheet["ann_vol"] = annual_volatility(returns_daily)
|
|
1406
|
-
sheet["tail_ratio"] = tail_ratio(returns_daily)
|
|
1407
|
-
sheet["stability"] = stability_of_returns(returns_daily)
|
|
1416
|
+
# sheet["tail_ratio"] = tail_ratio(returns_daily)
|
|
1417
|
+
# sheet["stability"] = stability_of_returns(returns_daily)
|
|
1408
1418
|
sheet["monthly_returns"] = aggregate_returns(returns_daily, convert_to="mon")
|
|
1409
1419
|
r_m = np.mean(returns_daily)
|
|
1410
1420
|
r_s = np.std(returns_daily)
|
|
1411
|
-
sheet["var"] = var_cov_var(init_cash, r_m, r_s)
|
|
1421
|
+
# sheet["var"] = var_cov_var(init_cash, r_m, r_s)
|
|
1412
1422
|
sheet["avg_return"] = 100 * r_m
|
|
1413
1423
|
|
|
1414
1424
|
# portfolio market values
|
|
@@ -1416,6 +1426,8 @@ def portfolio_metrics(
|
|
|
1416
1426
|
sheet["long_value"] = mkt_value[mkt_value > 0].sum(axis=1).fillna(0)
|
|
1417
1427
|
sheet["short_value"] = mkt_value[mkt_value < 0].sum(axis=1).fillna(0)
|
|
1418
1428
|
|
|
1429
|
+
sheet["gain"] = sheet["equity"].iloc[-1] - sheet["equity"].iloc[0]
|
|
1430
|
+
sheet["mdd_usd"] = mdd
|
|
1419
1431
|
# total commissions
|
|
1420
1432
|
sheet["fees"] = pft_total["Total_Commissions"].iloc[-1]
|
|
1421
1433
|
|
|
@@ -1423,9 +1435,10 @@ def portfolio_metrics(
|
|
|
1423
1435
|
funding_columns = pft_total.filter(regex=".*_Funding")
|
|
1424
1436
|
if not funding_columns.empty:
|
|
1425
1437
|
total_funding = funding_columns.sum(axis=1)
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1438
|
+
if total_funding.iloc[-1] != 0:
|
|
1439
|
+
sheet["funding_pnl"] = 100 * total_funding.iloc[-1] / init_cash # as percentage of initial capital
|
|
1440
|
+
# else:
|
|
1441
|
+
# sheet["funding_pnl"] = 0.0
|
|
1429
1442
|
|
|
1430
1443
|
# executions metrics
|
|
1431
1444
|
sheet["execs"] = execs
|
|
@@ -1725,7 +1738,7 @@ def _tearsheet_single(
|
|
|
1725
1738
|
ay = sbp(_n, 5)
|
|
1726
1739
|
plt.plot(lev, c="c", lw=1.5, label="Leverage")
|
|
1727
1740
|
plt.subplots_adjust(hspace=0)
|
|
1728
|
-
return pd.DataFrame(report).T.round(
|
|
1741
|
+
return pd.DataFrame(report).T.round(2)
|
|
1729
1742
|
|
|
1730
1743
|
|
|
1731
1744
|
def calculate_leverage(
|
|
@@ -1828,6 +1841,53 @@ def calculate_pnl_per_symbol(
|
|
|
1828
1841
|
return df
|
|
1829
1842
|
|
|
1830
1843
|
|
|
1844
|
+
def calculate_turnover(
|
|
1845
|
+
portfolio_log: pd.DataFrame,
|
|
1846
|
+
symbols: list[str],
|
|
1847
|
+
equity: pd.Series,
|
|
1848
|
+
resample: str = "1d",
|
|
1849
|
+
) -> pd.Series:
|
|
1850
|
+
"""
|
|
1851
|
+
Calculate daily turnover as percentage of equity.
|
|
1852
|
+
|
|
1853
|
+
Turnover measures trading activity by calculating the absolute value of position changes
|
|
1854
|
+
multiplied by price, then dividing by equity.
|
|
1855
|
+
|
|
1856
|
+
Args:
|
|
1857
|
+
portfolio_log: Portfolio log dataframe with position and price columns
|
|
1858
|
+
symbols: List of symbols to calculate turnover for
|
|
1859
|
+
equity: Equity curve series
|
|
1860
|
+
resample: Resampling period for turnover calculation (default "1d")
|
|
1861
|
+
|
|
1862
|
+
Returns:
|
|
1863
|
+
pd.Series: Daily turnover as percentage of equity
|
|
1864
|
+
"""
|
|
1865
|
+
position_diffs = []
|
|
1866
|
+
|
|
1867
|
+
for symbol in symbols:
|
|
1868
|
+
pos_col = f"{symbol}_Pos"
|
|
1869
|
+
price_col = f"{symbol}_Price"
|
|
1870
|
+
|
|
1871
|
+
if pos_col in portfolio_log.columns and price_col in portfolio_log.columns:
|
|
1872
|
+
# Calculate absolute position change multiplied by price (notional value)
|
|
1873
|
+
position_diff = portfolio_log[pos_col].diff().abs() * portfolio_log[price_col]
|
|
1874
|
+
position_diffs.append(position_diff)
|
|
1875
|
+
|
|
1876
|
+
if not position_diffs:
|
|
1877
|
+
return pd.Series(0, index=equity.index)
|
|
1878
|
+
|
|
1879
|
+
# Sum all position changes and resample to specified period
|
|
1880
|
+
notional_turnover = pd.concat(position_diffs, axis=1).sum(axis=1).resample(resample).sum()
|
|
1881
|
+
|
|
1882
|
+
# Resample equity to match turnover frequency
|
|
1883
|
+
equity_resampled = equity.resample(resample).last()
|
|
1884
|
+
|
|
1885
|
+
# Calculate turnover as percentage of equity
|
|
1886
|
+
daily_turnover = notional_turnover.div(equity_resampled).mul(100).fillna(0)
|
|
1887
|
+
|
|
1888
|
+
return daily_turnover
|
|
1889
|
+
|
|
1890
|
+
|
|
1831
1891
|
def chart_signals(
|
|
1832
1892
|
result: TradingSessionResult,
|
|
1833
1893
|
symbol: str,
|