Qubx 0.5.7__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 (100) hide show
  1. qubx/__init__.py +207 -0
  2. qubx/_nb_magic.py +100 -0
  3. qubx/backtester/__init__.py +5 -0
  4. qubx/backtester/account.py +145 -0
  5. qubx/backtester/broker.py +87 -0
  6. qubx/backtester/data.py +296 -0
  7. qubx/backtester/management.py +378 -0
  8. qubx/backtester/ome.py +296 -0
  9. qubx/backtester/optimization.py +201 -0
  10. qubx/backtester/simulated_data.py +558 -0
  11. qubx/backtester/simulator.py +362 -0
  12. qubx/backtester/utils.py +780 -0
  13. qubx/cli/__init__.py +0 -0
  14. qubx/cli/commands.py +67 -0
  15. qubx/connectors/ccxt/__init__.py +0 -0
  16. qubx/connectors/ccxt/account.py +495 -0
  17. qubx/connectors/ccxt/broker.py +132 -0
  18. qubx/connectors/ccxt/customizations.py +193 -0
  19. qubx/connectors/ccxt/data.py +612 -0
  20. qubx/connectors/ccxt/exceptions.py +17 -0
  21. qubx/connectors/ccxt/factory.py +93 -0
  22. qubx/connectors/ccxt/utils.py +307 -0
  23. qubx/core/__init__.py +0 -0
  24. qubx/core/account.py +251 -0
  25. qubx/core/basics.py +850 -0
  26. qubx/core/context.py +420 -0
  27. qubx/core/exceptions.py +38 -0
  28. qubx/core/helpers.py +480 -0
  29. qubx/core/interfaces.py +1150 -0
  30. qubx/core/loggers.py +514 -0
  31. qubx/core/lookups.py +475 -0
  32. qubx/core/metrics.py +1512 -0
  33. qubx/core/mixins/__init__.py +13 -0
  34. qubx/core/mixins/market.py +94 -0
  35. qubx/core/mixins/processing.py +428 -0
  36. qubx/core/mixins/subscription.py +203 -0
  37. qubx/core/mixins/trading.py +88 -0
  38. qubx/core/mixins/universe.py +270 -0
  39. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  40. qubx/core/series.pxd +125 -0
  41. qubx/core/series.pyi +118 -0
  42. qubx/core/series.pyx +988 -0
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/core/utils.pyi +6 -0
  45. qubx/core/utils.pyx +62 -0
  46. qubx/data/__init__.py +25 -0
  47. qubx/data/helpers.py +416 -0
  48. qubx/data/readers.py +1562 -0
  49. qubx/data/tardis.py +100 -0
  50. qubx/gathering/simplest.py +88 -0
  51. qubx/math/__init__.py +3 -0
  52. qubx/math/stats.py +129 -0
  53. qubx/pandaz/__init__.py +23 -0
  54. qubx/pandaz/ta.py +2757 -0
  55. qubx/pandaz/utils.py +638 -0
  56. qubx/resources/instruments/symbols-binance.cm.json +1 -0
  57. qubx/resources/instruments/symbols-binance.json +1 -0
  58. qubx/resources/instruments/symbols-binance.um.json +1 -0
  59. qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
  60. qubx/resources/instruments/symbols-bitfinex.json +1 -0
  61. qubx/resources/instruments/symbols-kraken.f.json +1 -0
  62. qubx/resources/instruments/symbols-kraken.json +1 -0
  63. qubx/ta/__init__.py +0 -0
  64. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  65. qubx/ta/indicators.pxd +149 -0
  66. qubx/ta/indicators.pyi +41 -0
  67. qubx/ta/indicators.pyx +787 -0
  68. qubx/trackers/__init__.py +3 -0
  69. qubx/trackers/abvanced.py +236 -0
  70. qubx/trackers/composite.py +146 -0
  71. qubx/trackers/rebalancers.py +129 -0
  72. qubx/trackers/riskctrl.py +641 -0
  73. qubx/trackers/sizers.py +235 -0
  74. qubx/utils/__init__.py +5 -0
  75. qubx/utils/_pyxreloader.py +281 -0
  76. qubx/utils/charting/lookinglass.py +1057 -0
  77. qubx/utils/charting/mpl_helpers.py +1183 -0
  78. qubx/utils/marketdata/binance.py +284 -0
  79. qubx/utils/marketdata/ccxt.py +90 -0
  80. qubx/utils/marketdata/dukas.py +130 -0
  81. qubx/utils/misc.py +541 -0
  82. qubx/utils/ntp.py +63 -0
  83. qubx/utils/numbers_utils.py +7 -0
  84. qubx/utils/orderbook.py +491 -0
  85. qubx/utils/plotting/__init__.py +0 -0
  86. qubx/utils/plotting/dashboard.py +150 -0
  87. qubx/utils/plotting/data.py +137 -0
  88. qubx/utils/plotting/interfaces.py +25 -0
  89. qubx/utils/plotting/renderers/__init__.py +0 -0
  90. qubx/utils/plotting/renderers/plotly.py +0 -0
  91. qubx/utils/runner/__init__.py +1 -0
  92. qubx/utils/runner/_jupyter_runner.pyt +60 -0
  93. qubx/utils/runner/accounts.py +88 -0
  94. qubx/utils/runner/configs.py +65 -0
  95. qubx/utils/runner/runner.py +470 -0
  96. qubx/utils/time.py +312 -0
  97. qubx-0.5.7.dist-info/METADATA +105 -0
  98. qubx-0.5.7.dist-info/RECORD +100 -0
  99. qubx-0.5.7.dist-info/WHEEL +4 -0
  100. qubx-0.5.7.dist-info/entry_points.txt +3 -0
