Qubx 0.5.7__cp312-cp312-manylinux_2_39_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (100) hide show
  1. qubx/__init__.py +207 -0
  2. qubx/_nb_magic.py +100 -0
  3. qubx/backtester/__init__.py +5 -0
  4. qubx/backtester/account.py +145 -0
  5. qubx/backtester/broker.py +87 -0
  6. qubx/backtester/data.py +296 -0
  7. qubx/backtester/management.py +378 -0
  8. qubx/backtester/ome.py +296 -0
  9. qubx/backtester/optimization.py +201 -0
  10. qubx/backtester/simulated_data.py +558 -0
  11. qubx/backtester/simulator.py +362 -0
  12. qubx/backtester/utils.py +780 -0
  13. qubx/cli/__init__.py +0 -0
  14. qubx/cli/commands.py +67 -0
  15. qubx/connectors/ccxt/__init__.py +0 -0
  16. qubx/connectors/ccxt/account.py +495 -0
  17. qubx/connectors/ccxt/broker.py +132 -0
  18. qubx/connectors/ccxt/customizations.py +193 -0
  19. qubx/connectors/ccxt/data.py +612 -0
  20. qubx/connectors/ccxt/exceptions.py +17 -0
  21. qubx/connectors/ccxt/factory.py +93 -0
  22. qubx/connectors/ccxt/utils.py +307 -0
  23. qubx/core/__init__.py +0 -0
  24. qubx/core/account.py +251 -0
  25. qubx/core/basics.py +850 -0
  26. qubx/core/context.py +420 -0
  27. qubx/core/exceptions.py +38 -0
  28. qubx/core/helpers.py +480 -0
  29. qubx/core/interfaces.py +1150 -0
  30. qubx/core/loggers.py +514 -0
  31. qubx/core/lookups.py +475 -0
  32. qubx/core/metrics.py +1512 -0
  33. qubx/core/mixins/__init__.py +13 -0
  34. qubx/core/mixins/market.py +94 -0
  35. qubx/core/mixins/processing.py +428 -0
  36. qubx/core/mixins/subscription.py +203 -0
  37. qubx/core/mixins/trading.py +88 -0
  38. qubx/core/mixins/universe.py +270 -0
  39. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  40. qubx/core/series.pxd +125 -0
  41. qubx/core/series.pyi +118 -0
  42. qubx/core/series.pyx +988 -0
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/core/utils.pyi +6 -0
  45. qubx/core/utils.pyx +62 -0
  46. qubx/data/__init__.py +25 -0
  47. qubx/data/helpers.py +416 -0
  48. qubx/data/readers.py +1562 -0
  49. qubx/data/tardis.py +100 -0
  50. qubx/gathering/simplest.py +88 -0
  51. qubx/math/__init__.py +3 -0
  52. qubx/math/stats.py +129 -0
  53. qubx/pandaz/__init__.py +23 -0
  54. qubx/pandaz/ta.py +2757 -0
  55. qubx/pandaz/utils.py +638 -0
  56. qubx/resources/instruments/symbols-binance.cm.json +1 -0
  57. qubx/resources/instruments/symbols-binance.json +1 -0
  58. qubx/resources/instruments/symbols-binance.um.json +1 -0
  59. qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
  60. qubx/resources/instruments/symbols-bitfinex.json +1 -0
  61. qubx/resources/instruments/symbols-kraken.f.json +1 -0
  62. qubx/resources/instruments/symbols-kraken.json +1 -0
  63. qubx/ta/__init__.py +0 -0
  64. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  65. qubx/ta/indicators.pxd +149 -0
  66. qubx/ta/indicators.pyi +41 -0
  67. qubx/ta/indicators.pyx +787 -0
  68. qubx/trackers/__init__.py +3 -0
  69. qubx/trackers/abvanced.py +236 -0
  70. qubx/trackers/composite.py +146 -0
  71. qubx/trackers/rebalancers.py +129 -0
  72. qubx/trackers/riskctrl.py +641 -0
  73. qubx/trackers/sizers.py +235 -0
  74. qubx/utils/__init__.py +5 -0
  75. qubx/utils/_pyxreloader.py +281 -0
  76. qubx/utils/charting/lookinglass.py +1057 -0
  77. qubx/utils/charting/mpl_helpers.py +1183 -0
  78. qubx/utils/marketdata/binance.py +284 -0
  79. qubx/utils/marketdata/ccxt.py +90 -0
  80. qubx/utils/marketdata/dukas.py +130 -0
  81. qubx/utils/misc.py +541 -0
  82. qubx/utils/ntp.py +63 -0
  83. qubx/utils/numbers_utils.py +7 -0
  84. qubx/utils/orderbook.py +491 -0
  85. qubx/utils/plotting/__init__.py +0 -0
  86. qubx/utils/plotting/dashboard.py +150 -0
  87. qubx/utils/plotting/data.py +137 -0
  88. qubx/utils/plotting/interfaces.py +25 -0
  89. qubx/utils/plotting/renderers/__init__.py +0 -0
  90. qubx/utils/plotting/renderers/plotly.py +0 -0
  91. qubx/utils/runner/__init__.py +1 -0
  92. qubx/utils/runner/_jupyter_runner.pyt +60 -0
  93. qubx/utils/runner/accounts.py +88 -0
  94. qubx/utils/runner/configs.py +65 -0
  95. qubx/utils/runner/runner.py +470 -0
  96. qubx/utils/time.py +312 -0
  97. qubx-0.5.7.dist-info/METADATA +105 -0
  98. qubx-0.5.7.dist-info/RECORD +100 -0
  99. qubx-0.5.7.dist-info/WHEEL +4 -0
  100. qubx-0.5.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,641 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Literal, TypeAlias
