Qubx 0.2.64__cp311-cp311-manylinux_2_35_x86_64.whl → 0.2.69__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 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 Deal, Instrument, Order, Position, Signal, TransactionCostsCalculator, dt_64, ITimeProvider
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: str,
88
- order_type: str,
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
- fill_at_price: bool = False,
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, fill_at_price=fill_at_price)
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, fill_at_price: bool = False) -> OmeReport:
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
- if fill_at_price and order.price:
134
- exec_price = order.price
160
+ _need_update_book = False
135
161
 
136
- elif order.type == "MARKET":
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 buy_side:
149
- self.bids.setdefault(order.price, list()).append(order.id)
150
- else:
151
- self.asks.setdefault(order.price, list()).append(order.id)
152
- order.status = "OPEN"
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
- if order_type.upper() not in ["LIMIT", "MARKET"]:
184
- raise InvalidOrder("Invalid order type. Only LIMIT or MARKET is supported.")
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 order_type.upper() == "LIMIT" and (price is None or price <= 0):
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
- def cancel_order(self, order_id: str) -> OmeReport:
196
- if order_id not in self.active_orders:
197
- raise InvalidOrder(f"Order {order_id} not found for {self.instrument.symbol}")
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
- timestamp = self.time_service.time()
200
- order = self.active_orders.pop(order_id)
201
- if order.side == "BUY":
202
- oids = self.bids[order.price]
203
- oids.remove(order_id)
204
- if not oids:
205
- self.bids.pop(order.price)
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
- oids = self.asks[order.price]
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(timestamp, order, None)
266
+ return OmeReport(self.time_service.time(), order, None)
215
267
 
216
268
  def __str__(self) -> str:
217
269
  _a, _b = True, True
@@ -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"<yellow>{now}</yellow> [ <level>{record['level'].icon}</level> ] "
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
- fill_at_price=options.get("fill_at_price", False),
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 = np.datetime64(0, "ns")
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 _GeneratedSignalsStrategy(IStrategy):
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 = _GeneratedSignalsStrategy()
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 = _GeneratedSignalsStrategy()
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: str
311
+ type: OrderType
291
312
  symbol: str
292
313
  time: dt_64
293
314
  quantity: float
294
315
  price: float
295
- side: str
296
- status: str
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.__process_target_positions(
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"[{self.time()}]: Invoking {self.strategy.__class__.__name__} on_fit('{current_fit_time}', '{prev_fit_time}')"
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"[{self.time()}]: {self.strategy.__class__.__name__} is fitted")
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
- # set reference prices for signals
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 __process_target_positions(
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, self.__process_target_positions(self.positions_tracker.update(self, self._symb_to_instr[symbol], bar))
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.__process_target_positions(target_positions),
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.__process_target_positions(target_positions))
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 = "limit" if price is not None else "market"
720
- logger.debug(f"(StrategyContext) sending {type} {side} for {size_adj} of {instrument.symbol} ...")
721
- client_id = self._generate_order_client_id(instrument.symbol)
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
- if self.broker_provider.is_simulated_trading and options.get("fill_at_price", False):
724
- # assume worst case, if we force execution and certain price, assume it's via market
725
- # TODO: add an additional flag besides price to indicate order type
726
- type = "market"
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
@@ -353,6 +353,7 @@ class SignalsLogger(_BaseIntervalDumper):
353
353
  "stop": s.stop,
354
354
  "group": s.signal.group,
355
355
  "comment": s.signal.comment,
356
+ "service": s.is_service,
356
357
  }
357
358
  )
358
359
  self._targets.clear()