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,236 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from qubx import logger
|
|
6
|
+
from qubx.core.basics import Deal, Instrument, Signal, TargetPosition
|
|
7
|
+
from qubx.core.interfaces import IPositionSizer, IStrategyContext, PositionsTracker
|
|
8
|
+
from qubx.core.series import OHLCV, Bar, Quote, Trade
|
|
9
|
+
from qubx.trackers.riskctrl import State, StopTakePositionTracker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _AdvCtrl:
|
|
14
|
+
signal: Signal
|
|
15
|
+
entry: float
|
|
16
|
+
stop: float
|
|
17
|
+
entry_bar_time: int
|
|
18
|
+
is_being_tracked: bool = True
|
|
19
|
+
|
|
20
|
+
def is_long(self) -> bool:
|
|
21
|
+
return self.signal.signal > 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImprovedEntryTracker(PositionsTracker):
|
|
25
|
+
"""
|
|
26
|
+
Advanced entry tracker with additional features like tracking price improvements, reassigning stops, and stop-take functionality.
|
|
27
|
+
|
|
28
|
+
Provides the same functionality as StopTakePositionTracker but sends take/stop as limit/stop orders
|
|
29
|
+
immediately after the tracked position is opened. If new signal is received it should adjust take and stop orders.
|
|
30
|
+
|
|
31
|
+
TODO: Need test for ImprovedEntryTracker (AdvancedTrackers)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
timeframe: str
|
|
35
|
+
_tracker: StopTakePositionTracker
|
|
36
|
+
_ohlcs: dict[Instrument, OHLCV]
|
|
37
|
+
_entries: dict[Instrument, _AdvCtrl]
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
timeframe: str,
|
|
42
|
+
sizer: IPositionSizer,
|
|
43
|
+
track_price_improvements: bool = True,
|
|
44
|
+
reassign_stops: bool = True, # set stop to signal's bar low / high
|
|
45
|
+
take_target: float | None = None,
|
|
46
|
+
stop_risk: float | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(sizer)
|
|
49
|
+
self.track_price_improvements = track_price_improvements
|
|
50
|
+
self.reassign_stops = reassign_stops
|
|
51
|
+
self.timeframe = timeframe
|
|
52
|
+
self._ohlcs = {}
|
|
53
|
+
self._tracker = StopTakePositionTracker(
|
|
54
|
+
take_target=take_target, stop_risk=stop_risk, sizer=sizer, risk_controlling_side="broker"
|
|
55
|
+
)
|
|
56
|
+
self._entries = {}
|
|
57
|
+
|
|
58
|
+
def ohlc(self, instrument: Instrument) -> OHLCV:
|
|
59
|
+
"""
|
|
60
|
+
We need this for make it works in multiprocessing env
|
|
61
|
+
"""
|
|
62
|
+
r = self._ohlcs.get(instrument)
|
|
63
|
+
if r is None:
|
|
64
|
+
self._ohlcs[instrument] = (r := OHLCV(instrument.symbol, self.timeframe, 3))
|
|
65
|
+
return r
|
|
66
|
+
|
|
67
|
+
def is_active(self, instrument: Instrument) -> bool:
|
|
68
|
+
if instrument in self._entries:
|
|
69
|
+
return self._entries[instrument].is_being_tracked
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition]:
|
|
73
|
+
_sgs = []
|
|
74
|
+
for s in signals:
|
|
75
|
+
instrument = s.instrument
|
|
76
|
+
if s.signal == 0:
|
|
77
|
+
if instrument in self._entries:
|
|
78
|
+
logger.debug(
|
|
79
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <W>REMOVING FROM</W> got 0 signal - stop entry processing"
|
|
80
|
+
)
|
|
81
|
+
self._entries.pop(instrument)
|
|
82
|
+
else:
|
|
83
|
+
# - buy at signal bar high or sell at signal bar low
|
|
84
|
+
signal_bar: Bar = self.ohlc(instrument)[0]
|
|
85
|
+
# - reassign new entry and stop to signal
|
|
86
|
+
ent_price = signal_bar.high if s.signal > 0 else signal_bar.low
|
|
87
|
+
stp_price = signal_bar.low if s.signal > 0 else signal_bar.high
|
|
88
|
+
s.price = ent_price # new entry
|
|
89
|
+
if self.reassign_stops or s.stop is None: # reassign new stop to signal if signal has no stops
|
|
90
|
+
s.stop = stp_price
|
|
91
|
+
|
|
92
|
+
# - case 1: there's already tracked entry
|
|
93
|
+
if self._is_entry_tracked_for(instrument):
|
|
94
|
+
# - ask tracker to cancel entry stop order
|
|
95
|
+
logger.debug(
|
|
96
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <Y><k> Cancel entry because new signal </k></Y></g>"
|
|
97
|
+
)
|
|
98
|
+
_sgs.append(instrument.signal(0, comment=f"{s.comment} - Cancel entry because new signal"))
|
|
99
|
+
|
|
100
|
+
# - case 2: position is opened and StopTake tracker tracks it
|
|
101
|
+
if self._tracker_has_position(instrument):
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <Y><k> Close position because new signal </k></Y>"
|
|
104
|
+
)
|
|
105
|
+
_sgs.append(instrument.signal(0, comment=f"{s.comment} Close positon because new signal"))
|
|
106
|
+
|
|
107
|
+
# - new entry for tracking
|
|
108
|
+
self._entries[instrument] = _AdvCtrl(s, entry=ent_price, stop=s.stop, entry_bar_time=signal_bar.time)
|
|
109
|
+
|
|
110
|
+
_sgs.append(s)
|
|
111
|
+
|
|
112
|
+
return self._tracker.process_signals(ctx, _sgs)
|
|
113
|
+
|
|
114
|
+
def update(
|
|
115
|
+
self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar
|
|
116
|
+
) -> list[TargetPosition] | TargetPosition:
|
|
117
|
+
_new_bar = False
|
|
118
|
+
if isinstance(update, Bar):
|
|
119
|
+
_new_bar = self.ohlc(instrument).update_by_bar(
|
|
120
|
+
update.time, update.open, update.high, update.low, upd := update.close, update.volume
|
|
121
|
+
)
|
|
122
|
+
elif isinstance(update, Quote):
|
|
123
|
+
_new_bar = self.ohlc(instrument).update(update.time, upd := update.mid_price(), 0)
|
|
124
|
+
elif isinstance(update, Trade):
|
|
125
|
+
_new_bar = self.ohlc(instrument).update(update.time, upd := update.price, update.size)
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unknown update type: {type(update)}")
|
|
128
|
+
|
|
129
|
+
_res = []
|
|
130
|
+
if self._is_entry_tracked_for(instrument):
|
|
131
|
+
s = self._entries[instrument]
|
|
132
|
+
_is_long = s.is_long()
|
|
133
|
+
|
|
134
|
+
if (_is_long and upd < s.stop) or (not _is_long and upd > s.stop):
|
|
135
|
+
logger.debug(
|
|
136
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <R><k>CANCELING ENTRY</k></R> : {s.signal} ||| {upd}"
|
|
137
|
+
)
|
|
138
|
+
self._entries.pop(instrument)
|
|
139
|
+
return self._tracker.process_signals(
|
|
140
|
+
ctx,
|
|
141
|
+
[
|
|
142
|
+
instrument.signal(
|
|
143
|
+
0, comment=f"Cancel: price {upd} broke entry's {'low' if _is_long else 'high'} at {s.stop}"
|
|
144
|
+
)
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# - if we need to improve entry on new bar
|
|
149
|
+
if _new_bar and self.track_price_improvements:
|
|
150
|
+
bar = self.ohlc(instrument)[1]
|
|
151
|
+
if _is_long and bar.high < s.entry:
|
|
152
|
+
logger.debug(
|
|
153
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <M><k>IMPROVING LONG ENTRY</k></M> : {s.signal.price} -> {bar.high}"
|
|
154
|
+
)
|
|
155
|
+
s.signal.price = bar.high
|
|
156
|
+
s.signal.take = self.get_new_take_on_improve(ctx, s.signal, bar)
|
|
157
|
+
s.entry = bar.high
|
|
158
|
+
_res.extend(self._tracker.process_signals(ctx, [s.signal]))
|
|
159
|
+
elif not _is_long and bar.low > s.entry:
|
|
160
|
+
logger.debug(
|
|
161
|
+
f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <M><k>IMPROVING SHORT ENTRY</k></M> : {s.signal.price} -> {bar.low}"
|
|
162
|
+
)
|
|
163
|
+
s.signal.price = bar.low
|
|
164
|
+
s.signal.take = self.get_new_take_on_improve(ctx, s.signal, bar)
|
|
165
|
+
s.entry = bar.low
|
|
166
|
+
_res.extend(self._tracker.process_signals(ctx, [s.signal]))
|
|
167
|
+
|
|
168
|
+
if _tu := self._tracker.update(ctx, instrument, update):
|
|
169
|
+
_res.extend(_tu)
|
|
170
|
+
return _res
|
|
171
|
+
|
|
172
|
+
def get_new_take_on_improve(self, ctx: IStrategyContext, s: Signal, b: Bar) -> float | None:
|
|
173
|
+
"""
|
|
174
|
+
What's new take target after improve (remains the same by default)
|
|
175
|
+
"""
|
|
176
|
+
return s.take
|
|
177
|
+
|
|
178
|
+
def _is_entry_tracked_for(self, instrument: Instrument):
|
|
179
|
+
return instrument in self._entries and (s := self._entries[instrument]).is_being_tracked
|
|
180
|
+
|
|
181
|
+
def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
|
|
182
|
+
self._tracker.on_execution_report(ctx, instrument, deal)
|
|
183
|
+
|
|
184
|
+
# - tracker becomes active - so position is open
|
|
185
|
+
if self._tracker_has_position(instrument):
|
|
186
|
+
logger.debug(f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <M><w> Position is open </w></M>")
|
|
187
|
+
if instrument in self._entries:
|
|
188
|
+
self._entries[instrument].is_being_tracked = False
|
|
189
|
+
|
|
190
|
+
if self._tracker_triggers_risk(instrument):
|
|
191
|
+
logger.debug(f"[<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: <R><k> Risk triggered </k></R>")
|
|
192
|
+
if instrument in self._entries:
|
|
193
|
+
self._entries.pop(instrument)
|
|
194
|
+
|
|
195
|
+
def _tracker_has_position(self, instrument: Instrument) -> bool:
|
|
196
|
+
# TODO: implementation is too StopTakePositionTracker dependent !
|
|
197
|
+
# TODO: need generic mehod to check if tracker is active / has open positions
|
|
198
|
+
if instrument in self._tracker.riskctrl._trackings:
|
|
199
|
+
st = self._tracker.riskctrl._trackings[instrument].status
|
|
200
|
+
return st == State.OPEN
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def _tracker_triggers_risk(self, instrument: Instrument) -> bool:
|
|
204
|
+
if instrument in self._tracker.riskctrl._trackings:
|
|
205
|
+
st = self._tracker.riskctrl._trackings[instrument].status
|
|
206
|
+
return st == State.RISK_TRIGGERED
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ImprovedEntryTrackerDynamicTake(ImprovedEntryTracker):
|
|
211
|
+
"""
|
|
212
|
+
Updates take profit target on improved entries
|
|
213
|
+
|
|
214
|
+
TODO: Need test for ImprovedEntryTrackerDynamicTake (AdvancedTrackers)
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
risk_reward_ratio: float
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
timeframe: str,
|
|
222
|
+
sizer: IPositionSizer,
|
|
223
|
+
risk_reward_ratio: float,
|
|
224
|
+
track_price_improvements: bool = True,
|
|
225
|
+
reassign_stops: bool = True, # set stop to signal's bar low / high
|
|
226
|
+
) -> None:
|
|
227
|
+
super().__init__(timeframe, sizer, track_price_improvements, reassign_stops, None, None)
|
|
228
|
+
self.risk_reward_ratio = risk_reward_ratio
|
|
229
|
+
|
|
230
|
+
def get_new_take_on_improve(self, ctx: IStrategyContext, s: Signal, b: Bar) -> float | None:
|
|
231
|
+
"""
|
|
232
|
+
Use risk reward ratio to calculate new take target
|
|
233
|
+
"""
|
|
234
|
+
if (t := s.take) is not None and s.price is not None and s.stop is not None:
|
|
235
|
+
t = s.price + np.sign(s.signal) * self.risk_reward_ratio * abs(s.price - s.stop)
|
|
236
|
+
return t
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from qubx.core.basics import Deal, Instrument, Signal, TargetPosition
|
|
5
|
+
from qubx.core.interfaces import IStrategyContext, PositionsTracker
|
|
6
|
+
from qubx.core.series import Bar, OrderBook, Quote, Trade
|
|
7
|
+
|
|
8
|
+
Targets = list[TargetPosition] | TargetPosition | None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CompositeTracker(PositionsTracker):
|
|
12
|
+
"""
|
|
13
|
+
Combines multiple trackers. Returns the most conservative target position.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *trackers: PositionsTracker) -> None:
|
|
17
|
+
self.trackers = trackers
|
|
18
|
+
|
|
19
|
+
def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition]:
|
|
20
|
+
_index_to_targets: dict[int, Targets] = {
|
|
21
|
+
index: tracker.process_signals(ctx, signals) for index, tracker in enumerate(self.trackers)
|
|
22
|
+
}
|
|
23
|
+
return self._select_min_targets(_index_to_targets)
|
|
24
|
+
|
|
25
|
+
def update(
|
|
26
|
+
self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
|
|
27
|
+
) -> list[TargetPosition]:
|
|
28
|
+
_index_to_targets: dict[int, Targets] = {
|
|
29
|
+
index: tracker.update(ctx, instrument, update) for index, tracker in enumerate(self.trackers)
|
|
30
|
+
}
|
|
31
|
+
return self._select_min_targets(_index_to_targets)
|
|
32
|
+
|
|
33
|
+
def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
|
|
34
|
+
for tracker in self.trackers:
|
|
35
|
+
tracker.on_execution_report(ctx, instrument, deal)
|
|
36
|
+
|
|
37
|
+
def _select_min_targets(self, tracker_to_targets: dict[int, Targets]) -> list[TargetPosition]:
|
|
38
|
+
_instrument_to_targets: dict[Instrument, list[TargetPosition]] = defaultdict(list)
|
|
39
|
+
for targets in tracker_to_targets.values():
|
|
40
|
+
if isinstance(targets, list):
|
|
41
|
+
for target in targets:
|
|
42
|
+
_instrument_to_targets[target.instrument].append(target)
|
|
43
|
+
elif isinstance(targets, TargetPosition):
|
|
44
|
+
_instrument_to_targets[targets.instrument].append(targets)
|
|
45
|
+
|
|
46
|
+
_instrument_to_targets = self._process_override_signals(_instrument_to_targets)
|
|
47
|
+
|
|
48
|
+
_instr_to_min_target = {
|
|
49
|
+
symbol: min(targets, key=lambda target: abs(target.target_position_size))
|
|
50
|
+
for symbol, targets in _instrument_to_targets.items()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return list(_instr_to_min_target.values())
|
|
54
|
+
|
|
55
|
+
def _process_override_signals(
|
|
56
|
+
self, instrument_to_targets: dict[Instrument, list[TargetPosition]]
|
|
57
|
+
) -> dict[Instrument, list[TargetPosition]]:
|
|
58
|
+
"""
|
|
59
|
+
Filter out signals that allow override if there is more than one signal for the same symbol.
|
|
60
|
+
"""
|
|
61
|
+
filt_instr_to_targets = {}
|
|
62
|
+
for instr, targets in instrument_to_targets.items():
|
|
63
|
+
if len(targets) == 1 or all(t.signal.options.get("allow_override", False) for t in targets):
|
|
64
|
+
filt_instr_to_targets[instr] = targets
|
|
65
|
+
continue
|
|
66
|
+
filt_instr_to_targets[instr] = [
|
|
67
|
+
target for target in targets if not target.signal.options.get("allow_override", False)
|
|
68
|
+
]
|
|
69
|
+
return filt_instr_to_targets
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConditionalTracker(PositionsTracker):
|
|
73
|
+
"""
|
|
74
|
+
Wraps a single tracker. Can be used to add some logic before or after the wrapped tracker.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, condition: Callable[[Signal], bool], tracker: PositionsTracker) -> None:
|
|
78
|
+
self.condition = condition
|
|
79
|
+
self.tracker = tracker
|
|
80
|
+
|
|
81
|
+
def process_signals(self, ctx: IStrategyContext, signals: list[Signal]) -> list[TargetPosition] | TargetPosition:
|
|
82
|
+
filtered_signals = []
|
|
83
|
+
for signal in signals:
|
|
84
|
+
cond = self.condition(signal)
|
|
85
|
+
if cond:
|
|
86
|
+
filtered_signals.append(signal)
|
|
87
|
+
elif self.tracker.is_active(signal.instrument):
|
|
88
|
+
# This is important for instance if we get an opposite signal
|
|
89
|
+
# we need to at least close the open position
|
|
90
|
+
filtered_signals.append(
|
|
91
|
+
Signal(
|
|
92
|
+
instrument=signal.instrument,
|
|
93
|
+
signal=0,
|
|
94
|
+
price=signal.price,
|
|
95
|
+
stop=signal.stop,
|
|
96
|
+
take=signal.take,
|
|
97
|
+
reference_price=signal.reference_price,
|
|
98
|
+
group=signal.group,
|
|
99
|
+
comment=f"Closing opposite signal {signal.signal} {signal.comment}",
|
|
100
|
+
options=dict(allow_override=True),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return self.tracker.process_signals(ctx, filtered_signals)
|
|
104
|
+
|
|
105
|
+
def update(
|
|
106
|
+
self, ctx: IStrategyContext, instrument: Instrument, update: Quote | Trade | Bar | OrderBook
|
|
107
|
+
) -> list[TargetPosition] | TargetPosition:
|
|
108
|
+
return self.tracker.update(ctx, instrument, update)
|
|
109
|
+
|
|
110
|
+
def on_execution_report(self, ctx: IStrategyContext, instrument: Instrument, deal: Deal):
|
|
111
|
+
self.tracker.on_execution_report(ctx, instrument, deal)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class LongTracker(ConditionalTracker):
|
|
115
|
+
def __init__(self, tracker: PositionsTracker) -> None:
|
|
116
|
+
super().__init__(self._condition, tracker)
|
|
117
|
+
|
|
118
|
+
def _condition(self, signal: Signal) -> bool:
|
|
119
|
+
return signal.signal > 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ShortTracker(ConditionalTracker):
|
|
123
|
+
def __init__(self, tracker: PositionsTracker) -> None:
|
|
124
|
+
super().__init__(self._condition, tracker)
|
|
125
|
+
|
|
126
|
+
def _condition(self, signal: Signal) -> bool:
|
|
127
|
+
return signal.signal < 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CompositeTrackerPerSide(CompositeTracker):
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
trackers: list[PositionsTracker] | None = None,
|
|
134
|
+
long_trackers: list[PositionsTracker] | None = None,
|
|
135
|
+
short_trackers: list[PositionsTracker] | None = None,
|
|
136
|
+
):
|
|
137
|
+
if trackers is None and long_trackers is None and short_trackers is None:
|
|
138
|
+
raise ValueError("At least one of trackers, long_trackers or short_trackers must be provided.")
|
|
139
|
+
self.trackers = trackers or []
|
|
140
|
+
self.long_trackers = LongTracker(CompositeTracker(*long_trackers)) if long_trackers else None
|
|
141
|
+
self.short_trackers = ShortTracker(CompositeTracker(*short_trackers)) if short_trackers else None
|
|
142
|
+
if self.long_trackers is not None:
|
|
143
|
+
self.trackers.append(self.long_trackers)
|
|
144
|
+
if self.short_trackers is not None:
|
|
145
|
+
self.trackers.append(self.short_trackers)
|
|
146
|
+
super().__init__(*self.trackers)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from qubx import logger
|
|
8
|
+
from qubx.core.basics import Instrument, Position, Signal, TargetPosition
|
|
9
|
+
from qubx.core.interfaces import IPositionGathering, IStrategyContext, PositionsTracker
|
|
10
|
+
from qubx.trackers.sizers import LongShortRatioPortfolioSizer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Capital:
|
|
15
|
+
capital: float
|
|
16
|
+
released_amount: float
|
|
17
|
+
symbols_to_close: List[str] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PortfolioRebalancerTracker(PositionsTracker):
|
|
21
|
+
"""
|
|
22
|
+
Simple portfolio rebalancer based on fixed weights
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
capital_invested: float
|
|
26
|
+
tolerance: float
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, capital_invested: float, tolerance: float, positions_sizer=LongShortRatioPortfolioSizer()
|
|
30
|
+
) -> None:
|
|
31
|
+
self.capital_invested = capital_invested
|
|
32
|
+
self.tolerance = tolerance
|
|
33
|
+
self._positions_sizer = positions_sizer
|
|
34
|
+
|
|
35
|
+
def calculate_released_capital(
|
|
36
|
+
self, ctx: IStrategyContext, instr_to_close: list[Instrument] | None = None
|
|
37
|
+
) -> Tuple[float, List[str]]:
|
|
38
|
+
"""
|
|
39
|
+
Calculate capital that would be released if close positions for provided symbols_to_close list
|
|
40
|
+
"""
|
|
41
|
+
released_capital_after_close = 0.0
|
|
42
|
+
closed_symbols = []
|
|
43
|
+
if instr_to_close is not None:
|
|
44
|
+
for instr in instr_to_close:
|
|
45
|
+
p = ctx.positions.get(instr)
|
|
46
|
+
if p is not None and p.quantity != 0:
|
|
47
|
+
released_capital_after_close += p.get_amount_released_funds_after_closing(
|
|
48
|
+
to_remain=ctx.get_reserved(p.instrument)
|
|
49
|
+
)
|
|
50
|
+
closed_symbols.append(instr)
|
|
51
|
+
return released_capital_after_close, closed_symbols
|
|
52
|
+
|
|
53
|
+
def estimate_capital_to_trade(
|
|
54
|
+
self, ctx: IStrategyContext, instr_to_close: list[Instrument] | None = None
|
|
55
|
+
) -> Capital:
|
|
56
|
+
released_capital = 0.0
|
|
57
|
+
closed_positions = None
|
|
58
|
+
|
|
59
|
+
if instr_to_close is not None:
|
|
60
|
+
released_capital, closed_positions = self.calculate_released_capital(ctx, instr_to_close)
|
|
61
|
+
|
|
62
|
+
cap_to_invest = ctx.get_capital() + released_capital
|
|
63
|
+
if self.capital_invested > 0:
|
|
64
|
+
cap_to_invest = min(self.capital_invested, cap_to_invest)
|
|
65
|
+
|
|
66
|
+
return Capital(cap_to_invest, released_capital, closed_positions)
|
|
67
|
+
|
|
68
|
+
def process_signals(self, ctx: IStrategyContext, signals: List[Signal]) -> List[TargetPosition]:
|
|
69
|
+
"""
|
|
70
|
+
Portfolio rebalancer - makes rebalancing portfolio based on provided signals.
|
|
71
|
+
It checks how much funds can be released first and then reallocate it into positions need to be opened.
|
|
72
|
+
"""
|
|
73
|
+
targets = self._positions_sizer.calculate_target_positions(ctx, signals)
|
|
74
|
+
|
|
75
|
+
# - find positions where exposure will be decreased
|
|
76
|
+
_close_first = []
|
|
77
|
+
_then_open = []
|
|
78
|
+
for t in targets:
|
|
79
|
+
pos = ctx.positions.get(t.instrument)
|
|
80
|
+
if pos is None:
|
|
81
|
+
logger.error(f"({self.__class__.__name__}) No position for {t.instrument} instrument !")
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
_pa, _ta = abs(pos.quantity), abs(t.target_position_size)
|
|
85
|
+
|
|
86
|
+
# - ones which decreases exposure
|
|
87
|
+
if _ta < _pa:
|
|
88
|
+
reserved = ctx.get_reserved(pos.instrument)
|
|
89
|
+
# - when we have some reserved amount we should check target position size
|
|
90
|
+
t.target_position_size = self._correct_target_position(pos.quantity, t.target_position_size, reserved)
|
|
91
|
+
_close_first.append(t)
|
|
92
|
+
logger.debug(
|
|
93
|
+
f"({self.__class__.__name__}) Decreasing exposure for {t.instrument} from {pos.quantity} -> {t.target_position_size} (reserved: {reserved})"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# - ones which increases exposure
|
|
97
|
+
elif _ta > _pa:
|
|
98
|
+
_then_open.append(t)
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"({self.__class__.__name__}) Increasing exposure for {t.instrument} from {pos.quantity} -> {t.target_position_size})"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return _close_first + _then_open
|
|
104
|
+
|
|
105
|
+
def _correct_target_position(self, start_position: float, new_position: float, reserved: float) -> float:
|
|
106
|
+
"""
|
|
107
|
+
Calcluate target position size considering reserved quantity.
|
|
108
|
+
"""
|
|
109
|
+
d = np.sign(start_position)
|
|
110
|
+
qty_to_close = start_position
|
|
111
|
+
|
|
112
|
+
if reserved != 0 and start_position != 0 and np.sign(reserved) == d:
|
|
113
|
+
qty_to_close = max(start_position - reserved, 0) if d > 0 else min(start_position - reserved, 0)
|
|
114
|
+
|
|
115
|
+
# - what's max value allowed to close taking in account reserved quantity
|
|
116
|
+
max_to_close = -d * qty_to_close
|
|
117
|
+
pos_change = new_position - start_position
|
|
118
|
+
direction = np.sign(pos_change)
|
|
119
|
+
prev_direction = np.sign(start_position)
|
|
120
|
+
|
|
121
|
+
# - how many shares are closed/open
|
|
122
|
+
qty_closing = min(abs(start_position), abs(pos_change)) * direction if prev_direction != direction else 0
|
|
123
|
+
# qty_opening = pos_change if prev_direction == direction else pos_change - qty_closing
|
|
124
|
+
# print(qty_closing, qty_opening, max_to_close)
|
|
125
|
+
|
|
126
|
+
if abs(qty_closing) > abs(max_to_close):
|
|
127
|
+
return reserved
|
|
128
|
+
|
|
129
|
+
return new_position
|