Qubx 0.2.65__cp311-cp311-manylinux_2_35_x86_64.whl → 0.2.70__cp311-cp311-manylinux_2_35_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/ome.py +88 -36
- qubx/backtester/simulator.py +12 -10
- qubx/core/basics.py +29 -7
- qubx/core/context.py +29 -19
- qubx/core/loggers.py +1 -0
- qubx/core/metrics.py +38 -13
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +1 -0
- qubx/core/series.pyi +3 -0
- qubx/core/series.pyx +54 -20
- qubx/core/strategy.py +5 -4
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyx +2 -2
- qubx/gathering/simplest.py +57 -11
- qubx/impl/ccxt_utils.py +3 -3
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +3 -0
- qubx/ta/indicators.pyi +1 -1
- qubx/ta/indicators.pyx +32 -0
- qubx/trackers/riskctrl.py +346 -121
- qubx/trackers/sizers.py +7 -7
- qubx/utils/charting/lookinglass.py +15 -0
- {qubx-0.2.65.dist-info → qubx-0.2.70.dist-info}/METADATA +1 -1
- {qubx-0.2.65.dist-info → qubx-0.2.70.dist-info}/RECORD +25 -25
- {qubx-0.2.65.dist-info → qubx-0.2.70.dist-info}/WHEEL +0 -0
qubx/backtester/ome.py
CHANGED
|
@@ -6,7 +6,19 @@ import numpy as np
|
|
|
6
6
|
from sortedcontainers import SortedDict
|
|
7
7
|
|
|
8
8
|
from qubx import logger
|
|
9
|
-
from qubx.core.basics import
|
|
9
|
+
from qubx.core.basics import (
|
|
10
|
+
Deal,
|
|
11
|
+
Instrument,
|
|
12
|
+
Order,
|
|
13
|
+
OrderSide,
|
|
14
|
+
OrderType,
|
|
15
|
+
Position,
|
|
16
|
+
Signal,
|
|
17
|
+
TransactionCostsCalculator,
|
|
18
|
+
dt_64,
|
|
19
|
+
ITimeProvider,
|
|
20
|
+
OPTION_FILL_AT_SIGNAL_PRICE,
|
|
21
|
+
)
|
|
10
22
|
from qubx.core.series import Quote, Trade
|
|
11
23
|
from qubx.core.exceptions import (
|
|
12
24
|
ExchangeError,
|
|
@@ -25,6 +37,7 @@ class OrdersManagementEngine:
|
|
|
25
37
|
instrument: Instrument
|
|
26
38
|
time_service: ITimeProvider
|
|
27
39
|
active_orders: Dict[str, Order]
|
|
40
|
+
stop_orders: Dict[str, Order]
|
|
28
41
|
asks: SortedDict[float, List[str]]
|
|
29
42
|
bids: SortedDict[float, List[str]]
|
|
30
43
|
bbo: Quote | None # current best bid/ask order book (simplest impl)
|
|
@@ -40,6 +53,7 @@ class OrdersManagementEngine:
|
|
|
40
53
|
self.asks = SortedDict()
|
|
41
54
|
self.bids = SortedDict(neg)
|
|
42
55
|
self.active_orders = dict()
|
|
56
|
+
self.stop_orders = dict()
|
|
43
57
|
self.bbo = None
|
|
44
58
|
self.__order_id = 100000
|
|
45
59
|
self.__trade_id = 100000
|
|
@@ -58,7 +72,7 @@ class OrdersManagementEngine:
|
|
|
58
72
|
return self.bbo
|
|
59
73
|
|
|
60
74
|
def get_open_orders(self) -> List[Order]:
|
|
61
|
-
return list(self.active_orders.values())
|
|
75
|
+
return list(self.active_orders.values()) + list(self.stop_orders.values())
|
|
62
76
|
|
|
63
77
|
def update_bbo(self, quote: Quote) -> List[OmeReport]:
|
|
64
78
|
timestamp = self.time_service.time()
|
|
@@ -79,18 +93,30 @@ class OrdersManagementEngine:
|
|
|
79
93
|
rep.append(self._execute_order(timestamp, order.price, order, False))
|
|
80
94
|
self.bids.pop(level)
|
|
81
95
|
|
|
96
|
+
# - processing stop orders
|
|
97
|
+
for soid in list(self.stop_orders.keys()):
|
|
98
|
+
so = self.stop_orders[soid]
|
|
99
|
+
if so.side == "BUY" and quote.ask >= so.price:
|
|
100
|
+
_exec_price = quote.ask if not so.options.get(OPTION_FILL_AT_SIGNAL_PRICE, False) else so.price
|
|
101
|
+
self.stop_orders.pop(soid)
|
|
102
|
+
rep.append(self._execute_order(timestamp, _exec_price, so, True))
|
|
103
|
+
elif so.side == "SELL" and quote.bid <= so.price:
|
|
104
|
+
_exec_price = quote.bid if not so.options.get(OPTION_FILL_AT_SIGNAL_PRICE, False) else so.price
|
|
105
|
+
self.stop_orders.pop(soid)
|
|
106
|
+
rep.append(self._execute_order(timestamp, _exec_price, so, True))
|
|
107
|
+
|
|
82
108
|
self.bbo = quote
|
|
83
109
|
return rep
|
|
84
110
|
|
|
85
111
|
def place_order(
|
|
86
112
|
self,
|
|
87
|
-
order_side:
|
|
88
|
-
order_type:
|
|
113
|
+
order_side: OrderSide,
|
|
114
|
+
order_type: OrderType,
|
|
89
115
|
amount: float,
|
|
90
116
|
price: float | None = None,
|
|
91
117
|
client_id: str | None = None,
|
|
92
118
|
time_in_force: str = "gtc",
|
|
93
|
-
|
|
119
|
+
**options,
|
|
94
120
|
) -> OmeReport:
|
|
95
121
|
|
|
96
122
|
if self.bbo is None:
|
|
@@ -113,14 +139,15 @@ class OrdersManagementEngine:
|
|
|
113
139
|
"NEW",
|
|
114
140
|
time_in_force,
|
|
115
141
|
client_id,
|
|
142
|
+
options=options,
|
|
116
143
|
)
|
|
117
144
|
|
|
118
|
-
return self._process_order(timestamp, order
|
|
145
|
+
return self._process_order(timestamp, order)
|
|
119
146
|
|
|
120
147
|
def _dbg(self, message, **kwargs) -> None:
|
|
121
148
|
logger.debug(f"[OMS] {self.instrument.symbol} - {message}", **kwargs)
|
|
122
149
|
|
|
123
|
-
def _process_order(self, timestamp: dt_64, order: Order
|
|
150
|
+
def _process_order(self, timestamp: dt_64, order: Order) -> OmeReport:
|
|
124
151
|
if order.status in ["CLOSED", "CANCELED"]:
|
|
125
152
|
raise InvalidOrder(f"Order {order.id} is already closed or canceled.")
|
|
126
153
|
|
|
@@ -130,33 +157,45 @@ class OrdersManagementEngine:
|
|
|
130
157
|
|
|
131
158
|
# - check if order can be "executed" immediately
|
|
132
159
|
exec_price = None
|
|
133
|
-
|
|
134
|
-
exec_price = order.price
|
|
160
|
+
_need_update_book = False
|
|
135
161
|
|
|
136
|
-
|
|
162
|
+
if order.type == "MARKET":
|
|
137
163
|
exec_price = c_ask if buy_side else c_bid
|
|
138
164
|
|
|
139
165
|
elif order.type == "LIMIT":
|
|
166
|
+
_need_update_book = True
|
|
140
167
|
if (buy_side and order.price >= c_ask) or (not buy_side and order.price <= c_bid):
|
|
141
168
|
exec_price = c_ask if buy_side else c_bid
|
|
142
169
|
|
|
170
|
+
elif order.type == "STOP_MARKET":
|
|
171
|
+
# - it processes stop orders separately without adding to orderbook (as on real exchanges)
|
|
172
|
+
order.status = "OPEN"
|
|
173
|
+
self.stop_orders[order.id] = order
|
|
174
|
+
|
|
175
|
+
elif order.type == "STOP_LIMIT":
|
|
176
|
+
# TODO: check trigger conditions in options etc
|
|
177
|
+
raise NotImplementedError("'STOP_LIMIT' order is not supported in Qubx simulator yet !")
|
|
178
|
+
|
|
143
179
|
# - if order must be "executed" immediately
|
|
144
180
|
if exec_price is not None:
|
|
145
181
|
return self._execute_order(timestamp, exec_price, order, True)
|
|
146
182
|
|
|
147
183
|
# - processing limit orders
|
|
148
|
-
if
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
if _need_update_book:
|
|
185
|
+
if buy_side:
|
|
186
|
+
self.bids.setdefault(order.price, list()).append(order.id)
|
|
187
|
+
else:
|
|
188
|
+
self.asks.setdefault(order.price, list()).append(order.id)
|
|
189
|
+
|
|
190
|
+
order.status = "OPEN"
|
|
191
|
+
self.active_orders[order.id] = order
|
|
192
|
+
|
|
153
193
|
self._dbg(f"registered {order.id} {order.type} {order.side} {order.quantity} {order.price}")
|
|
154
|
-
self.active_orders[order.id] = order
|
|
155
194
|
return OmeReport(timestamp, order, None)
|
|
156
195
|
|
|
157
196
|
def _execute_order(self, timestamp: dt_64, exec_price: float, order: Order, taker: bool) -> OmeReport:
|
|
158
197
|
order.status = "CLOSED"
|
|
159
|
-
self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} executed at {exec_price}")
|
|
198
|
+
self._dbg(f"<red>{order.id}</red> {order.type} {order.side} {order.quantity} executed at {exec_price}")
|
|
160
199
|
return OmeReport(
|
|
161
200
|
timestamp,
|
|
162
201
|
order,
|
|
@@ -180,38 +219,51 @@ class OrdersManagementEngine:
|
|
|
180
219
|
if order_side.upper() not in ["BUY", "SELL"]:
|
|
181
220
|
raise InvalidOrder("Invalid order side. Only BUY or SELL is allowed.")
|
|
182
221
|
|
|
183
|
-
|
|
184
|
-
|
|
222
|
+
_ot = order_type.upper()
|
|
223
|
+
if _ot not in ["LIMIT", "MARKET", "STOP_MARKET", "STOP_LIMIT"]:
|
|
224
|
+
raise InvalidOrder("Invalid order type. Only LIMIT, MARKET, STOP_MARKET, STOP_LIMIT are supported.")
|
|
185
225
|
|
|
186
226
|
if amount <= 0:
|
|
187
227
|
raise InvalidOrder("Invalid order amount. Amount must be positive.")
|
|
188
228
|
|
|
189
|
-
if
|
|
190
|
-
raise InvalidOrder("Invalid order price. Price must be positively defined for LIMIT orders.")
|
|
229
|
+
if (_ot == "LIMIT" or _ot.startswith("STOP")) and (price is None or price <= 0):
|
|
230
|
+
raise InvalidOrder("Invalid order price. Price must be positively defined for LIMIT or STOP orders.")
|
|
191
231
|
|
|
192
232
|
if time_in_force.upper() not in ["GTC", "IOC"]:
|
|
193
233
|
raise InvalidOrder("Invalid time in force. Only GTC or IOC is supported for now.")
|
|
194
234
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
235
|
+
if _ot.startswith("STOP"):
|
|
236
|
+
assert price is not None
|
|
237
|
+
c_ask, c_bid = self.bbo.ask, self.bbo.bid
|
|
238
|
+
if (order_side == "BUY" and c_ask >= price) or (order_side == "SELL" and c_bid <= price):
|
|
239
|
+
raise ExchangeError(
|
|
240
|
+
f"Stop price would trigger immediately: STOP_MARKET {order_side} {amount} of {self.instrument.symbol} at {price} | market: {c_ask} / {c_bid}"
|
|
241
|
+
)
|
|
198
242
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
243
|
+
def cancel_order(self, order_id: str) -> OmeReport:
|
|
244
|
+
# - check limit orders
|
|
245
|
+
if order_id in self.active_orders:
|
|
246
|
+
order = self.active_orders.pop(order_id)
|
|
247
|
+
if order.side == "BUY":
|
|
248
|
+
oids = self.bids[order.price]
|
|
249
|
+
oids.remove(order_id)
|
|
250
|
+
if not oids:
|
|
251
|
+
self.bids.pop(order.price)
|
|
252
|
+
else:
|
|
253
|
+
oids = self.asks[order.price]
|
|
254
|
+
oids.remove(order_id)
|
|
255
|
+
if not oids:
|
|
256
|
+
self.asks.pop(order.price)
|
|
257
|
+
# - check stop orders
|
|
258
|
+
elif order_id in self.stop_orders:
|
|
259
|
+
order = self.stop_orders.pop(order_id)
|
|
260
|
+
# - wrong order_id
|
|
206
261
|
else:
|
|
207
|
-
|
|
208
|
-
oids.remove(order_id)
|
|
209
|
-
if not oids:
|
|
210
|
-
self.asks.pop(order.price)
|
|
262
|
+
raise InvalidOrder(f"Order {order_id} not found for {self.instrument.symbol}")
|
|
211
263
|
|
|
212
264
|
order.status = "CANCELED"
|
|
213
265
|
self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} canceled")
|
|
214
|
-
return OmeReport(
|
|
266
|
+
return OmeReport(self.time_service.time(), order, None)
|
|
215
267
|
|
|
216
268
|
def __str__(self) -> str:
|
|
217
269
|
_a, _b = True, True
|
qubx/backtester/simulator.py
CHANGED
|
@@ -125,7 +125,7 @@ class _SimulatedLogFormatter:
|
|
|
125
125
|
|
|
126
126
|
now = self.time_provider.time().astype("datetime64[us]").item().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
127
127
|
# prefix = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> [ <level>%s</level> ] " % record["level"].icon
|
|
128
|
-
prefix = f"<
|
|
128
|
+
prefix = f"<lc>{now}</lc> [<level>{record['level'].icon}</level>] "
|
|
129
129
|
|
|
130
130
|
if record["exception"] is not None:
|
|
131
131
|
record["extra"]["stack"] = stackprinter.format(record["exception"], style="darkbg3")
|
|
@@ -201,7 +201,7 @@ class SimulatedTrading(ITradingServiceProvider):
|
|
|
201
201
|
price,
|
|
202
202
|
client_id,
|
|
203
203
|
time_in_force,
|
|
204
|
-
|
|
204
|
+
**options,
|
|
205
205
|
)
|
|
206
206
|
order = report.order
|
|
207
207
|
self._order_to_symbol[order.id] = instrument.symbol
|
|
@@ -362,7 +362,7 @@ class SimulatedExchange(IBrokerServiceProvider):
|
|
|
362
362
|
|
|
363
363
|
# - create exchange's instance
|
|
364
364
|
self._last_quotes = defaultdict(lambda: None)
|
|
365
|
-
self._current_time =
|
|
365
|
+
self._current_time = self.trading_service.time()
|
|
366
366
|
self._loaders = defaultdict(dict)
|
|
367
367
|
self._symbol_to_instrument: dict[str, Instrument] = {}
|
|
368
368
|
|
|
@@ -509,20 +509,22 @@ class SimulatedExchange(IBrokerServiceProvider):
|
|
|
509
509
|
self._current_time = max(np.datetime64(t, "ns"), self._current_time)
|
|
510
510
|
q = self.trading_service.emulate_quote_from_data(symbol, np.datetime64(t, "ns"), data)
|
|
511
511
|
is_hist = data_type.startswith("hist")
|
|
512
|
+
|
|
512
513
|
if not is_hist and q is not None:
|
|
513
514
|
self._last_quotes[symbol] = q
|
|
514
515
|
self.trading_service.update_position_price(symbol, self._current_time, q)
|
|
515
516
|
|
|
517
|
+
# we have to schedule possible crons before sending the data event itself
|
|
518
|
+
if self._scheduler.check_and_run_tasks():
|
|
519
|
+
# - push nothing - it will force to process last event
|
|
520
|
+
cc.send((None, "time", None))
|
|
521
|
+
|
|
516
522
|
cc.send((symbol, data_type, data))
|
|
517
523
|
|
|
518
524
|
if not is_hist:
|
|
519
525
|
if q is not None and data_type != "quote":
|
|
520
526
|
cc.send((symbol, "quote", q))
|
|
521
527
|
|
|
522
|
-
if self._scheduler.check_and_run_tasks():
|
|
523
|
-
# - push nothing - it will force to process last event
|
|
524
|
-
cc.send((None, "time", None))
|
|
525
|
-
|
|
526
528
|
def get_quote(self, symbol: str) -> Optional[Quote]:
|
|
527
529
|
return self._last_quotes[symbol]
|
|
528
530
|
|
|
@@ -783,7 +785,7 @@ def find_instruments_and_exchanges(
|
|
|
783
785
|
return _instrs, _exchanges
|
|
784
786
|
|
|
785
787
|
|
|
786
|
-
class
|
|
788
|
+
class SignalsProxy(IStrategy):
|
|
787
789
|
|
|
788
790
|
def on_fit(
|
|
789
791
|
self, ctx: StrategyContext, fit_time: str | pd.Timestamp, previous_fit_time: str | pd.Timestamp | None = None
|
|
@@ -870,7 +872,7 @@ def _run_setup(
|
|
|
870
872
|
strat.tracker = lambda ctx: setup.tracker # type: ignore
|
|
871
873
|
|
|
872
874
|
case _Types.SIGNAL:
|
|
873
|
-
strat =
|
|
875
|
+
strat = SignalsProxy()
|
|
874
876
|
exchange.set_generated_signals(setup.generator) # type: ignore
|
|
875
877
|
# - we don't need any unexpected triggerings
|
|
876
878
|
_trigger = "bar: 0s"
|
|
@@ -880,7 +882,7 @@ def _run_setup(
|
|
|
880
882
|
enable_event_batching = False
|
|
881
883
|
|
|
882
884
|
case _Types.SIGNAL_AND_TRACKER:
|
|
883
|
-
strat =
|
|
885
|
+
strat = SignalsProxy()
|
|
884
886
|
strat.tracker = lambda ctx: setup.tracker
|
|
885
887
|
exchange.set_generated_signals(setup.generator) # type: ignore
|
|
886
888
|
# - we don't need any unexpected triggerings
|
qubx/core/basics.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
2
|
+
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from dataclasses import dataclass, field
|
|
@@ -14,6 +14,8 @@ from qubx.core.utils import prec_ceil, prec_floor
|
|
|
14
14
|
dt_64 = np.datetime64
|
|
15
15
|
td_64 = np.timedelta64
|
|
16
16
|
|
|
17
|
+
OPTION_FILL_AT_SIGNAL_PRICE = "fill_at_signal_price"
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
@dataclass
|
|
19
21
|
class Signal:
|
|
@@ -24,7 +26,6 @@ class Signal:
|
|
|
24
26
|
reference_price: float - exact price when signal was generated
|
|
25
27
|
|
|
26
28
|
Options:
|
|
27
|
-
- fill_at_signal_price: bool - if True, then fill order at signal price (only used in backtesting)
|
|
28
29
|
- allow_override: bool - if True, and there is another signal for the same instrument, then override current.
|
|
29
30
|
"""
|
|
30
31
|
|
|
@@ -58,15 +59,23 @@ class TargetPosition:
|
|
|
58
59
|
time: dt_64 # time when position was set
|
|
59
60
|
signal: Signal # original signal
|
|
60
61
|
target_position_size: float # actual position size after processing in sizer
|
|
62
|
+
_is_service: bool = False
|
|
61
63
|
|
|
62
64
|
@staticmethod
|
|
63
65
|
def create(ctx: "ITimeProvider", signal: Signal, target_size: float) -> "TargetPosition":
|
|
64
|
-
return TargetPosition(ctx.time(), signal, target_size)
|
|
66
|
+
return TargetPosition(ctx.time(), signal, signal.instrument.round_size_down(target_size))
|
|
65
67
|
|
|
66
68
|
@staticmethod
|
|
67
69
|
def zero(ctx: "ITimeProvider", signal: Signal) -> "TargetPosition":
|
|
68
70
|
return TargetPosition(ctx.time(), signal, 0.0)
|
|
69
71
|
|
|
72
|
+
@staticmethod
|
|
73
|
+
def service(ctx: "ITimeProvider", signal: Signal, size: float | None = None) -> "TargetPosition":
|
|
74
|
+
"""
|
|
75
|
+
Generate just service position target (for logging purposes)
|
|
76
|
+
"""
|
|
77
|
+
return TargetPosition(ctx.time(), signal, size if size else signal.signal, _is_service=True)
|
|
78
|
+
|
|
70
79
|
@property
|
|
71
80
|
def instrument(self) -> "Instrument":
|
|
72
81
|
return self.signal.instrument
|
|
@@ -83,8 +92,15 @@ class TargetPosition:
|
|
|
83
92
|
def take(self) -> float | None:
|
|
84
93
|
return self.signal.take
|
|
85
94
|
|
|
95
|
+
@property
|
|
96
|
+
def is_service(self) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Some target may be used just for informative purposes (post-factum risk management etc)
|
|
99
|
+
"""
|
|
100
|
+
return self._is_service
|
|
101
|
+
|
|
86
102
|
def __str__(self) -> str:
|
|
87
|
-
return f"Target for {self.signal} -> {self.target_position_size} at {self.time}"
|
|
103
|
+
return f"{'::: INFORMATIVE ::: ' if self.is_service else ''}Target for {self.signal} -> {self.target_position_size} at {self.time}"
|
|
88
104
|
|
|
89
105
|
|
|
90
106
|
@dataclass
|
|
@@ -284,19 +300,25 @@ class Deal:
|
|
|
284
300
|
fee_currency: str | None = None
|
|
285
301
|
|
|
286
302
|
|
|
303
|
+
OrderType = Literal["MARKET", "LIMIT", "STOP_MARKET", "STOP_LIMIT"]
|
|
304
|
+
OrderSide = Literal["BUY", "SELL"]
|
|
305
|
+
OrderStatus = Literal["OPEN", "CLOSED", "CANCELED", "NEW"]
|
|
306
|
+
|
|
307
|
+
|
|
287
308
|
@dataclass
|
|
288
309
|
class Order:
|
|
289
310
|
id: str
|
|
290
|
-
type:
|
|
311
|
+
type: OrderType
|
|
291
312
|
symbol: str
|
|
292
313
|
time: dt_64
|
|
293
314
|
quantity: float
|
|
294
315
|
price: float
|
|
295
|
-
side:
|
|
296
|
-
status:
|
|
316
|
+
side: OrderSide
|
|
317
|
+
status: OrderStatus
|
|
297
318
|
time_in_force: str
|
|
298
319
|
client_id: str | None = None
|
|
299
320
|
cost: float = 0.0
|
|
321
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
300
322
|
|
|
301
323
|
def __str__(self) -> str:
|
|
302
324
|
return f"[{self.id}] {self.type} {self.side} {self.quantity} of {self.symbol} {('@ ' + str(self.price)) if self.price > 0 else ''} ({self.time_in_force}) [{self.status}]"
|
qubx/core/context.py
CHANGED
|
@@ -381,7 +381,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
381
381
|
# - process and execute signals if they are provided
|
|
382
382
|
if signals:
|
|
383
383
|
# process signals by tracker and turn convert them into positions
|
|
384
|
-
positions_from_strategy = self.
|
|
384
|
+
positions_from_strategy = self.__process_and_log_target_positions(
|
|
385
385
|
self.positions_tracker.process_signals(self, self.__process_signals(signals))
|
|
386
386
|
)
|
|
387
387
|
|
|
@@ -415,11 +415,11 @@ class StrategyContextImpl(StrategyContext):
|
|
|
415
415
|
try:
|
|
416
416
|
self.__fit_is_running = True
|
|
417
417
|
logger.debug(
|
|
418
|
-
f"
|
|
418
|
+
f"Invoking <green>{self.strategy.__class__.__name__}</green> on_fit('{current_fit_time}', '{prev_fit_time}')"
|
|
419
419
|
)
|
|
420
420
|
_SW.start("strategy.on_fit")
|
|
421
421
|
self.strategy.on_fit(self, current_fit_time, prev_fit_time)
|
|
422
|
-
logger.debug(f"
|
|
422
|
+
logger.debug(f"<green>{self.strategy.__class__.__name__}</green> is fitted")
|
|
423
423
|
except Exception as strat_error:
|
|
424
424
|
logger.error(
|
|
425
425
|
f"[{self.time()}]: Strategy {self.strategy.__class__.__name__} on_fit('{current_fit_time}', '{prev_fit_time}') raised an exception: {strat_error}"
|
|
@@ -505,13 +505,12 @@ class StrategyContextImpl(StrategyContext):
|
|
|
505
505
|
elif signals is None:
|
|
506
506
|
return []
|
|
507
507
|
|
|
508
|
-
# set strategy group name if not set
|
|
509
508
|
for signal in signals:
|
|
509
|
+
# set strategy group name if not set
|
|
510
510
|
if not signal.group:
|
|
511
511
|
signal.group = self.strategy_name
|
|
512
512
|
|
|
513
|
-
|
|
514
|
-
for signal in signals:
|
|
513
|
+
# set reference prices for signals
|
|
515
514
|
if signal.reference_price is None:
|
|
516
515
|
q = self.quote(signal.instrument.symbol)
|
|
517
516
|
if q is None:
|
|
@@ -530,9 +529,10 @@ class StrategyContextImpl(StrategyContext):
|
|
|
530
529
|
signals = [pos.signal for pos in target_positions]
|
|
531
530
|
self.__process_signals(signals)
|
|
532
531
|
|
|
533
|
-
def
|
|
532
|
+
def __process_and_log_target_positions(
|
|
534
533
|
self, target_positions: List[TargetPosition] | TargetPosition | None
|
|
535
534
|
) -> List[TargetPosition]:
|
|
535
|
+
|
|
536
536
|
if isinstance(target_positions, TargetPosition):
|
|
537
537
|
target_positions = [target_positions]
|
|
538
538
|
elif target_positions is None:
|
|
@@ -548,7 +548,10 @@ class StrategyContextImpl(StrategyContext):
|
|
|
548
548
|
|
|
549
549
|
# - update tracker and handle alterd positions if need
|
|
550
550
|
self.positions_gathering.alter_positions(
|
|
551
|
-
self,
|
|
551
|
+
self,
|
|
552
|
+
self.__process_and_log_target_positions(
|
|
553
|
+
self.positions_tracker.update(self, self._symb_to_instr[symbol], bar)
|
|
554
|
+
),
|
|
552
555
|
)
|
|
553
556
|
|
|
554
557
|
# - check if it's time to trigger the on_event if it's configured
|
|
@@ -570,7 +573,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
570
573
|
# - update tracker and handle alterd positions if need
|
|
571
574
|
self.positions_gathering.alter_positions(
|
|
572
575
|
self,
|
|
573
|
-
self.
|
|
576
|
+
self.__process_and_log_target_positions(target_positions),
|
|
574
577
|
)
|
|
575
578
|
|
|
576
579
|
if self._trig_on_trade:
|
|
@@ -585,7 +588,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
585
588
|
self.__process_signals_from_target_positions(target_positions)
|
|
586
589
|
|
|
587
590
|
# - update tracker and handle alterd positions if need
|
|
588
|
-
self.positions_gathering.alter_positions(self, self.
|
|
591
|
+
self.positions_gathering.alter_positions(self, self.__process_and_log_target_positions(target_positions))
|
|
589
592
|
|
|
590
593
|
# - TODO: here we can apply throttlings or filters
|
|
591
594
|
# - let's say we can skip quotes if bid & ask is not changed
|
|
@@ -599,7 +602,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
599
602
|
@_SW.watch("StrategyContext")
|
|
600
603
|
def _processing_order(self, symbol: str, order: Order) -> TriggerEvent | None:
|
|
601
604
|
logger.debug(
|
|
602
|
-
f"[{order.id} / {order.client_id}] : {order.type} {order.side} {order.quantity} of {symbol} { (' @ ' + str(order.price)) if order.price else '' } -> [{order.status}]"
|
|
605
|
+
f"[<red>{order.id}</red> / {order.client_id}] : {order.type} {order.side} {order.quantity} of {symbol} { (' @ ' + str(order.price)) if order.price else '' } -> [{order.status}]"
|
|
603
606
|
)
|
|
604
607
|
# - check if we want to trigger any strat's logic on order
|
|
605
608
|
return None
|
|
@@ -620,7 +623,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
620
623
|
# - notify position gatherer and tracker
|
|
621
624
|
self.positions_gathering.on_execution_report(self, instr, d)
|
|
622
625
|
self.positions_tracker.on_execution_report(self, instr, d)
|
|
623
|
-
logger.debug(f"Executed {d.amount} @ {d.price} of {symbol} for order {d.order_id}")
|
|
626
|
+
logger.debug(f"Executed {d.amount} @ {d.price} of {symbol} for order <red>{d.order_id}</red>")
|
|
624
627
|
else:
|
|
625
628
|
logger.debug(f"Execution report for unknown instrument {symbol}")
|
|
626
629
|
return None
|
|
@@ -716,14 +719,17 @@ class StrategyContextImpl(StrategyContext):
|
|
|
716
719
|
raise ValueError(f"Attempt to trade size {abs(amount)} less than minimal allowed {instrument.min_size} !")
|
|
717
720
|
|
|
718
721
|
side = "buy" if amount > 0 else "sell"
|
|
719
|
-
type = "
|
|
720
|
-
|
|
721
|
-
|
|
722
|
+
type = "market"
|
|
723
|
+
if price is not None:
|
|
724
|
+
price = instrument.round_price_down(price) if amount > 0 else instrument.round_price_up(price)
|
|
725
|
+
type = "limit"
|
|
726
|
+
if (stp_type := options.get("stop_type")) is not None:
|
|
727
|
+
type = f"stop_{stp_type}"
|
|
722
728
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
729
|
+
logger.debug(
|
|
730
|
+
f"(StrategyContext) sending {type} {side} for {size_adj} of <green>{instrument.symbol}</green> @ {price} ..."
|
|
731
|
+
)
|
|
732
|
+
client_id = self._generate_order_client_id(instrument.symbol)
|
|
727
733
|
|
|
728
734
|
order = self.trading_service.send_order(
|
|
729
735
|
instrument, side, type, size_adj, price, time_in_force=time_in_force, client_id=client_id, **options
|
|
@@ -741,6 +747,10 @@ class StrategyContextImpl(StrategyContext):
|
|
|
741
747
|
for o in self.trading_service.get_orders(instrument.symbol):
|
|
742
748
|
self.trading_service.cancel_order(o.id)
|
|
743
749
|
|
|
750
|
+
def cancel_order(self, order_id: str):
|
|
751
|
+
if order_id:
|
|
752
|
+
self.trading_service.cancel_order(order_id)
|
|
753
|
+
|
|
744
754
|
def quote(self, symbol: str) -> Quote | None:
|
|
745
755
|
return self.broker_provider.get_quote(symbol)
|
|
746
756
|
|
qubx/core/loggers.py
CHANGED
qubx/core/metrics.py
CHANGED
|
@@ -647,6 +647,7 @@ def portfolio_metrics(
|
|
|
647
647
|
pft_total["Total_Commissions"] = pft_total["Total_Commissions"].cumsum()
|
|
648
648
|
|
|
649
649
|
# if it's asked to account transactions into equ
|
|
650
|
+
pft_total["Total_Commissions"] *= kwargs.get("commission_factor", 1)
|
|
650
651
|
if account_transactions:
|
|
651
652
|
pft_total["Total_PnL"] -= pft_total["Total_Commissions"]
|
|
652
653
|
|
|
@@ -729,13 +730,19 @@ def tearsheet(
|
|
|
729
730
|
sort_by: str | None = "Sharpe",
|
|
730
731
|
sort_ascending: bool = False,
|
|
731
732
|
plot_equities: bool = True,
|
|
733
|
+
commission_factor: float = 1,
|
|
732
734
|
):
|
|
733
735
|
if timeframe is None:
|
|
734
736
|
timeframe = _estimate_timeframe(session)
|
|
735
737
|
if isinstance(session, list):
|
|
736
738
|
if len(session) == 1:
|
|
737
739
|
return _tearsheet_single(
|
|
738
|
-
session[0],
|
|
740
|
+
session[0],
|
|
741
|
+
compound,
|
|
742
|
+
account_transactions,
|
|
743
|
+
performance_statistics_period,
|
|
744
|
+
timeframe=timeframe,
|
|
745
|
+
commission_factor=commission_factor,
|
|
739
746
|
)
|
|
740
747
|
else:
|
|
741
748
|
import matplotlib.pyplot as plt
|
|
@@ -744,7 +751,9 @@ def tearsheet(
|
|
|
744
751
|
_rs = []
|
|
745
752
|
# _eq = []
|
|
746
753
|
for s in session:
|
|
747
|
-
report, mtrx = _pfl_metrics_prepare(
|
|
754
|
+
report, mtrx = _pfl_metrics_prepare(
|
|
755
|
+
s, account_transactions, performance_statistics_period, commission_factor=commission_factor
|
|
756
|
+
)
|
|
748
757
|
_rs.append(report)
|
|
749
758
|
if plot_equities:
|
|
750
759
|
if compound:
|
|
@@ -770,7 +779,12 @@ def tearsheet(
|
|
|
770
779
|
|
|
771
780
|
else:
|
|
772
781
|
return _tearsheet_single(
|
|
773
|
-
session,
|
|
782
|
+
session,
|
|
783
|
+
compound,
|
|
784
|
+
account_transactions,
|
|
785
|
+
performance_statistics_period,
|
|
786
|
+
timeframe=timeframe,
|
|
787
|
+
commission_factor=commission_factor,
|
|
774
788
|
)
|
|
775
789
|
|
|
776
790
|
|
|
@@ -814,13 +828,19 @@ def _estimate_timeframe(
|
|
|
814
828
|
return "1min"
|
|
815
829
|
|
|
816
830
|
|
|
817
|
-
def _pfl_metrics_prepare(
|
|
831
|
+
def _pfl_metrics_prepare(
|
|
832
|
+
session: TradingSessionResult,
|
|
833
|
+
account_transactions: bool,
|
|
834
|
+
performance_statistics_period: int,
|
|
835
|
+
commission_factor: float = 1,
|
|
836
|
+
):
|
|
818
837
|
mtrx = portfolio_metrics(
|
|
819
838
|
session.portfolio_log,
|
|
820
839
|
session.executions_log,
|
|
821
840
|
session.capital,
|
|
822
841
|
performance_statistics_period=performance_statistics_period,
|
|
823
842
|
account_transactions=account_transactions,
|
|
843
|
+
commission_factor=commission_factor,
|
|
824
844
|
)
|
|
825
845
|
rpt = {}
|
|
826
846
|
for k, v in mtrx.items():
|
|
@@ -836,8 +856,11 @@ def _tearsheet_single(
|
|
|
836
856
|
account_transactions=True,
|
|
837
857
|
performance_statistics_period=365,
|
|
838
858
|
timeframe: str | pd.Timedelta = "1h",
|
|
859
|
+
commission_factor: float = 1,
|
|
839
860
|
):
|
|
840
|
-
report, mtrx = _pfl_metrics_prepare(
|
|
861
|
+
report, mtrx = _pfl_metrics_prepare(
|
|
862
|
+
session, account_transactions, performance_statistics_period, commission_factor=commission_factor
|
|
863
|
+
)
|
|
841
864
|
tbl = go.Table(
|
|
842
865
|
columnwidth=[130, 130, 130, 130, 200],
|
|
843
866
|
header=dict(
|
|
@@ -920,14 +943,6 @@ def chart_signals(
|
|
|
920
943
|
end = executions.index[-1]
|
|
921
944
|
|
|
922
945
|
if portfolio is not None:
|
|
923
|
-
symbol_count = len(portfolio.filter(like="_PnL").columns)
|
|
924
|
-
pnl = portfolio.filter(regex=f"{symbol}_PnL").cumsum() + result.capital / symbol_count
|
|
925
|
-
pnl = pnl.loc[start:]
|
|
926
|
-
if apply_commissions:
|
|
927
|
-
comm = portfolio.filter(regex=f"{symbol}_Commissions").loc[start:].cumsum()
|
|
928
|
-
pnl -= comm.values
|
|
929
|
-
pnl = (pnl / pnl.iloc[0] - 1) * 100
|
|
930
|
-
indicators["PnL"] = ["area", "green", pnl]
|
|
931
946
|
if show_quantity:
|
|
932
947
|
pos = portfolio.filter(regex=f"{symbol}_Pos").loc[start:]
|
|
933
948
|
indicators["Pos"] = ["area", "cyan", pos]
|
|
@@ -940,6 +955,14 @@ def chart_signals(
|
|
|
940
955
|
value = portfolio.filter(regex=f"{symbol}_Value").loc[start:]
|
|
941
956
|
leverage = (value.squeeze() / capital).mul(100).rename("Leverage")
|
|
942
957
|
indicators["Leverage"] = ["area", "cyan", leverage]
|
|
958
|
+
symbol_count = len(portfolio.filter(like="_PnL").columns)
|
|
959
|
+
pnl = portfolio.filter(regex=f"{symbol}_PnL").cumsum() + result.capital / symbol_count
|
|
960
|
+
pnl = pnl.loc[start:]
|
|
961
|
+
if apply_commissions:
|
|
962
|
+
comm = portfolio.filter(regex=f"{symbol}_Commissions").loc[start:].cumsum()
|
|
963
|
+
pnl -= comm.values
|
|
964
|
+
pnl = (pnl / pnl.iloc[0] - 1) * 100
|
|
965
|
+
indicators["PnL"] = ["area", "green", pnl]
|
|
943
966
|
|
|
944
967
|
if isinstance(ohlc, dict):
|
|
945
968
|
bars = ohlc[symbol]
|
|
@@ -952,6 +975,8 @@ def chart_signals(
|
|
|
952
975
|
elif isinstance(ohlc, OHLCV):
|
|
953
976
|
bars = ohlc.pd()
|
|
954
977
|
bars = ohlc_resample(bars, timeframe) if timeframe else bars
|
|
978
|
+
else:
|
|
979
|
+
raise ValueError(f"Invalid data type {type(ohlc)}")
|
|
955
980
|
|
|
956
981
|
if timeframe:
|
|
957
982
|
|
|
Binary file
|