Qubx 0.5.7__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/__init__.py +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- qubx-0.5.7.dist-info/entry_points.txt +3 -0
|
@@ -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
|