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.
- qubx/__init__.py +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- 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()
|