qubx/core/basics.py ADDED
@@ -0,0 +1,850 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from enum import StrEnum
4
+ from queue import Empty, Queue
5
+ from threading import Event, Lock
6
+ from typing import Any, Literal, Optional, TypeAlias, Union
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from qubx.core.exceptions import QueueTimeout
12
+ from qubx.core.series import Bar, OrderBook, Quote, Trade, time_as_nsec
13
+ from qubx.core.utils import prec_ceil, prec_floor, time_delta_to_str
14
+ from qubx.utils.misc import Stopwatch
15
+ from qubx.utils.ntp import start_ntp_thread, time_now
16
+
17
+ dt_64 = np.datetime64
18
+ td_64 = np.timedelta64
19
+
20
+ OPTION_FILL_AT_SIGNAL_PRICE = "fill_at_signal_price"
21
+
22
+ SW = Stopwatch()
23
+
24
+
25
+ @dataclass
26
+ class Liquidation:
27
+ time: dt_64
28
+ quantity: float
29
+ price: float
30
+ side: int
31
+
32
+
33
+ @dataclass
34
+ class FundingRate:
35
+ time: dt_64
36
+ rate: float
37
+ interval: str
38
+ next_funding_time: dt_64
39
+ mark_price: float | None = None
40
+ index_price: float | None = None
41
+
42
+
43
+ @dataclass
44
+ class TimestampedDict:
45
+ """
46
+ Generic class for representing arbitrary data (as dict) with timestamp
47
+
48
+ TODO: probably we need to have generic interface for classes like Quote, Bar, .... etc
49
+ """
50
+
51
+ time: dt_64
52
+ data: dict[str, Any]
53
+
54
+
55
+ # Alias for timestamped data types used in Qubx
56
+ Timestamped: TypeAlias = Quote | Trade | Bar | OrderBook | TimestampedDict | FundingRate | Liquidation
57
+
58
+
59
+ @dataclass
60
+ class Signal:
61
+ """
62
+ Class for presenting signals generated by strategy
63
+
64
+ Attributes:
65
+ reference_price: float - exact price when signal was generated
66
+
67
+ Options:
68
+ - allow_override: bool - if True, and there is another signal for the same instrument, then override current.
69
+ """
70
+
71
+ instrument: "Instrument"
72
+ signal: float
73
+ price: float | None = None
74
+ stop: float | None = None
75
+ take: float | None = None
76
+ reference_price: float | None = None
77
+ group: str = ""
78
+ comment: str = ""
79
+ options: dict[str, Any] = field(default_factory=dict)
80
+
81
+ def __str__(self) -> str:
82
+ _p = f" @ { self.price }" if self.price is not None else ""
83
+ _s = f" stop: { self.stop }" if self.stop is not None else ""
84
+ _t = f" take: { self.take }" if self.take is not None else ""
85
+ _r = f" {self.reference_price:.2f}" if self.reference_price is not None else ""
86
+ _c = f" ({self.comment})" if self.comment else ""
87
+ return f"{self.group}{_r} {self.signal:+f} {self.instrument}{_p}{_s}{_t}{_c}"
88
+
89
+ def copy(self) -> "Signal":
90
+ """
91
+ Return a copy of the original signal
92
+ """
93
+ return Signal(
94
+ self.instrument,
95
+ self.signal,
96
+ self.price,
97
+ self.stop,
98
+ self.take,
99
+ self.reference_price,
100
+ self.group,
101
+ self.comment,
102
+ dict(self.options),
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class TargetPosition:
108
+ """
109
+ Class for presenting target position calculated from signal
110
+ """
111
+
112
+ time: dt_64 # time when position was set
113
+ signal: Signal # original signal
114
+ target_position_size: float # actual position size after processing in sizer
115
+ _is_service: bool = False
116
+
117
+ @staticmethod
118
+ def create(ctx: "ITimeProvider", signal: Signal, target_size: float) -> "TargetPosition":
119
+ return TargetPosition(ctx.time(), signal, signal.instrument.round_size_down(target_size))
120
+
121
+ @staticmethod
122
+ def zero(ctx: "ITimeProvider", signal: Signal) -> "TargetPosition":
123
+ return TargetPosition(ctx.time(), signal, 0.0)
124
+
125
+ @staticmethod
126
+ def service(ctx: "ITimeProvider", signal: Signal, size: float | None = None) -> "TargetPosition":
127
+ """
128
+ Generate just service position target (for logging purposes)
129
+ """
130
+ return TargetPosition(ctx.time(), signal, size if size else signal.signal, _is_service=True)
131
+
132
+ @property
133
+ def instrument(self) -> "Instrument":
134
+ return self.signal.instrument
135
+
136
+ @property
137
+ def price(self) -> float | None:
138
+ return self.signal.price
139
+
140
+ @property
141
+ def stop(self) -> float | None:
142
+ return self.signal.stop
143
+
144
+ @property
145
+ def take(self) -> float | None:
146
+ return self.signal.take
147
+
148
+ @property
149
+ def is_service(self) -> bool:
150
+ """
151
+ Some target may be used just for informative purposes (post-factum risk management etc)
152
+ """
153
+ return self._is_service
154
+
155
+ def __str__(self) -> str:
156
+ return f"{'::: INFORMATIVE ::: ' if self.is_service else ''}Target {self.target_position_size:+f} for {self.signal}"
157
+
158
+
159
+ class AssetType(StrEnum):
160
+ CRYPTO = "CRYPTO"
161
+ STOCK = "STOCK"
162
+ FX = "FX"
163
+ INDEX = "INDEX"
164
+
165
+
166
+ class MarketType(StrEnum):
167
+ SPOT = "SPOT"
168
+ MARGIN = "MARGIN"
169
+ SWAP = "SWAP"
170
+ FUTURE = "FUTURE"
171
+ OPTION = "OPTION"
172
+
173
+
174
+ @dataclass
175
+ class Instrument:
176
+ symbol: str
177
+ asset_type: AssetType
178
+ market_type: MarketType
179
+ exchange: str
180
+ base: str
181
+ quote: str
182
+ settle: str
183
+ exchange_symbol: str # symbol used by the exchange
184
+ tick_size: float # minimal price step
185
+ lot_size: float # minimal position size
186
+ min_size: float # minimal allowed position size
187
+ min_notional: float = 0.0 # minimal notional value
188
+ initial_margin: float = 0.0 # initial margin
189
+ maint_margin: float = 0.0 # maintenance margin
190
+ liquidation_fee: float = 0.0 # liquidation fee
191
+ contract_size: float = 1.0 # contract size
192
+ onboard_date: datetime | None = None
193
+ delivery_date: datetime | None = None
194
+
195
+ @property
196
+ def price_precision(self):
197
+ if not hasattr(self, "_price_precision"):
198
+ self._price_precision = int(abs(np.log10(self.tick_size)))
199
+ return self._price_precision
200
+
201
+ @property
202
+ def size_precision(self):
203
+ if not hasattr(self, "_size_precision"):
204
+ self._size_precision = int(abs(np.log10(self.lot_size)))
205
+ return self._size_precision
206
+
207
+ def is_futures(self) -> bool:
208
+ return self.market_type in [MarketType.FUTURE, MarketType.SWAP]
209
+
210
+ def is_spot(self) -> bool:
211
+ # TODO: handle margin better
212
+ return self.market_type in [MarketType.SPOT, MarketType.MARGIN]
213
+
214
+ def round_size_down(self, size: float) -> float:
215
+ """
216
+ Round down size to specified precision
217
+
218
+ i.size_precision == 3
219
+ i.round_size_up(0.1234) -> 0.123
220
+ """
221
+ return prec_floor(size, self.size_precision)
222
+
223
+ def round_size_up(self, size: float) -> float:
224
+ """
225
+ Round up size to specified precision
226
+
227
+ i.size_precision == 3
228
+ i.round_size_up(0.1234) -> 0.124
229
+ """
230
+ return prec_ceil(size, self.size_precision)
231
+
232
+ def round_price_down(self, price: float) -> float:
233
+ """
234
+ Round down price to specified precision
235
+
236
+ i.price_precision == 3
237
+ i.round_price_down(1.234999, 3) -> 1.234
238
+ """
239
+ return prec_floor(price, self.price_precision)
240
+
241
+ def round_price_up(self, price: float) -> float:
242
+ """
243
+ Round up price to specified precision
244
+
245
+ i.price_precision == 3
246
+ i.round_price_up(1.234999) -> 1.235
247
+ """
248
+ return prec_ceil(price, self.price_precision)
249
+
250
+ def signal(
251
+ self,
252
+ signal: float,
253
+ price: float | None = None,
254
+ stop: float | None = None,
255
+ take: float | None = None,
256
+ group: str = "",
257
+ comment: str = "",
258
+ options: dict[str, Any] | None = None, # - probably we need to remove it ?
259
+ **kwargs,
260
+ ) -> Signal:
261
+ return Signal(
262
+ instrument=self,
263
+ signal=signal,
264
+ price=price,
265
+ stop=stop,
266
+ take=take,
267
+ group=group,
268
+ comment=comment,
269
+ options=(options or {}) | kwargs,
270
+ )
271
+
272
+ def __hash__(self) -> int:
273
+ return hash((self.symbol, self.exchange, self.market_type))
274
+
275
+ def __eq__(self, other: Any) -> bool:
276
+ if other is None or not isinstance(other, Instrument):
277
+ return False
278
+ return str(self) == str(other)
279
+
280
+ def __str__(self) -> str:
281
+ return ":".join([self.exchange, self.market_type, self.symbol])
282
+
283
+ def __repr__(self) -> str:
284
+ return self.__str__()
285
+
286
+
287
+ class TransactionCostsCalculator:
288
+ """
289
+ A class for calculating transaction costs for a trading strategy.
290
+ Attributes
291
+ ----------
292
+ name : str
293
+ The name of the transaction costs calculator.
294
+ maker : float
295
+ The maker fee, as a percentage of the transaction value.
296
+ taker : float
297
+ The taker fee, as a percentage of the transaction value.
298
+
299
+ """
300
+
301
+ name: str
302
+ maker: float
303
+ taker: float
304
+
305
+ def __init__(self, name: str, maker: float, taker: float):
306
+ self.name = name
307
+ self.maker = maker / 100.0
308
+ self.taker = taker / 100.0
309
+
310
+ def get_execution_fees(
311
+ self, instrument: Instrument, exec_price: float, amount: float, crossed_market=False, conversion_rate=1.0
312
+ ):
313
+ if crossed_market:
314
+ return abs(amount * exec_price) * self.taker / conversion_rate
315
+ else:
316
+ return abs(amount * exec_price) * self.maker / conversion_rate
317
+
318
+ def get_overnight_fees(self, instrument: Instrument, amount: float):
319
+ return 0.0
320
+
321
+ def get_funding_rates_fees(self, instrument: Instrument, amount: float):
322
+ return 0.0
323
+
324
+ def __repr__(self):
325
+ return f"<{self.name}: {self.maker * 100:.4f} / {self.taker * 100:.4f}>"
326
+
327
+
328
+ ZERO_COSTS = TransactionCostsCalculator("Zero", 0.0, 0.0)
329
+
330
+
331
+ @dataclass
332
+ class TriggerEvent:
333
+ """
334
+ Event data for strategy trigger
335
+ """
336
+
337
+ time: dt_64
338
+ type: str
339
+ instrument: Optional[Instrument]
340
+ data: Optional[Any]
341
+
342
+
343
+ @dataclass
344
+ class MarketEvent:
345
+ """
346
+ Market data update.
347
+ """
348
+
349
+ time: dt_64
350
+ type: str
351
+ instrument: Instrument | None
352
+ data: Any
353
+ is_trigger: bool = False
354
+
355
+ def to_trigger(self) -> TriggerEvent:
356
+ return TriggerEvent(self.time, self.type, self.instrument, self.data)
357
+
358
+ def __repr__(self):
359
+ _items = [
360
+ f"time={self.time}",
361
+ f"type={self.type}",
362
+ ]
363
+ if self.instrument is not None:
364
+ _items.append(f"instrument={self.instrument}")
365
+ _items.append(f"data={self.data}")
366
+ return f"MarketEvent({', '.join(_items)})"
367
+
368
+
369
+ @dataclass
370
+ class Deal:
371
+ id: str # trade id
372
+ order_id: str # order's id
373
+ time: dt_64 # time of trade
374
+ amount: float # signed traded amount: positive for buy and negative for selling
375
+ price: float
376
+ aggressive: bool
377
+ fee_amount: float | None = None
378
+ fee_currency: str | None = None
379
+
380
+
381
+ OrderType = Literal["MARKET", "LIMIT", "STOP_MARKET", "STOP_LIMIT"]
382
+ OrderSide = Literal["BUY", "SELL"]
383
+ OrderStatus = Literal["OPEN", "CLOSED", "CANCELED", "NEW"]
384
+
385
+
386
+ @dataclass
387
+ class OrderRequest:
388
+ instrument: Instrument
389
+ quantity: float
390
+ price: float | None = None
391
+ options: dict[str, Any] = field(default_factory=dict)
392
+
393
+
394
+ @dataclass
395
+ class Order:
396
+ id: str
397
+ type: OrderType
398
+ instrument: Instrument
399
+ time: dt_64
400
+ quantity: float
401
+ price: float
402
+ side: OrderSide
403
+ status: OrderStatus
404
+ time_in_force: str
405
+ client_id: str | None = None
406
+ cost: float = 0.0
407
+ options: dict[str, Any] = field(default_factory=dict)
408
+
409
+ def __str__(self) -> str:
410
+ return f"[{self.id}] {self.type} {self.side} {self.quantity} of {self.instrument} {('@ ' + str(self.price)) if self.price > 0 else ''} ({self.time_in_force}) [{self.status}]"
411
+
412
+
413
+ @dataclass
414
+ class AssetBalance:
415
+ free: float = 0.0
416
+ locked: float = 0.0
417
+ total: float = 0.0
418
+
419
+ def __str__(self) -> str:
420
+ return f"free={self.free:.2f} locked={self.locked:.2f} total={self.total:.2f}"
421
+
422
+ def lock(self, lock_amount: float) -> None:
423
+ self.locked += lock_amount
424
+ self.free = self.total - self.locked
425
+
426
+ def __add__(self, amount: float) -> "AssetBalance":
427
+ self.total += amount
428
+ self.free += amount
429
+ return self
430
+
431
+ def __sub__(self, amount: float) -> "AssetBalance":
432
+ self.total -= amount
433
+ self.free -= amount
434
+ return self
435
+
436
+
437
+ MARKET_TYPE = Literal["SPOT", "MARGIN", "SWAP", "FUTURES", "OPTION"]
438
+
439
+
440
+ class Position:
441
+ instrument: Instrument # instrument for this position
442
+ quantity: float = 0.0 # quantity positive for long and negative for short
443
+ pnl: float = 0.0 # total cumulative position PnL in portfolio basic funds currency
444
+ r_pnl: float = 0.0 # total cumulative position PnL in portfolio basic funds currency
445
+ market_value: float = 0.0 # position's market value in quote currency
446
+ market_value_funds: float = 0.0 # position market value in portfolio funded currency
447
+ position_avg_price: float = 0.0 # average position price
448
+ position_avg_price_funds: float = 0.0 # average position price
449
+ commissions: float = 0.0 # cumulative commissions paid for this position
450
+
451
+ last_update_time: int = np.nan # when price updated or position changed # type: ignore
452
+ last_update_price: float = np.nan # last update price (actually instrument's price) in quoted currency
453
+ last_update_conversion_rate: float = np.nan # last update conversion rate
454
+
455
+ # margin requirements
456
+ maint_margin: float = 0.0
457
+
458
+ # - helpers for position processing
459
+ _qty_multiplier: float = 1.0
460
+ __pos_incr_qty: float = 0
461
+
462
+ def __init__(
463
+ self,
464
+ instrument: Instrument,
465
+ quantity: float = 0.0,
466
+ pos_average_price: float = 0.0,
467
+ r_pnl: float = 0.0,
468
+ ) -> None:
469
+ self.instrument = instrument
470
+
471
+ self.reset()
472
+ if quantity != 0.0 and pos_average_price > 0.0:
473
+ self.quantity = quantity
474
+ self.position_avg_price = pos_average_price
475
+ self.r_pnl = r_pnl
476
+
477
+ def reset(self) -> None:
478
+ """
479
+ Reset position to zero
480
+ """
481
+ self.quantity = 0.0
482
+ self.pnl = 0.0
483
+ self.r_pnl = 0.0
484
+ self.market_value = 0.0
485
+ self.market_value_funds = 0.0
486
+ self.position_avg_price = 0.0
487
+ self.position_avg_price_funds = 0.0
488
+ self.commissions = 0.0
489
+ self.last_update_time = np.nan # type: ignore
490
+ self.last_update_price = np.nan
491
+ self.last_update_conversion_rate = np.nan
492
+ self.maint_margin = 0.0
493
+ self.__pos_incr_qty = 0
494
+ self._qty_multiplier = self.instrument.contract_size
495
+
496
+ def reset_by_position(self, pos: "Position") -> None:
497
+ self.quantity = pos.quantity
498
+ self.pnl = pos.pnl
499
+ self.r_pnl = pos.r_pnl
500
+ self.market_value = pos.market_value
501
+ self.market_value_funds = pos.market_value_funds
502
+ self.position_avg_price = pos.position_avg_price
503
+ self.position_avg_price_funds = pos.position_avg_price_funds
504
+ self.commissions = pos.commissions
505
+ self.last_update_time = pos.last_update_time
506
+ self.last_update_price = pos.last_update_price
507
+ self.last_update_conversion_rate = pos.last_update_conversion_rate
508
+ self.maint_margin = pos.maint_margin
509
+ self.__pos_incr_qty = pos.__pos_incr_qty
510
+
511
+ @property
512
+ def notional_value(self) -> float:
513
+ return self.quantity * self.last_update_price / self.last_update_conversion_rate
514
+
515
+ def _price(self, update: Quote | Trade) -> float:
516
+ if isinstance(update, Quote):
517
+ return update.bid if np.sign(self.quantity) > 0 else update.ask
518
+ elif isinstance(update, Trade):
519
+ return update.price
520
+ raise ValueError(f"Unknown update type: {type(update)}")
521
+
522
+ def change_position_by(
523
+ self, timestamp: dt_64, amount: float, exec_price: float, fee_amount: float = 0, conversion_rate: float = 1
524
+ ) -> tuple[float, float]:
525
+ return self.update_position(
526
+ timestamp,
527
+ self.instrument.round_size_down(self.quantity + amount),
528
+ exec_price,
529
+ fee_amount,
530
+ conversion_rate=conversion_rate,
531
+ )
532
+
533
+ def update_position(
534
+ self, timestamp: dt_64, position: float, exec_price: float, fee_amount: float = 0, conversion_rate: float = 1
535
+ ) -> tuple[float, float]:
536
+ # - realized PnL of this fill
537
+ deal_pnl = 0
538
+ quantity = self.quantity
539
+ comms = 0
540
+
541
+ if quantity != position:
542
+ pos_change = position - quantity
543
+ direction = np.sign(pos_change)
544
+ prev_direction = np.sign(quantity)
545
+
546
+ # how many shares are closed/open
547
+ qty_closing = min(abs(self.quantity), abs(pos_change)) * direction if prev_direction != direction else 0
548
+ qty_opening = pos_change if prev_direction == direction else pos_change - qty_closing
549
+
550
+ # - extract realized part of PnL
551
+ if qty_closing != 0:
552
+ _abs_qty_close = abs(qty_closing)
553
+ deal_pnl = qty_closing * (self.position_avg_price - exec_price)
554
+
555
+ quantity += qty_closing
556
+ self.__pos_incr_qty -= _abs_qty_close
557
+
558
+ # - reset average price to 0 if smaller than minimal price change to avoid cumulative error
559
+ if abs(quantity) < self.instrument.lot_size:
560
+ quantity = 0.0
561
+ self.position_avg_price = 0.0
562
+ self.__pos_incr_qty = 0
563
+
564
+ # - if it has something to add to position let's update price and cost
565
+ if qty_opening != 0:
566
+ _abs_qty_open = abs(qty_opening)
567
+ pos_avg_price_raw = (_abs_qty_open * exec_price + self.__pos_incr_qty * self.position_avg_price) / (
568
+ self.__pos_incr_qty + _abs_qty_open
569
+ )
570
+ # - round position average price to be in line with how it's calculated by broker
571
+ self.position_avg_price = self.instrument.round_price_down(pos_avg_price_raw)
572
+ self.__pos_incr_qty += _abs_qty_open
573
+
574
+ # - update position and position's price
575
+ self.position_avg_price_funds = self.position_avg_price / conversion_rate
576
+ self.quantity = position
577
+
578
+ # - convert PnL to fund currency
579
+ self.r_pnl += deal_pnl / conversion_rate
580
+
581
+ # - update pnl
582
+ self.update_market_price(time_as_nsec(timestamp), exec_price, conversion_rate)
583
+
584
+ # - calculate transaction costs
585
+ comms = fee_amount / conversion_rate
586
+ self.commissions += comms
587
+
588
+ return deal_pnl, comms
589
+
590
+ def update_market_price_by_tick(self, tick: Quote | Trade, conversion_rate: float = 1) -> float:
591
+ return self.update_market_price(tick.time, self._price(tick), conversion_rate)
592
+
593
+ def update_position_by_deal(self, deal: Deal, conversion_rate: float = 1) -> tuple[float, float]:
594
+ time = deal.time.as_unit("ns").asm8 if isinstance(deal.time, pd.Timestamp) else deal.time
595
+ return self.change_position_by(
596
+ timestamp=time,
597
+ amount=deal.amount,
598
+ exec_price=deal.price,
599
+ fee_amount=deal.fee_amount or 0,
600
+ conversion_rate=conversion_rate,
601
+ )
602
+ # - deal contains cumulative amount
603
+ # return self.update_position(time, deal.amount, deal.price, deal.aggressive, conversion_rate)
604
+
605
+ def update_market_price(self, timestamp: dt_64, price: float, conversion_rate: float) -> float:
606
+ self.last_update_time = timestamp # type: ignore
607
+ self.last_update_price = price
608
+ self.last_update_conversion_rate = conversion_rate
609
+
610
+ if not np.isnan(price):
611
+ u_pnl = self.unrealized_pnl()
612
+ self.pnl = u_pnl + self.r_pnl
613
+ if self.instrument.is_futures():
614
+ # for derivatives market value of the position is the current unrealized PnL
615
+ self.market_value = u_pnl
616
+ else:
617
+ # for spot: market value is the current value of the position
618
+ # TODO: implement market value calculation for margin
619
+ self.market_value = self.quantity * self.last_update_price * self._qty_multiplier
620
+
621
+ # calculate mkt value in funded currency
622
+ self.market_value_funds = self.market_value / conversion_rate
623
+
624
+ # - update margin requirements
625
+ self._update_maint_margin()
626
+
627
+ return self.pnl
628
+
629
+ def total_pnl(self) -> float:
630
+ # TODO: account for commissions
631
+ return self.r_pnl + self.unrealized_pnl()
632
+
633
+ def unrealized_pnl(self) -> float:
634
+ if not np.isnan(self.last_update_price):
635
+ return self.quantity * (self.last_update_price - self.position_avg_price) / self.last_update_conversion_rate # type: ignore
636
+ return 0.0
637
+
638
+ def is_open(self) -> bool:
639
+ return abs(self.quantity) > self.instrument.min_size
640
+
641
+ def get_amount_released_funds_after_closing(self, to_remain: float = 0.0) -> float:
642
+ """
643
+ Estimate how much funds would be released if part of position closed
644
+ """
645
+ d = np.sign(self.quantity)
646
+ funds_release = self.market_value_funds
647
+ if to_remain != 0 and self.quantity != 0 and np.sign(to_remain) == d:
648
+ qty_to_release = max(self.quantity - to_remain, 0) if d > 0 else min(self.quantity - to_remain, 0)
649
+ funds_release = qty_to_release * self.last_update_price / self.last_update_conversion_rate
650
+ return abs(funds_release)
651
+
652
+ @staticmethod
653
+ def _t2s(t) -> str:
654
+ return (
655
+ np.datetime64(t, "ns").astype("datetime64[ms]").item().strftime("%Y-%m-%d %H:%M:%S")
656
+ if not np.isnan(t)
657
+ else "???"
658
+ )
659
+
660
+ def __str__(self):
661
+ return " ".join(
662
+ [
663
+ f"{self._t2s(self.last_update_time)}",
664
+ f"[{self.instrument}]",
665
+ f"qty={self.quantity:.{self.instrument.size_precision}f}",
666
+ f"entryPrice={self.position_avg_price:.{self.instrument.price_precision}f}",
667
+ f"price={self.last_update_price:.{self.instrument.price_precision}f}",
668
+ f"pnl={self.unrealized_pnl():.2f}",
669
+ f"value={self.market_value_funds:.2f}",
670
+ ]
671
+ )
672
+
673
+ def __repr__(self):
674
+ return self.__str__()
675
+
676
+ def _update_maint_margin(self) -> None:
677
+ if self.instrument.maint_margin:
678
+ self.maint_margin = (
679
+ self.instrument.maint_margin * self._qty_multiplier * abs(self.quantity) * self.last_update_price
680
+ )
681
+
682
+
683
+ class CtrlChannel:
684
+ """
685
+ Controlled data communication channel
686
+ """
687
+
688
+ control: Event
689
+ _queue: Queue # we need something like disruptor here (Queue is temporary)
690
+ name: str
691
+ lock: Lock
692
+
693
+ def __init__(self, name: str, sentinel=(None, None, None, None)):
694
+ self.name = name
695
+ self.control = Event()
696
+ self.lock = Lock()
697
+ self._sent = sentinel
698
+ self._queue = Queue()
699
+ self.start()
700
+
701
+ def register(self, callback):
702
+ pass
703
+
704
+ def stop(self):
705
+ if self.control.is_set():
706
+ self.control.clear()
707
+ self._queue.put(self._sent) # send sentinel
708
+
709
+ def start(self):
710
+ self.control.set()
711
+
712
+ def send(self, data):
713
+ if self.control.is_set():
714
+ self._queue.put(data)
715
+
716
+ def receive(self, timeout: int | None = None) -> Any:
717
+ try:
718
+ return self._queue.get(timeout=timeout)
719
+ except Empty:
720
+ raise QueueTimeout(f"Timeout waiting for data on {self.name} channel")
721
+
722
+
723
+ class ITimeProvider:
724
+ """
725
+ Generic interface for providing current time
726
+ """
727
+
728
+ def time(self) -> dt_64:
729
+ """
730
+ Returns current time
731
+ """
732
+ ...
733
+
734
+
735
+ class DataType(StrEnum):
736
+ """
737
+ Data type constants. Used for specifying the type of data and can be used for subscription to.
738
+ Special value `DataType.ALL` can be used to subscribe to all available data types
739
+ that are currently in use by the broker for other instruments.
740
+ """
741
+
742
+ ALL = "__all__"
743
+ NONE = "__none__"
744
+ QUOTE = "quote"
745
+ TRADE = "trade"
746
+ OHLC = "ohlc"
747
+ ORDERBOOK = "orderbook"
748
+ LIQUIDATION = "liquidation"
749
+ FUNDING_RATE = "funding_rate"
750
+ OHLC_QUOTES = "ohlc_quotes" # when we want to emulate quotes from OHLC data
751
+ OHLC_TRADES = "ohlc_trades" # when we want to emulate trades from OHLC data
752
+ RECORD = "record" # arbitrary timestamped data (actually liquidation and funding rates fall into this type)
753
+
754
+ def __repr__(self) -> str:
755
+ return self.value
756
+
757
+ def __str__(self) -> str:
758
+ return self.value
759
+
760
+ def __eq__(self, other: Any) -> bool:
761
+ if isinstance(other, DataType):
762
+ return self.value == other.value
763
+ return self.value == DataType.from_str(other)[0].value
764
+
765
+ def __hash__(self) -> int:
766
+ return hash(self.value)
767
+
768
+ def __getitem__(self, *args, **kwargs) -> str:
769
+ match self:
770
+ case DataType.OHLC | DataType.OHLC_QUOTES:
771
+ tf = args[0] if args else kwargs.get("timeframe")
772
+ if not tf:
773
+ raise ValueError("Timeframe is not provided for OHLC subscription")
774
+ return f"{self.value}({tf})"
775
+ case DataType.ORDERBOOK:
776
+ if len(args) == 2:
777
+ tick_size_pct, depth = args
778
+ elif len(args) > 0:
779
+ raise ValueError(f"Invalid arguments for ORDERBOOK subscription: {args}")
780
+ else:
781
+ tick_size_pct = kwargs.get("tick_size_pct", 0.01)
782
+ depth = kwargs.get("depth", 200)
783
+ return f"{self.value}({tick_size_pct}, {depth})"
784
+ case _:
785
+ return self.value
786
+
787
+ @staticmethod
788
+ def from_str(value: Union[str, "DataType"]) -> tuple["DataType", dict[str, Any]]:
789
+ """
790
+ Parse subscription type from string.
791
+ Returns: (subtype, params)
792
+
793
+ Example:
794
+ >>> Subtype.from_str("ohlc(1Min)")
795
+ (Subtype.OHLC, {"timeframe": "1Min"})
796
+
797
+ >>> Subtype.from_str("orderbook(0.01, 100)")
798
+ (Subtype.ORDERBOOK, {"tick_size_pct": 0.01, "depth": 100})
799
+
800
+ >>> Subtype.from_str("quote")
801
+ (Subtype.QUOTE, {})
802
+ """
803
+ if isinstance(value, DataType):
804
+ return value, {}
805
+ try:
806
+ _value = value.lower()
807
+ _has_params = DataType._str_has_params(value)
808
+ if not _has_params and value.upper() not in DataType.__members__:
809
+ return DataType.NONE, {}
810
+ elif not _has_params:
811
+ return DataType(_value), {}
812
+ else:
813
+ type_name, params_str = value.split("(", 1)
814
+ params = [p.strip() for p in params_str.rstrip(")").split(",")]
815
+ match type_name.lower():
816
+ case DataType.OHLC.value:
817
+ return DataType.OHLC, {"timeframe": time_delta_to_str(pd.Timedelta(params[0]).asm8.item())}
818
+
819
+ case DataType.OHLC_QUOTES.value:
820
+ return DataType.OHLC_QUOTES, {
821
+ "timeframe": time_delta_to_str(pd.Timedelta(params[0]).asm8.item())
822
+ }
823
+
824
+ case DataType.OHLC_TRADES.value:
825
+ return DataType.OHLC_TRADES, {
826
+ "timeframe": time_delta_to_str(pd.Timedelta(params[0]).asm8.item())
827
+ }
828
+
829
+ case DataType.ORDERBOOK.value:
830
+ return DataType.ORDERBOOK, {"tick_size_pct": float(params[0]), "depth": int(params[1])}
831
+
832
+ case _:
833
+ return DataType.NONE, {}
834
+ except IndexError:
835
+ raise ValueError(f"Invalid subscription type: {value}")
836
+
837
+ @staticmethod
838
+ def _str_has_params(value: str) -> bool:
839
+ return "(" in value
840
+
841
+
842
+ class LiveTimeProvider(ITimeProvider):
843
+ def __init__(self):
844
+ self._start_ntp_thread()
845
+
846
+ def time(self) -> dt_64:
847
+ return time_now()
848
+
849
+ def _start_ntp_thread(self):
850
+ start_ntp_thread()