4
+
5
+ import numpy as np
6
+
7
+ from qubx import logger
8
+ from qubx.core.basics import Deal, Instrument, Signal, TargetPosition
9
+ from qubx.core.interfaces import IPositionSizer, IStrategyContext, PositionsTracker
10
+ from qubx.core.series import Bar, OrderBook, Quote, Trade
11
+ from qubx.ta.indicators import atr
12
+ from qubx.trackers.sizers import FixedSizer
13
+
14
+ RiskControllingSide: TypeAlias = Literal["broker", "client"]
15
+
16
+
17
+ class State(Enum):
18
+ NEW = 0
19
+ OPEN = 1
20
+ RISK_TRIGGERED = 2
21
+ DONE = 3
22
+
23
+
24
+ @dataclass
25
+ class SgnCtrl:
26
+ signal: Signal
27
+ target: TargetPosition
28
+ status: State = State.NEW
29
+ take_order_id: str | None = None
30
+ stop_order_id: str | None = None
31
+ take_executed_price: float | None = None
32
+ stop_executed_price: float | None = None
33
+
34
+
35
+ class RiskCalculator:
36
+ def calculate_risks(self, ctx: IStrategyContext, quote: Quote, signal: Signal) -> Signal | None:
37
+ return signal
38
+
39
+
40
+ class RiskController(PositionsTracker):
41
+ _trackings: dict[Instrument, SgnCtrl]
42
+ _waiting: dict[Instrument, SgnCtrl]
43
+ _risk_calculator: RiskCalculator
44
+
45
+ def __init__(self, name: str, risk_calculator: RiskCalculator, sizer: IPositionSizer) -> None:
46
+ self._name = f"{name}.{self.__class__.__name__}"
47
+ self._risk_calculator = risk_calculator
48
+ self._trackings = {}
49
+ self._waiting = {}
50
+ super().__init__(sizer)
51
+
52
+ @staticmethod
53
+ def _get_price(update: float | Quote | Trade | Bar | OrderBook, direction: int) -> float:
54
+ if isinstance(update, float):
55
+ return update
56
+ elif isinstance(update, Quote):
57
+ return update.ask if direction > 0 else update.bid
58
+ elif isinstance(update, OrderBook):
59
+ return update.top_ask if direction > 0 else update.top_bid
60
+ elif isinstance(update, Trade):
61
+ return update.price
62
+ elif isinstance(update, Bar):
63
+ return update.close
64
+ else:
65
+ raise ValueError(f"Unknown update type: {type(update)}")
66
+
67
+ def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition]:
68
+ targets = []
69
+ for s in signals:
70
+ quote = ctx.quote(s.instrument)
71
+ if quote is None:
72
+ logger.warning(
73
+ f"[<y>{self._name}</y>] :: Quote is not available for <g>{s.instrument}</g>. Skipping signal {s}"
74
+ )
75
+ continue
76
+
77
+ # - calculate risk, here we need to use copy of signa to prevent modifications of original signal
78
+ s_copy = s.copy()
79
+ signal_with_risk = self._risk_calculator.calculate_risks(ctx, quote, s_copy)
80
+ if signal_with_risk is None:
81
+ continue
82
+
83
+ # - final step - calculate actual target position and check if tracker can approve it
84
+ target = self.get_position_sizer().calculate_target_positions(ctx, [signal_with_risk])[0]
85
+ if self.handle_new_target(ctx, s_copy, target):
86
+ targets.append(target)
87
+
88
+ return targets
89
+
90
+ def handle_new_target(self, ctx: IStrategyContext, signal: Signal, target: TargetPosition) -> bool:
91
+ """
92
+ As it doesn't use any referenced orders for position - new target is always approved
93
+ """
94
+ # - add first in waiting list
95
+ self._waiting[signal.instrument] = SgnCtrl(signal, target, State.NEW)
96
+ logger.debug(
97
+ f"[<y>{self._name}</y>(<g>{ signal.instrument }</g>)] :: Processing signal ({signal.signal}) to target: <c><b>{target}</b></c>"
98
+ )
99
+
100
+ return True
101
+
102
+ def is_active(self, instrument: Instrument) -> bool:
103
+ return instrument in self._trackings
104
+
105
+
106
+ class ClientSideRiskController(RiskController):
107
+ """
108
+ Risk is controlled on client (Qubx) side without using limit order for take and stop order for loss.
109
+ So when risk is triggered, it uses market orders to close position immediatelly.
110
+ As result it may lead to significant slippage.
111
+ """
112
+
113
+ def update(
114
+ self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
115
+ ) -> list[TargetPosition] | TargetPosition:
116
+ c = self._trackings.get(instrument)
117
+ if c is None:
118
+ return []
119
+
120
+ match c.status:
121
+ case State.NEW:
122
+ # - nothing to do just waiting for position to be open
123
+ pass
124
+
125
+ case State.RISK_TRIGGERED:
126
+ # - nothing to do just waiting for position to be closed
127
+ pass
128
+
129
+ case State.OPEN:
130
+ pos = c.target.target_position_size
131
+ if c.signal.stop:
132
+ if (
133
+ pos > 0
134
+ and self._get_price(update, +1) <= c.signal.stop
135
+ or (pos < 0 and self._get_price(update, -1) >= c.signal.stop)
136
+ ):
137
+ c.status = State.RISK_TRIGGERED
138
+ logger.debug(
139
+ f"[<y>{self._name}</y>(<g>{ c.signal.instrument }</g>)] :: triggered <red>STOP LOSS</red> at {c.signal.stop}"
140
+ )
141
+ return TargetPosition.zero(
142
+ ctx, instrument.signal(0, group="Risk Manager", comment="Stop triggered")
143
+ )
144
+
145
+ if c.signal.take:
146
+ if (
147
+ pos > 0
148
+ and self._get_price(update, -1) >= c.signal.take
149
+ or (pos < 0 and self._get_price(update, +1) <= c.signal.take)
150
+ ):
151
+ c.status = State.RISK_TRIGGERED
152
+ logger.debug(
153
+ f"[<y>{self._name}</y>(<g>{ c.signal.instrument }</g>)] :: triggered <g>TAKE PROFIT</g> at {c.signal.take}"
154
+ )
155
+ return TargetPosition.zero(
156
+ ctx,
157
+ instrument.signal(
158
+ 0,
159
+ group="Risk Manager",
160
+ comment="Take triggered",
161
+ ),
162
+ )
163
+
164
+ case State.DONE:
165
+ logger.debug(f"[<y>{self._name}</y>(<g>{ c.signal.instrument }</g>)] :: <m>Stop tracking</m>")
166
+ self._trackings.pop(instrument)
167
+
168
+ return []
169
+
170
+ def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
171
+ pos = ctx.positions[instrument].quantity
172
+
173
+ # - check what is in the waiting list
174
+ if (c_w := self._waiting.get(instrument)) is not None:
175
+ if abs(pos - c_w.target.target_position_size) <= instrument.min_size:
176
+ c_w.status = State.OPEN
177
+ self._trackings[instrument] = c_w # add to tracking
178
+ self._waiting.pop(instrument) # remove from waiting
179
+ logger.debug(
180
+ f"[<y>{self._name}</y>(<g>{c_w.signal.instrument.symbol}</g>)] :: Start tracking <cyan><b>{c_w.target}</b></cyan>"
181
+ )
182
+ return
183
+
184
+ # - check what is in the tracking list
185
+ if (c_t := self._trackings.get(instrument)) is not None:
186
+ if c_t.status == State.RISK_TRIGGERED and abs(pos) <= instrument.min_size:
187
+ c_t.status = State.DONE
188
+
189
+
190
+ class BrokerSideRiskController(RiskController):
191
+ """
192
+ Risk is managed on the broker's side by using limit orders for take and stop order for loss.
193
+ For backtesting we assume that stop orders are executed by it's price.
194
+ """
195
+
196
+ def update(
197
+ self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
198
+ ) -> list[TargetPosition]:
199
+ # fmt: off
200
+ c = self._trackings.get(instrument)
201
+ if c is None:
202
+ return []
203
+
204
+ match c.status:
205
+ case State.NEW:
206
+ # - nothing to do just waiting for position to be open
207
+ pass
208
+
209
+ case State.RISK_TRIGGERED:
210
+ c.status = State.DONE
211
+
212
+ # - remove from the tracking list
213
+ logger.debug(
214
+ f"[<y>{self._name}</y>(<g>{instrument.symbol}</g>)] :: risk triggered - <m>Stop tracking</m>"
215
+ )
216
+ self._trackings.pop(instrument)
217
+
218
+ # - send service signal that risk triggeres (it won't be processed by StrategyContext)
219
+ if c.stop_executed_price:
220
+ return [
221
+ TargetPosition.service(
222
+ ctx, instrument.signal(0, price=c.stop_executed_price, group="Risk Manager", comment="Stop triggered"),
223
+ )
224
+ ]
225
+ elif c.take_executed_price:
226
+ return [
227
+ TargetPosition.service(
228
+ ctx, instrument.signal(0, price=c.take_executed_price, group="Risk Manager", comment="Take triggered"),
229
+ )
230
+ ]
231
+
232
+ case State.DONE:
233
+ logger.debug(
234
+ f"[<y>{self._name}</y>(<g>{instrument.symbol}</g>)] state is done - <m>Stop tracking</m>"
235
+ )
236
+ self._trackings.pop(instrument)
237
+
238
+ # fmt: on
239
+ return []
240
+
241
+ def __cncl_stop(self, ctx: IStrategyContext, ctrl: SgnCtrl):
242
+ if ctrl.stop_order_id is not None:
243
+ logger.debug(
244
+ f"[<y>{self._name}</y>(<g>{ ctrl.signal.instrument }</g>)] :: <m>Canceling stop order</m> <red>{ctrl.stop_order_id}</red>"
245
+ )
246
+ try:
247
+ ctx.cancel_order(ctrl.stop_order_id)
248
+ except Exception as e:
249
+ # - in case if order can't be cancelled, it means that it was already cancelled
250
+ logger.error(
251
+ f"[<y>{self._name}</y>(<g>{ ctrl.signal.instrument }</g>)] :: <m>Canceling stop order</m> <red>{ctrl.stop_order_id}</red> failed: {str(e)}"
252
+ )
253
+ ctrl.stop_order_id = None
254
+
255
+ def __cncl_take(self, ctx: IStrategyContext, ctrl: SgnCtrl):
256
+ if ctrl.take_order_id is not None:
257
+ logger.debug(
258
+ f"[<y>{self._name}(<g>{ctrl.signal.instrument}</g>)</y>] :: <m>Canceling take order</m> <r>{ctrl.take_order_id}</r>"
259
+ )
260
+ try:
261
+ ctx.cancel_order(ctrl.take_order_id)
262
+ except Exception as e:
263
+ # - in case if order can't be cancelled, it means that it was already cancelled
264
+ logger.error(
265
+ f"[<y>{self._name}(<g>{ctrl.signal.instrument}</g>)</y>] :: <m>Canceling take order</m> <r>{ctrl.take_order_id}</r> failed: {str(e)}"
266
+ )
267
+ ctrl.take_order_id = None
268
+
269
+ def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
270
+ pos = ctx.positions[instrument].quantity
271
+ _waiting = self._waiting.get(instrument)
272
+
273
+ # - check if there is any waiting signals
274
+ if _waiting is not None:
275
+ _tracking = self._trackings.get(instrument)
276
+ # - when asked to process 0 signal and got new execution - we need to remove all previous orders
277
+ if _waiting.target.target_position_size == 0:
278
+ self._waiting.pop(instrument) # remove from waiting
279
+
280
+ if _tracking is not None:
281
+ logger.debug(
282
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: got execution from <r>{deal.order_id}</r> and before was asked to process 0 by <c>{_waiting.signal}</c>"
283
+ )
284
+ self.__cncl_stop(ctx, _tracking)
285
+ self.__cncl_take(ctx, _tracking)
286
+
287
+ return
288
+
289
+ # - when gathered asked position
290
+ if abs(pos - _waiting.target.target_position_size) <= instrument.min_size:
291
+ _waiting.status = State.OPEN
292
+ logger.debug(
293
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: <r>{deal.order_id}</r> opened position for <c>{_waiting.signal}</c>"
294
+ )
295
+
296
+ # - check if we need to cancel previous stop / take orders
297
+ if _tracking is not None:
298
+ self.__cncl_stop(ctx, _tracking)
299
+ self.__cncl_take(ctx, _tracking)
300
+
301
+ self._trackings[instrument] = _waiting # add to tracking
302
+ self._waiting.pop(instrument) # remove from waiting
303
+
304
+ if _waiting.target.take:
305
+ try:
306
+ logger.debug(
307
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: sending <g>take limit</g> order at {_waiting.target.take}"
308
+ )
309
+ order = ctx.trade(instrument, -pos, _waiting.target.take)
310
+ _waiting.take_order_id = order.id
311
+
312
+ # - if order was executed immediately we don't need to send stop order
313
+ if order.status == "CLOSED":
314
+ _waiting.status = State.RISK_TRIGGERED
315
+ logger.debug(
316
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: <g>TAKE PROFIT</g> was exected immediately at {_waiting.target.take}"
317
+ )
318
+ return
319
+
320
+ except Exception as e:
321
+ logger.error(
322
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: couldn't send take limit order: {str(e)}"
323
+ )
324
+
325
+ if _waiting.target.stop:
326
+ try:
327
+ logger.debug(
328
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: sending <g>stop</g> order at {_waiting.target.stop}"
329
+ )
330
+ # - for simulation purposes we assume that stop order will be executed at stop price
331
+ order = ctx.trade(
332
+ instrument, -pos, _waiting.target.stop, stop_type="market", fill_at_signal_price=True
333
+ )
334
+ _waiting.stop_order_id = order.id
335
+ except Exception as e:
336
+ logger.error(
337
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: couldn't send stop order: {str(e)}"
338
+ )
339
+
340
+ # - check tracked signal
341
+ if (_tracking := self._trackings.get(instrument)) is not None:
342
+ if _tracking.status == State.OPEN and abs(pos) <= instrument.min_size:
343
+ if deal.order_id == _tracking.take_order_id:
344
+ _tracking.status = State.RISK_TRIGGERED
345
+ _tracking.take_executed_price = deal.price
346
+ logger.debug(
347
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: triggered <green>TAKE PROFIT</green> (<red>{_tracking.take_order_id}</red>) at {_tracking.take_executed_price}"
348
+ )
349
+ # - cancel stop if need
350
+ self.__cncl_stop(ctx, _tracking)
351
+
352
+ elif deal.order_id == _tracking.stop_order_id:
353
+ _tracking.status = State.RISK_TRIGGERED
354
+ _tracking.stop_executed_price = deal.price
355
+ logger.debug(
356
+ f"[<y>{self._name}</y>(<g>{instrument}</g>)] :: triggered <magenta>STOP LOSS</magenta> (<red>{_tracking.take_order_id}</red>) at {_tracking.stop_executed_price}"
357
+ )
358
+ # - cancel take if need
359
+ self.__cncl_take(ctx, _tracking)
360
+
361
+ else:
362
+ # - closed by opposite signal or externally
363
+ _tracking.status = State.DONE
364
+ self.__cncl_stop(ctx, _tracking)
365
+ self.__cncl_take(ctx, _tracking)
366
+
367
+
368
+ class GenericRiskControllerDecorator(PositionsTracker, RiskCalculator):
369
+ riskctrl: RiskController
370
+
371
+ def __init__(
372
+ self,
373
+ sizer: IPositionSizer,
374
+ riskctrl: RiskController,
375
+ ) -> None:
376
+ super().__init__(sizer)
377
+ self.riskctrl = riskctrl
378
+
379
+ def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition]:
380
+ return self.riskctrl.process_signals(ctx, signals)
381
+
382
+ def is_active(self, instrument: Instrument) -> bool:
383
+ return self.riskctrl.is_active(instrument)
384
+
385
+ def update(
386
+ self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
387
+ ) -> list[TargetPosition] | TargetPosition:
388
+ return self.riskctrl.update(ctx, instrument, update)
389
+
390
+ def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
391
+ return self.riskctrl.on_execution_report(ctx, instrument, deal)
392
+
393
+ def calculate_risks(self, ctx: IStrategyContext, quote: Quote, signal: Signal) -> Signal | None:
394
+ raise NotImplementedError("calculate_risks should be implemented by subclasses")
395
+
396
+ @staticmethod
397
+ def create_risk_controller_for_side(
398
+ name: str, risk_controlling_side: RiskControllingSide, risk_calculator: RiskCalculator, sizer: IPositionSizer
399
+ ) -> RiskController:
400
+ match risk_controlling_side:
401
+ case "broker":
402
+ return BrokerSideRiskController(name, risk_calculator, sizer)
403
+ case "client":
404
+ return ClientSideRiskController(name, risk_calculator, sizer)
405
+ case _:
406
+ raise ValueError(
407
+ f"Invalid risk controlling side: {risk_controlling_side} for {name} only 'broker' or 'client' are supported"
408
+ )
409
+
410
+
411
+ class StopTakePositionTracker(GenericRiskControllerDecorator):
412
+ """
413
+ Basic fixed stop-take position tracker. It observes position opening or closing and controls stop-take logic.
414
+ It may use either limit and stop orders for managing risk or market orders depending on 'risk_controlling_side' parameter.
415
+ """
416
+
417
+ def __init__(
418
+ self,
419
+ take_target: float | None = None,
420
+ stop_risk: float | None = None,
421
+ sizer: IPositionSizer = FixedSizer(1.0, amount_in_quote=False),
422
+ risk_controlling_side: RiskControllingSide = "broker",
423
+ purpose: str = "", # if we need to distinguish different instances of the same tracker, i.e. for shorts or longs etc
424
+ ) -> None:
425
+ self.take_target = take_target
426
+ self.stop_risk = stop_risk
427
+ self._take_target_fraction = take_target / 100 if take_target else None
428
+ self._stop_risk_fraction = stop_risk / 100 if stop_risk else None
429
+
430
+ super().__init__(
431
+ sizer,
432
+ GenericRiskControllerDecorator.create_risk_controller_for_side(
433
+ f"{self.__class__.__name__}{purpose}", risk_controlling_side, self, sizer
434
+ ),
435
+ )
436
+
437
+ def calculate_risks(self, ctx: IStrategyContext, quote: Quote, signal: Signal) -> Signal | None:
438
+ if signal.signal > 0:
439
+ entry = signal.price if signal.price else quote.ask
440
+ if self._take_target_fraction:
441
+ signal.take = entry * (1 + self._take_target_fraction)
442
+ if self._stop_risk_fraction:
443
+ signal.stop = entry * (1 - self._stop_risk_fraction)
444
+
445
+ elif signal.signal < 0:
446
+ entry = signal.price if signal.price else quote.bid
447
+ if self._take_target_fraction:
448
+ signal.take = entry * (1 - self._take_target_fraction)
449
+ if self._stop_risk_fraction:
450
+ signal.stop = entry * (1 + self._stop_risk_fraction)
451
+
452
+ if signal.stop is not None:
453
+ signal.stop = signal.instrument.round_price_down(signal.stop)
454
+ if signal.take is not None:
455
+ signal.take = signal.instrument.round_price_down(signal.take)
456
+
457
+ return signal
458
+
459
+
460
+ class AtrRiskTracker(GenericRiskControllerDecorator):
461
+ """
462
+ ATR based risk management
463
+ - Take at entry +/- ATR[1] * take_target
464
+ - Stop at entry -/+ ATR[1] * stop_risk
465
+ It may use either limit and stop orders for managing risk or market orders depending on 'risk_controlling_side' parameter.
466
+ """
467
+
468
+ def __init__(
469
+ self,
470
+ take_target: float | None,
471
+ stop_risk: float | None,
472
+ atr_timeframe: str,
473
+ atr_period: int,
474
+ atr_smoother="sma",
475
+ sizer: IPositionSizer = FixedSizer(1.0),
476
+ risk_controlling_side: RiskControllingSide = "broker",
477
+ purpose: str = "", # if we need to distinguish different instances of the same tracker, i.e. for shorts or longs etc
478
+ ) -> None:
479
+ self.take_target = take_target
480
+ self.stop_risk = stop_risk
481
+ self.atr_timeframe = atr_timeframe
482
+ self.atr_period = atr_period
483
+ self.atr_smoother = atr_smoother
484
+ self._full_name = f"{self.__class__.__name__}{purpose}"
485
+
486
+ super().__init__(
487
+ sizer,
488
+ GenericRiskControllerDecorator.create_risk_controller_for_side(
489
+ f"{self._full_name}", risk_controlling_side, self, sizer
490
+ ),
491
+ )
492
+
493
+ def calculate_risks(self, ctx: IStrategyContext, quote: Quote, signal: Signal) -> Signal | None:
494
+ volatility = atr(
495
+ ctx.ohlc(signal.instrument, self.atr_timeframe),
496
+ self.atr_period,
497
+ smoother=self.atr_smoother,
498
+ percentage=False,
499
+ )
500
+
501
+ if len(volatility) < 2 or ((last_volatility := volatility[1]) is None or not np.isfinite(last_volatility)):
502
+ logger.debug(
503
+ f"[<y>{self._full_name}</y>(<g>{signal.instrument}</g>)] :: not enough ATR data, skipping risk calculation"
504
+ )
505
+ return None
506
+
507
+ if quote is None:
508
+ logger.debug(
509
+ f"[<y>{self._full_name}</y>(<g>{signal.instrument}</g>)] :: there is no actual market data, skipping risk calculation"
510
+ )
511
+ return None
512
+
513
+ if signal.signal > 0:
514
+ entry = signal.price if signal.price else quote.ask
515
+ if self.stop_risk:
516
+ signal.stop = entry - self.stop_risk * last_volatility
517
+ if self.take_target:
518
+ signal.take = entry + self.take_target * last_volatility
519
+
520
+ elif signal.signal < 0:
521
+ entry = signal.price if signal.price else quote.bid
522
+ if self.stop_risk:
523
+ signal.stop = entry + self.stop_risk * last_volatility
524
+ if self.take_target:
525
+ signal.take = entry - self.take_target * last_volatility
526
+
527
+ return signal
528
+
529
+
530
+ class MinAtrExitDistanceTracker(PositionsTracker):
531
+ """
532
+ Allow exit only if price has moved away from entry by the specified distance in ATR units.
533
+ """
534
+
535
+ _signals: dict[Instrument, Signal]
536
+
537
+ def __init__(
538
+ self,
539
+ take_target: float | None,
540
+ stop_target: float | None,
541
+ atr_timeframe: str,
542
+ atr_period: int,
543
+ atr_smoother="sma",
544
+ sizer: IPositionSizer = FixedSizer(1.0),
545
+ ) -> None:
546
+ super().__init__(sizer)
547
+ self.atr_timeframe = atr_timeframe
548
+ self.atr_period = atr_period
549
+ self.atr_smoother = atr_smoother
550
+ self.take_target = take_target
551
+ self.stop_target = stop_target
552
+ self._signals = dict()
553
+
554
+ def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition]:
555
+ targets = []
556
+ for s in signals:
557
+ volatility = atr(
558
+ ctx.ohlc(s.instrument, self.atr_timeframe),
559
+ self.atr_period,
560
+ smoother=self.atr_smoother,
561
+ percentage=False,
562
+ )
563
+ if len(volatility) < 2:
564
+ continue
565
+ last_volatility = volatility[1]
566
+ quote = ctx.quote(s.instrument)
567
+ if last_volatility is None or not np.isfinite(last_volatility) or quote is None:
568
+ continue
569
+
570
+ self._signals[s.instrument] = s
571
+
572
+ if s.signal != 0:
573
+ # if signal is not 0, atr thresholds don't apply
574
+ # set expected stop price in case sizer needs it
575
+ if s.stop is None:
576
+ price = quote.ask if s.signal > 0 else quote.bid
577
+ s.stop = (
578
+ price - self.stop_target * last_volatility
579
+ if s.signal > 0
580
+ else price + self.stop_target * last_volatility
581
+ )
582
+
583
+ target = self.get_position_sizer().calculate_target_positions(ctx, [s])[0]
584
+ targets.append(target)
585
+ continue
586
+
587
+ if self.__check_exit(ctx, s.instrument):
588
+ logger.debug(
589
+ f"[<y>{self.__class__.__name__}</y>(<g>{s.instrument.symbol}</g>)] :: <y>Min ATR distance reached</y>"
590
+ )
591
+ targets.append(TargetPosition.zero(ctx, s))
592
+
593
+ return targets
594
+
595
+ def update(
596
+ self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
597
+ ) -> list[TargetPosition] | TargetPosition:
598
+ signal = self._signals.get(instrument)
599
+ if signal is None or signal.signal != 0:
600
+ return []
601
+ if not self.__check_exit(ctx, instrument):
602
+ return []
603
+ logger.debug(
604
+ f"[<y>{self.__class__.__name__}</y>(<g>{instrument.symbol}</g>)] :: <y>Min ATR distance reached</y>"
605
+ )
606
+ return TargetPosition.zero(
607
+ ctx, instrument.signal(0, group="Risk Manager", comment=f"Original signal price: {signal.reference_price}")
608
+ )
609
+
610
+ def __check_exit(self, ctx: IStrategyContext, instrument: Instrument) -> bool:
611
+ volatility = atr(
612
+ ctx.ohlc(instrument, self.atr_timeframe),
613
+ self.atr_period,
614
+ smoother=self.atr_smoother,
615
+ percentage=False,
616
+ )
617
+ if len(volatility) < 2:
618
+ return False
619
+
620
+ last_volatility = volatility[1]
621
+ quote = ctx.quote(instrument)
622
+ if last_volatility is None or not np.isfinite(last_volatility) or quote is None:
623
+ return False
624
+
625
+ pos = ctx.positions.get(instrument)
626
+ if pos is None:
627
+ return False
628
+
629
+ entry = pos.position_avg_price
630
+ allow_exit = False
631
+ if pos.quantity > 0:
632
+ stop = entry - self.stop_target * last_volatility
633
+ take = entry + self.take_target * last_volatility
634
+ if quote.bid <= stop or quote.ask >= take:
635
+ allow_exit = True
636
+ else:
637
+ stop = entry + self.stop_target * last_volatility
638
+ take = entry - self.take_target * last_volatility
639
+ if quote.ask >= stop or quote.bid <= take:
640
+ allow_exit = True
641
+ return allow_exit