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.

Files changed (37) hide show
  1. qubx/backtester/management.py +3 -2
  2. qubx/backtester/runner.py +1 -1
  3. qubx/cli/commands.py +46 -1
  4. qubx/connectors/ccxt/account.py +1 -0
  5. qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +17 -9
  6. qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +1 -1
  7. qubx/connectors/ccxt/handlers/funding_rate.py +3 -3
  8. qubx/connectors/ccxt/handlers/orderbook.py +8 -6
  9. qubx/connectors/ccxt/handlers/trade.py +116 -20
  10. qubx/connectors/ccxt/reader.py +3 -2
  11. qubx/core/helpers.py +9 -3
  12. qubx/core/interfaces.py +7 -6
  13. qubx/core/metrics.py +74 -14
  14. qubx/core/mixins/subscription.py +7 -1
  15. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  16. qubx/core/series.pxd +3 -2
  17. qubx/core/series.pyi +3 -2
  18. qubx/core/series.pyx +30 -5
  19. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  20. qubx/emitters/base.py +23 -14
  21. qubx/emitters/composite.py +13 -0
  22. qubx/emitters/csv.py +2 -1
  23. qubx/emitters/indicator.py +4 -2
  24. qubx/emitters/inmemory.py +5 -4
  25. qubx/emitters/prometheus.py +2 -2
  26. qubx/emitters/questdb.py +16 -10
  27. qubx/exporters/formatters/__init__.py +8 -1
  28. qubx/exporters/formatters/target_position.py +78 -0
  29. qubx/health/base.py +7 -10
  30. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  31. qubx/utils/runner/configs.py +120 -17
  32. qubx/utils/runner/runner.py +6 -6
  33. {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/METADATA +1 -1
  34. {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/RECORD +37 -36
  35. {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/WHEEL +0 -0
  36. {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/entry_points.txt +0 -0
  37. {qubx-0.6.84.dist-info → qubx-0.6.87.dist-info}/licenses/LICENSE +0 -0
@@ -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", "max_dd_pct", "mdd_usd", "fees", "execs"]]
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", "max_dd_pct", "mdd_usd", "fees", "execs"]
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
@@ -478,7 +478,7 @@ class SimulationRunner:
478
478
  )
479
479
 
480
480
  if self.emitter is not None:
481
- self.emitter.set_time_provider(simulated_clock)
481
+ self.emitter.set_context(ctx)
482
482
 
483
483
  # - setup base subscription from spec
484
484
  if ctx.get_base_subscription() == DataType.NONE:
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 TemplateManager, TemplateError
406
+ from qubx.templates import TemplateError, TemplateManager
362
407
 
363
408
  try:
364
409
  manager = TemplateManager()
@@ -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.05, # 5% default slippage
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(f"[<y>{instrument.symbol}</y>] :: Quote is not available for market order slippage calculation.")
42
- raise BadRequest(f"Quote is not available for market order slippage calculation for {instrument.symbol}")
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(f"[<y>{instrument.symbol}</y>] :: Market BUY order: using slippage-protected price {price:.6f} (ask: {quote.ask:.6f}, slippage: {slippage:.1%})")
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(f"[<y>{instrument.symbol}</y>] :: Market SELL order: using slippage-protected price {price:.6f} (bid: {quote.bid:.6f}, slippage: {slippage:.1%})")
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 = 5
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(f"<yellow>{exchange_id}</yellow> Error unsubscribing orderbook for {symbol}: {e}")
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
- for trade in trades:
56
- converted_trade = ccxt_convert_trade(trade)
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.
@@ -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", "funding_payment"}
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
- _timeframe = pd.Timedelta(timeframe or "1m")
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 and ser[0].time > trade.time:
238
- continue
239
- ser.update(trade.time, trade.price, total_vol, bought_vol)
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, str] | None = None,
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 set_time_provider(self, time_provider: ITimeProvider) -> None:
2095
+ def set_context(self, context: "IStrategyContext") -> None:
2096
2096
  """
2097
- Set the time provider for the metric emitter.
2097
+ Set the strategy context for the metric emitter.
2098
2098
 
2099
- This method is used to set the time provider that will be used to get timestamps
2100
- when no explicit timestamp is provided in the emit method.
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
- time_provider: The time provider to use
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["drawdown_usd"] = dd_data
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
- sheet["funding_pnl"] = 100 * total_funding.iloc[-1] / init_cash # as percentage of initial capital
1427
- else:
1428
- sheet["funding_pnl"] = 0.0
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(3)
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,