Qubx 0.6.58__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.60__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/simulator.py +7 -6
- qubx/backtester/utils.py +1 -1
- qubx/connectors/ccxt/data.py +119 -11
- qubx/core/basics.py +131 -60
- qubx/core/context.py +17 -0
- qubx/core/interfaces.py +43 -3
- qubx/core/loggers.py +64 -36
- qubx/core/metrics.py +20 -9
- qubx/core/mixins/processing.py +180 -83
- qubx/core/mixins/universe.py +12 -5
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/emitters/__init__.py +9 -1
- qubx/emitters/indicator.py +213 -0
- qubx/gathering/simplest.py +1 -1
- qubx/loggers/csv.py +22 -7
- qubx/loggers/inmemory.py +18 -6
- qubx/loggers/mongo.py +2 -1
- qubx/restarts/state_resolvers.py +62 -25
- qubx/restarts/time_finders.py +47 -4
- qubx/restorers/interfaces.py +8 -2
- qubx/restorers/signal.py +209 -126
- qubx/restorers/state.py +25 -9
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/trackers/advanced.py +4 -5
- qubx/trackers/composite.py +4 -4
- qubx/trackers/riskctrl.py +151 -37
- qubx/trackers/sizers.py +8 -8
- qubx/utils/runner/_jupyter_runner.pyt +1 -1
- qubx/utils/runner/runner.py +3 -2
- {qubx-0.6.58.dist-info → qubx-0.6.60.dist-info}/METADATA +1 -1
- {qubx-0.6.58.dist-info → qubx-0.6.60.dist-info}/RECORD +35 -34
- {qubx-0.6.58.dist-info → qubx-0.6.60.dist-info}/LICENSE +0 -0
- {qubx-0.6.58.dist-info → qubx-0.6.60.dist-info}/WHEEL +0 -0
- {qubx-0.6.58.dist-info → qubx-0.6.60.dist-info}/entry_points.txt +0 -0
qubx/backtester/simulator.py
CHANGED
|
@@ -265,12 +265,13 @@ def _run_setup(
|
|
|
265
265
|
stop,
|
|
266
266
|
setup.exchanges,
|
|
267
267
|
setup.instruments,
|
|
268
|
-
setup.capital,
|
|
269
|
-
setup.base_currency,
|
|
270
|
-
commissions_for_result,
|
|
271
|
-
runner.logs_writer.get_portfolio(as_plain_dataframe=True),
|
|
272
|
-
runner.logs_writer.get_executions(),
|
|
273
|
-
runner.logs_writer.get_signals(),
|
|
268
|
+
capital=setup.capital,
|
|
269
|
+
base_currency=setup.base_currency,
|
|
270
|
+
commissions=commissions_for_result,
|
|
271
|
+
portfolio_log=runner.logs_writer.get_portfolio(as_plain_dataframe=True),
|
|
272
|
+
executions_log=runner.logs_writer.get_executions(),
|
|
273
|
+
signals_log=runner.logs_writer.get_signals(),
|
|
274
|
+
targets_log=runner.logs_writer.get_targets(),
|
|
274
275
|
strategy_class=runner.strategy_class,
|
|
275
276
|
parameters=runner.strategy_params,
|
|
276
277
|
is_simulation=True,
|
qubx/backtester/utils.py
CHANGED
|
@@ -209,7 +209,7 @@ class SignalsProxy(IStrategy):
|
|
|
209
209
|
signal = event.data.get("order")
|
|
210
210
|
# - TODO: also need to think about how to pass stop/take here
|
|
211
211
|
if signal is not None and event.instrument:
|
|
212
|
-
return [event.instrument.signal(signal)]
|
|
212
|
+
return [event.instrument.signal(ctx, signal)]
|
|
213
213
|
return None
|
|
214
214
|
|
|
215
215
|
|
qubx/connectors/ccxt/data.py
CHANGED
|
@@ -44,10 +44,12 @@ class CcxtDataProvider(IDataProvider):
|
|
|
44
44
|
|
|
45
45
|
# - subscriptions
|
|
46
46
|
_subscriptions: Dict[str, Set[Instrument]]
|
|
47
|
+
_pending_subscriptions: Dict[str, Set[Instrument]] # Track subscriptions being established
|
|
47
48
|
_sub_to_coro: Dict[str, concurrent.futures.Future]
|
|
48
49
|
_sub_to_name: Dict[str, str]
|
|
49
50
|
_sub_to_unsubscribe: Dict[str, Callable[[], Awaitable[None]]]
|
|
50
51
|
_is_sub_name_enabled: Dict[str, bool]
|
|
52
|
+
_sub_connection_ready: Dict[str, bool] # Track if connection is actually ready
|
|
51
53
|
|
|
52
54
|
_sub_instr_to_time: Dict[Tuple[str, Instrument], dt_64]
|
|
53
55
|
_last_quotes: Dict[Instrument, Optional[Quote]]
|
|
@@ -80,10 +82,12 @@ class CcxtDataProvider(IDataProvider):
|
|
|
80
82
|
|
|
81
83
|
self._last_quotes = defaultdict(lambda: None)
|
|
82
84
|
self._subscriptions = defaultdict(set)
|
|
85
|
+
self._pending_subscriptions = defaultdict(set)
|
|
83
86
|
self._sub_to_coro = {}
|
|
84
87
|
self._sub_to_name = {}
|
|
85
88
|
self._sub_to_unsubscribe = {}
|
|
86
89
|
self._is_sub_name_enabled = defaultdict(lambda: False)
|
|
90
|
+
self._sub_connection_ready = defaultdict(lambda: False)
|
|
87
91
|
self._symbol_to_instrument = {}
|
|
88
92
|
self._subscribers = {
|
|
89
93
|
n.split("_subscribe_")[1]: f
|
|
@@ -135,11 +139,33 @@ class CcxtDataProvider(IDataProvider):
|
|
|
135
139
|
def get_subscribed_instruments(self, subscription_type: str | None = None) -> list[Instrument]:
|
|
136
140
|
if not subscription_type:
|
|
137
141
|
return list(self.subscribed_instruments)
|
|
138
|
-
|
|
142
|
+
|
|
143
|
+
# Return active subscriptions, fallback to pending if no active ones
|
|
144
|
+
_sub_type, _ = DataType.from_str(subscription_type)
|
|
145
|
+
if _sub_type in self._subscriptions:
|
|
146
|
+
return list(self._subscriptions[_sub_type])
|
|
147
|
+
elif _sub_type in self._pending_subscriptions:
|
|
148
|
+
return list(self._pending_subscriptions[_sub_type])
|
|
149
|
+
else:
|
|
150
|
+
return []
|
|
139
151
|
|
|
140
152
|
def has_subscription(self, instrument: Instrument, subscription_type: str) -> bool:
|
|
141
153
|
sub_type, _ = DataType.from_str(subscription_type)
|
|
142
|
-
return
|
|
154
|
+
# Only return True if subscription is actually active (not just pending)
|
|
155
|
+
return (
|
|
156
|
+
sub_type in self._subscriptions
|
|
157
|
+
and instrument in self._subscriptions[sub_type]
|
|
158
|
+
and self._sub_connection_ready.get(sub_type, False)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def has_pending_subscription(self, instrument: Instrument, subscription_type: str) -> bool:
|
|
162
|
+
"""Check if a subscription is pending (connection being established)."""
|
|
163
|
+
sub_type, _ = DataType.from_str(subscription_type)
|
|
164
|
+
return (
|
|
165
|
+
sub_type in self._pending_subscriptions
|
|
166
|
+
and instrument in self._pending_subscriptions[sub_type]
|
|
167
|
+
and not self._sub_connection_ready.get(subscription_type, False)
|
|
168
|
+
)
|
|
143
169
|
|
|
144
170
|
def warmup(self, warmups: Dict[Tuple[str, Instrument], str]) -> None:
|
|
145
171
|
_coros = []
|
|
@@ -207,9 +233,9 @@ class CcxtDataProvider(IDataProvider):
|
|
|
207
233
|
|
|
208
234
|
@property
|
|
209
235
|
def subscribed_instruments(self) -> Set[Instrument]:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return
|
|
236
|
+
active = set.union(*self._subscriptions.values()) if self._subscriptions else set()
|
|
237
|
+
pending = set.union(*self._pending_subscriptions.values()) if self._pending_subscriptions else set()
|
|
238
|
+
return active.union(pending)
|
|
213
239
|
|
|
214
240
|
@property
|
|
215
241
|
def is_read_only(self) -> bool:
|
|
@@ -226,19 +252,29 @@ class CcxtDataProvider(IDataProvider):
|
|
|
226
252
|
if _subscriber is None:
|
|
227
253
|
raise ValueError(f"{self._exchange_id}: Subscription type {sub_type} is not supported")
|
|
228
254
|
|
|
255
|
+
# Save old subscription state before starting cleanup
|
|
256
|
+
old_sub_info = None
|
|
229
257
|
if sub_type in self._sub_to_coro:
|
|
258
|
+
old_sub_info = {"name": self._sub_to_name[sub_type], "coro": self._sub_to_coro[sub_type]}
|
|
230
259
|
logger.debug(
|
|
231
|
-
f"<yellow>{self._exchange_id}</yellow> Canceling existing {sub_type} subscription for {self._subscriptions
|
|
260
|
+
f"<yellow>{self._exchange_id}</yellow> Canceling existing {sub_type} subscription for {self._subscriptions.get(_sub_type, set())}"
|
|
232
261
|
)
|
|
233
|
-
|
|
234
|
-
|
|
262
|
+
|
|
263
|
+
# Clear state immediately to prevent interference with new subscription
|
|
235
264
|
del self._sub_to_coro[sub_type]
|
|
236
265
|
del self._sub_to_name[sub_type]
|
|
237
|
-
|
|
266
|
+
# Clean up both active and pending subscriptions
|
|
267
|
+
self._subscriptions.pop(_sub_type, None)
|
|
268
|
+
self._pending_subscriptions.pop(_sub_type, None)
|
|
269
|
+
self._sub_connection_ready.pop(sub_type, None)
|
|
238
270
|
|
|
239
271
|
if instruments is not None and len(instruments) == 0:
|
|
240
272
|
return
|
|
241
273
|
|
|
274
|
+
# Mark subscription as pending (not active yet)
|
|
275
|
+
self._pending_subscriptions[_sub_type] = instruments
|
|
276
|
+
self._sub_connection_ready[sub_type] = False
|
|
277
|
+
|
|
242
278
|
kwargs = {"instruments": instruments, **_params}
|
|
243
279
|
_subscriber = self._subscribers[_sub_type]
|
|
244
280
|
_subscriber_params = set(_subscriber.__code__.co_varnames[: _subscriber.__code__.co_argcount])
|
|
@@ -247,7 +283,12 @@ class CcxtDataProvider(IDataProvider):
|
|
|
247
283
|
self._sub_to_name[sub_type] = (name := self._get_subscription_name(_sub_type, **kwargs))
|
|
248
284
|
self._sub_to_coro[sub_type] = self._loop.submit(_subscriber(self, name, _sub_type, self.channel, **kwargs))
|
|
249
285
|
|
|
250
|
-
|
|
286
|
+
# Now stop the old subscriber after new one is started (to avoid interference)
|
|
287
|
+
if old_sub_info is not None:
|
|
288
|
+
# Stop old subscriber in background to avoid blocking
|
|
289
|
+
self._loop.submit(self._stop_old_subscriber(old_sub_info["name"], old_sub_info["coro"]))
|
|
290
|
+
|
|
291
|
+
# Don't set _subscriptions here - it will be set when connection is established
|
|
251
292
|
|
|
252
293
|
def _time_msec_nbars_back(self, timeframe: str, nbarsback: int = 1) -> int:
|
|
253
294
|
return (self.time_provider.time() - nbarsback * pd.Timedelta(timeframe)).asm8.item() // 1000000
|
|
@@ -278,6 +319,14 @@ class CcxtDataProvider(IDataProvider):
|
|
|
278
319
|
_name += f" ({kwargs_str})"
|
|
279
320
|
return _name
|
|
280
321
|
|
|
322
|
+
def _mark_subscription_active(self, sub_type: str) -> None:
|
|
323
|
+
"""Mark a subscription as active once the WebSocket connection is established."""
|
|
324
|
+
_sub_type, _ = DataType.from_str(sub_type)
|
|
325
|
+
if _sub_type in self._pending_subscriptions:
|
|
326
|
+
self._subscriptions[_sub_type] = self._pending_subscriptions[_sub_type]
|
|
327
|
+
self._sub_connection_ready[sub_type] = True
|
|
328
|
+
logger.debug(f"<yellow>{self._exchange_id}</yellow> Subscription {sub_type} is now active")
|
|
329
|
+
|
|
281
330
|
async def _stop_subscriber(self, sub_type: str, sub_name: str) -> None:
|
|
282
331
|
try:
|
|
283
332
|
self._is_sub_name_enabled[sub_name] = False # stop the subscriber
|
|
@@ -303,11 +352,57 @@ class CcxtDataProvider(IDataProvider):
|
|
|
303
352
|
del self._sub_to_unsubscribe[sub_name]
|
|
304
353
|
|
|
305
354
|
del self._is_sub_name_enabled[sub_name]
|
|
355
|
+
|
|
356
|
+
# Clean up connection state for this subscription
|
|
357
|
+
for sub_type, stream_name in list(self._sub_to_name.items()):
|
|
358
|
+
if stream_name == sub_name:
|
|
359
|
+
self._sub_connection_ready.pop(sub_type, None)
|
|
360
|
+
break
|
|
361
|
+
|
|
306
362
|
logger.debug(f"<yellow>{self._exchange_id}</yellow> Unsubscribed from {sub_name}")
|
|
307
363
|
except Exception as e:
|
|
308
364
|
logger.error(f"<yellow>{self._exchange_id}</yellow> Error stopping {sub_name}")
|
|
309
365
|
logger.exception(e)
|
|
310
366
|
|
|
367
|
+
async def _stop_old_subscriber(self, old_name: str, old_coro: concurrent.futures.Future) -> None:
|
|
368
|
+
"""Stop an old subscriber safely without interfering with new subscriptions."""
|
|
369
|
+
try:
|
|
370
|
+
# Disable the old stream by name
|
|
371
|
+
self._is_sub_name_enabled[old_name] = False
|
|
372
|
+
|
|
373
|
+
# Wait for the old coroutine to finish
|
|
374
|
+
total_sleep_time = 0.0
|
|
375
|
+
while old_coro.running():
|
|
376
|
+
await asyncio.sleep(1.0)
|
|
377
|
+
total_sleep_time += 1.0
|
|
378
|
+
if total_sleep_time >= 20.0:
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
if old_coro.running():
|
|
382
|
+
logger.warning(
|
|
383
|
+
f"<yellow>{self._exchange_id}</yellow> Old subscriber {old_name} is still running. Cancelling it."
|
|
384
|
+
)
|
|
385
|
+
old_coro.cancel()
|
|
386
|
+
else:
|
|
387
|
+
logger.debug(f"<yellow>{self._exchange_id}</yellow> Old subscriber {old_name} has been stopped")
|
|
388
|
+
|
|
389
|
+
# Clean up old unsubscriber if it exists
|
|
390
|
+
if old_name in self._sub_to_unsubscribe:
|
|
391
|
+
logger.debug(f"<yellow>{self._exchange_id}</yellow> Calling old unsubscriber for {old_name}")
|
|
392
|
+
await self._sub_to_unsubscribe[old_name]()
|
|
393
|
+
# Use pop to safely remove, in case it was already removed
|
|
394
|
+
self._sub_to_unsubscribe.pop(old_name, None)
|
|
395
|
+
|
|
396
|
+
# Clean up old stream state
|
|
397
|
+
if old_name in self._is_sub_name_enabled:
|
|
398
|
+
del self._is_sub_name_enabled[old_name]
|
|
399
|
+
|
|
400
|
+
logger.debug(f"<yellow>{self._exchange_id}</yellow> Old subscription {old_name} cleaned up")
|
|
401
|
+
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.error(f"<yellow>{self._exchange_id}</yellow> Error stopping old subscriber {old_name}")
|
|
404
|
+
logger.exception(e)
|
|
405
|
+
|
|
311
406
|
async def _listen_to_stream(
|
|
312
407
|
self,
|
|
313
408
|
subscriber: Callable[[], Awaitable[None]],
|
|
@@ -322,10 +417,22 @@ class CcxtDataProvider(IDataProvider):
|
|
|
322
417
|
|
|
323
418
|
self._is_sub_name_enabled[name] = True
|
|
324
419
|
n_retry = 0
|
|
420
|
+
connection_established = False
|
|
421
|
+
|
|
325
422
|
while channel.control.is_set() and self._is_sub_name_enabled[name]:
|
|
326
423
|
try:
|
|
327
424
|
await subscriber()
|
|
328
425
|
n_retry = 0
|
|
426
|
+
|
|
427
|
+
# Mark subscription as active on first successful data reception
|
|
428
|
+
if not connection_established:
|
|
429
|
+
# Find the subscription type for this stream name
|
|
430
|
+
for sub_type, stream_name in self._sub_to_name.items():
|
|
431
|
+
if stream_name == name:
|
|
432
|
+
self._mark_subscription_active(sub_type)
|
|
433
|
+
connection_established = True
|
|
434
|
+
break
|
|
435
|
+
|
|
329
436
|
if not self._is_sub_name_enabled[name]:
|
|
330
437
|
break
|
|
331
438
|
except CcxtSymbolNotRecognized:
|
|
@@ -616,7 +723,8 @@ class CcxtDataProvider(IDataProvider):
|
|
|
616
723
|
for exch_symbol, ccxt_ticker in ccxt_tickers.items(): # type: ignore
|
|
617
724
|
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
618
725
|
quote = ccxt_convert_ticker(ccxt_ticker)
|
|
619
|
-
|
|
726
|
+
last_quote = self._last_quotes[instrument]
|
|
727
|
+
if last_quote is None or quote.time > last_quote.time:
|
|
620
728
|
self._health_monitor.record_data_arrival(sub_type, dt_64(quote.time, "ns"))
|
|
621
729
|
self._last_quotes[instrument] = quote
|
|
622
730
|
channel.send((instrument, sub_type, quote, False))
|
qubx/core/basics.py
CHANGED
|
@@ -56,22 +56,73 @@ class TimestampedDict:
|
|
|
56
56
|
data: dict[str, Any]
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class ITimeProvider:
|
|
60
|
+
"""
|
|
61
|
+
Generic interface for providing current time
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def time(self) -> dt_64:
|
|
65
|
+
"""
|
|
66
|
+
Returns current time
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
# Alias for timestamped data types used in Qubx
|
|
60
72
|
Timestamped: TypeAlias = Quote | Trade | Bar | OrderBook | TimestampedDict | FundingRate | Liquidation
|
|
61
73
|
|
|
62
74
|
|
|
75
|
+
@dataclass
|
|
76
|
+
class TargetPosition:
|
|
77
|
+
"""
|
|
78
|
+
Class for presenting target position calculated from signal
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
time: dt_64 | str # time when position was created
|
|
82
|
+
instrument: "Instrument"
|
|
83
|
+
target_position_size: float # actual position size after processing in sizer
|
|
84
|
+
entry_price: float | None = None
|
|
85
|
+
stop_price: float | None = None
|
|
86
|
+
take_price: float | None = None
|
|
87
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def price(self) -> float | None:
|
|
91
|
+
return self.entry_price
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def stop(self) -> float | None:
|
|
95
|
+
return self.stop_price
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def take(self) -> float | None:
|
|
99
|
+
return self.take_price
|
|
100
|
+
|
|
101
|
+
def __str__(self) -> str:
|
|
102
|
+
_d = f"{pd.Timestamp(self.time).strftime('%Y-%m-%d %H:%M:%S.%f')}"
|
|
103
|
+
_p = f" @ {self.entry_price}" if self.entry_price is not None else ""
|
|
104
|
+
_s = f" stop: {self.stop_price}" if self.stop_price is not None else ""
|
|
105
|
+
_t = f" take: {self.take_price}" if self.take_price is not None else ""
|
|
106
|
+
return f"[{_d}] TARGET {self.target_position_size:+f} {self.instrument.base}{_p}{_s}{_t} for {self.instrument}"
|
|
107
|
+
|
|
108
|
+
|
|
63
109
|
@dataclass
|
|
64
110
|
class Signal:
|
|
65
111
|
"""
|
|
66
112
|
Class for presenting signals generated by strategy
|
|
67
113
|
|
|
68
114
|
Attributes:
|
|
69
|
-
reference_price: float -
|
|
115
|
+
reference_price: float - aux market price when signal was generated
|
|
116
|
+
is_service: bool - when we need this signal only for informative purposes (post-factum risk management etc)
|
|
70
117
|
|
|
71
118
|
Options:
|
|
72
|
-
|
|
119
|
+
- allow_override: bool - if True, and there is another signal for the same instrument, then override current.
|
|
120
|
+
- group: str - group name for signal
|
|
121
|
+
- comment: str - comment for signal
|
|
122
|
+
- options: dict[str, Any] - additional options for signal
|
|
73
123
|
"""
|
|
74
124
|
|
|
125
|
+
time: dt_64 | str # time when signal was generated
|
|
75
126
|
instrument: "Instrument"
|
|
76
127
|
signal: float
|
|
77
128
|
price: float | None = None
|
|
@@ -81,20 +132,37 @@ class Signal:
|
|
|
81
132
|
group: str = ""
|
|
82
133
|
comment: str = ""
|
|
83
134
|
options: dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
is_service: bool = False # when we need this signal only for informative purposes (post-factum risk management etc)
|
|
136
|
+
|
|
137
|
+
def target_for_amount(self, amount: float, **kwargs) -> TargetPosition:
|
|
138
|
+
assert not self.is_service, "Service signals can't be converted to target positions !"
|
|
139
|
+
return self.instrument.target(
|
|
140
|
+
self.time,
|
|
141
|
+
self.instrument.round_size_down(amount),
|
|
142
|
+
entry_price=self.price,
|
|
143
|
+
stop_price=self.stop,
|
|
144
|
+
take_price=self.take,
|
|
145
|
+
options=self.options,
|
|
146
|
+
**kwargs,
|
|
147
|
+
)
|
|
84
148
|
|
|
85
149
|
def __str__(self) -> str:
|
|
150
|
+
_d = f"{pd.Timestamp(self.time).strftime('%Y-%m-%d %H:%M:%S.%f')}"
|
|
86
151
|
_p = f" @ {self.price}" if self.price is not None else ""
|
|
87
152
|
_s = f" stop: {self.stop}" if self.stop is not None else ""
|
|
88
153
|
_t = f" take: {self.take}" if self.take is not None else ""
|
|
89
154
|
_r = f" {self.reference_price:.2f}" if self.reference_price is not None else ""
|
|
90
155
|
_c = f" ({self.comment})" if self.comment else ""
|
|
91
|
-
|
|
156
|
+
_i = "SERVICE ::" if self.is_service else ""
|
|
157
|
+
|
|
158
|
+
return f"[{_d}] {_i}{self.group}{_r} {self.signal:+.2f} {self.instrument}{_p}{_s}{_t}{_c}"
|
|
92
159
|
|
|
93
160
|
def copy(self) -> "Signal":
|
|
94
161
|
"""
|
|
95
162
|
Return a copy of the original signal
|
|
96
163
|
"""
|
|
97
164
|
return Signal(
|
|
165
|
+
self.time,
|
|
98
166
|
self.instrument,
|
|
99
167
|
self.signal,
|
|
100
168
|
self.price,
|
|
@@ -104,60 +172,27 @@ class Signal:
|
|
|
104
172
|
self.group,
|
|
105
173
|
self.comment,
|
|
106
174
|
dict(self.options),
|
|
175
|
+
self.is_service,
|
|
107
176
|
)
|
|
108
177
|
|
|
109
178
|
|
|
110
179
|
@dataclass
|
|
111
|
-
class
|
|
180
|
+
class InitializingSignal(Signal):
|
|
112
181
|
"""
|
|
113
|
-
|
|
182
|
+
Special signal type for post-warmup initialization
|
|
114
183
|
"""
|
|
115
184
|
|
|
116
|
-
|
|
117
|
-
signal: Signal # original signal
|
|
118
|
-
target_position_size: float # actual position size after processing in sizer
|
|
119
|
-
_is_service: bool = False
|
|
120
|
-
|
|
121
|
-
@staticmethod
|
|
122
|
-
def create(ctx: "ITimeProvider", signal: Signal, target_size: float) -> "TargetPosition":
|
|
123
|
-
return TargetPosition(ctx.time(), signal, signal.instrument.round_size_down(target_size))
|
|
124
|
-
|
|
125
|
-
@staticmethod
|
|
126
|
-
def zero(ctx: "ITimeProvider", signal: Signal) -> "TargetPosition":
|
|
127
|
-
return TargetPosition(ctx.time(), signal, 0.0)
|
|
128
|
-
|
|
129
|
-
@staticmethod
|
|
130
|
-
def service(ctx: "ITimeProvider", signal: Signal, size: float | None = None) -> "TargetPosition":
|
|
131
|
-
"""
|
|
132
|
-
Generate just service position target (for logging purposes)
|
|
133
|
-
"""
|
|
134
|
-
return TargetPosition(ctx.time(), signal, size if size else signal.signal, _is_service=True)
|
|
135
|
-
|
|
136
|
-
@property
|
|
137
|
-
def instrument(self) -> "Instrument":
|
|
138
|
-
return self.signal.instrument
|
|
139
|
-
|
|
140
|
-
@property
|
|
141
|
-
def price(self) -> float | None:
|
|
142
|
-
return self.signal.price
|
|
143
|
-
|
|
144
|
-
@property
|
|
145
|
-
def stop(self) -> float | None:
|
|
146
|
-
return self.signal.stop
|
|
147
|
-
|
|
148
|
-
@property
|
|
149
|
-
def take(self) -> float | None:
|
|
150
|
-
return self.signal.take
|
|
151
|
-
|
|
152
|
-
@property
|
|
153
|
-
def is_service(self) -> bool:
|
|
154
|
-
"""
|
|
155
|
-
Some target may be used just for informative purposes (post-factum risk management etc)
|
|
156
|
-
"""
|
|
157
|
-
return self._is_service
|
|
185
|
+
use_limit_order: bool = False # if True, then use limit order for post-warmup initialization
|
|
158
186
|
|
|
159
187
|
def __str__(self) -> str:
|
|
160
|
-
|
|
188
|
+
_d = f"{pd.Timestamp(self.time).strftime('%Y-%m-%d %H:%M:%S.%f')}"
|
|
189
|
+
_p = f" @ {self.price}" if self.price is not None else ""
|
|
190
|
+
_s = f" stop: {self.stop}" if self.stop is not None else ""
|
|
191
|
+
_t = f" take: {self.take}" if self.take is not None else ""
|
|
192
|
+
_r = f" {self.reference_price:.2f}" if self.reference_price is not None else ""
|
|
193
|
+
_c = f" ({self.comment})" if self.comment else ""
|
|
194
|
+
|
|
195
|
+
return f"[{_d}] POST-WARMUP-INIT ::{self.group}{_r} {self.signal:+.2f} {self.instrument}{_p}{_s}{_t}{_c}"
|
|
161
196
|
|
|
162
197
|
|
|
163
198
|
class AssetType(StrEnum):
|
|
@@ -261,8 +296,26 @@ class Instrument:
|
|
|
261
296
|
"""
|
|
262
297
|
return prec_ceil(price, self.price_precision)
|
|
263
298
|
|
|
299
|
+
def service_signal(
|
|
300
|
+
self,
|
|
301
|
+
time: dt_64 | str | ITimeProvider,
|
|
302
|
+
signal: float,
|
|
303
|
+
price: float | None = None,
|
|
304
|
+
stop: float | None = None,
|
|
305
|
+
take: float | None = None,
|
|
306
|
+
group: str = "",
|
|
307
|
+
comment: str = "",
|
|
308
|
+
options: dict[str, Any] | None = None,
|
|
309
|
+
**kwargs,
|
|
310
|
+
) -> Signal:
|
|
311
|
+
"""
|
|
312
|
+
Create service signal for the instrument
|
|
313
|
+
"""
|
|
314
|
+
return self.signal(time, signal, price, stop, take, group, comment, options, is_service=True, **kwargs)
|
|
315
|
+
|
|
264
316
|
def signal(
|
|
265
317
|
self,
|
|
318
|
+
time: dt_64 | str | ITimeProvider,
|
|
266
319
|
signal: float,
|
|
267
320
|
price: float | None = None,
|
|
268
321
|
stop: float | None = None,
|
|
@@ -270,9 +323,14 @@ class Instrument:
|
|
|
270
323
|
group: str = "",
|
|
271
324
|
comment: str = "",
|
|
272
325
|
options: dict[str, Any] | None = None,
|
|
326
|
+
is_service: bool = False,
|
|
273
327
|
**kwargs,
|
|
274
328
|
) -> Signal:
|
|
329
|
+
"""
|
|
330
|
+
Create signal for the instrument
|
|
331
|
+
"""
|
|
275
332
|
return Signal(
|
|
333
|
+
time=time.time() if isinstance(time, ITimeProvider) else time,
|
|
276
334
|
instrument=self,
|
|
277
335
|
signal=signal,
|
|
278
336
|
price=price,
|
|
@@ -281,6 +339,30 @@ class Instrument:
|
|
|
281
339
|
group=group,
|
|
282
340
|
comment=comment,
|
|
283
341
|
options=(options or {}) | kwargs,
|
|
342
|
+
is_service=is_service,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def target(
|
|
346
|
+
self,
|
|
347
|
+
time: dt_64 | str | ITimeProvider,
|
|
348
|
+
amount: float,
|
|
349
|
+
entry_price: float | None = None,
|
|
350
|
+
stop_price: float | None = None,
|
|
351
|
+
take_price: float | None = None,
|
|
352
|
+
options: dict[str, Any] | None = None,
|
|
353
|
+
**kwargs,
|
|
354
|
+
) -> TargetPosition:
|
|
355
|
+
"""
|
|
356
|
+
Create target position for the instrument
|
|
357
|
+
"""
|
|
358
|
+
return TargetPosition(
|
|
359
|
+
time=time.time() if isinstance(time, ITimeProvider) else time,
|
|
360
|
+
instrument=self,
|
|
361
|
+
target_position_size=self.round_size_down(amount),
|
|
362
|
+
entry_price=entry_price,
|
|
363
|
+
stop_price=stop_price,
|
|
364
|
+
take_price=take_price,
|
|
365
|
+
options=(options or {}) | kwargs,
|
|
284
366
|
)
|
|
285
367
|
|
|
286
368
|
def __hash__(self) -> int:
|
|
@@ -736,18 +818,6 @@ class CtrlChannel:
|
|
|
736
818
|
raise QueueTimeout(f"Timeout waiting for data on {self.name} channel")
|
|
737
819
|
|
|
738
820
|
|
|
739
|
-
class ITimeProvider:
|
|
740
|
-
"""
|
|
741
|
-
Generic interface for providing current time
|
|
742
|
-
"""
|
|
743
|
-
|
|
744
|
-
def time(self) -> dt_64:
|
|
745
|
-
"""
|
|
746
|
-
Returns current time
|
|
747
|
-
"""
|
|
748
|
-
...
|
|
749
|
-
|
|
750
|
-
|
|
751
821
|
class DataType(StrEnum):
|
|
752
822
|
"""
|
|
753
823
|
Data type constants. Used for specifying the type of data and can be used for subscription to.
|
|
@@ -884,6 +954,7 @@ class RestoredState:
|
|
|
884
954
|
|
|
885
955
|
time: np.datetime64
|
|
886
956
|
balances: dict[str, AssetBalance]
|
|
957
|
+
instrument_to_signal_positions: dict[Instrument, list[Signal]]
|
|
887
958
|
instrument_to_target_positions: dict[Instrument, list[TargetPosition]]
|
|
888
959
|
positions: dict[Instrument, Position]
|
|
889
960
|
|
qubx/core/context.py
CHANGED
|
@@ -15,6 +15,8 @@ from qubx.core.basics import (
|
|
|
15
15
|
Order,
|
|
16
16
|
OrderRequest,
|
|
17
17
|
Position,
|
|
18
|
+
Signal,
|
|
19
|
+
TargetPosition,
|
|
18
20
|
Timestamped,
|
|
19
21
|
dt_64,
|
|
20
22
|
)
|
|
@@ -87,6 +89,7 @@ class StrategyContext(IStrategyContext):
|
|
|
87
89
|
|
|
88
90
|
_warmup_positions: dict[Instrument, Position] | None = None
|
|
89
91
|
_warmup_orders: dict[Instrument, list[Order]] | None = None
|
|
92
|
+
_warmup_active_targets: dict[Instrument, list[TargetPosition]] | None = None
|
|
90
93
|
|
|
91
94
|
def __init__(
|
|
92
95
|
self,
|
|
@@ -420,6 +423,8 @@ class StrategyContext(IStrategyContext):
|
|
|
420
423
|
|
|
421
424
|
# ITradingManager delegation
|
|
422
425
|
def trade(self, instrument: Instrument, amount: float, price: float | None = None, time_in_force="gtc", **options):
|
|
426
|
+
# TODO: we need to generate target position and apply it in the processing manager
|
|
427
|
+
# - one of the options is to have multiple entry levels in TargetPosition class
|
|
423
428
|
return self._trading_manager.trade(instrument, amount, price, time_in_force, **options)
|
|
424
429
|
|
|
425
430
|
def trade_async(
|
|
@@ -522,16 +527,28 @@ class StrategyContext(IStrategyContext):
|
|
|
522
527
|
def is_fitted(self) -> bool:
|
|
523
528
|
return self._processing_manager.is_fitted()
|
|
524
529
|
|
|
530
|
+
def get_active_targets(self) -> dict[Instrument, list[TargetPosition]]:
|
|
531
|
+
return self._processing_manager.get_active_targets()
|
|
532
|
+
|
|
533
|
+
def emit_signal(self, signal: Signal) -> None:
|
|
534
|
+
return self._processing_manager.emit_signal(signal)
|
|
535
|
+
|
|
525
536
|
# IWarmupStateSaver delegation
|
|
526
537
|
def set_warmup_positions(self, positions: dict[Instrument, Position]) -> None:
|
|
527
538
|
self._warmup_positions = positions
|
|
528
539
|
|
|
540
|
+
def set_warmup_active_targets(self, active_targets: dict[Instrument, list[TargetPosition]]) -> None:
|
|
541
|
+
self._warmup_active_targets = active_targets
|
|
542
|
+
|
|
529
543
|
def set_warmup_orders(self, orders: dict[Instrument, list[Order]]) -> None:
|
|
530
544
|
self._warmup_orders = orders
|
|
531
545
|
|
|
532
546
|
def get_warmup_positions(self) -> dict[Instrument, Position]:
|
|
533
547
|
return self._warmup_positions if self._warmup_positions is not None else {}
|
|
534
548
|
|
|
549
|
+
def get_warmup_active_targets(self) -> dict[Instrument, list[TargetPosition]]:
|
|
550
|
+
return self._warmup_active_targets if self._warmup_active_targets is not None else {}
|
|
551
|
+
|
|
535
552
|
def get_warmup_orders(self) -> dict[Instrument, list[Order]]:
|
|
536
553
|
return self._warmup_orders if self._warmup_orders is not None else {}
|
|
537
554
|
|