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,3 @@
1
+ from .rebalancers import PortfolioRebalancerTracker, Capital
2
+ from .riskctrl import AtrRiskTracker
3
+ from .sizers import FixedSizer, FixedRiskSizer, LongShortRatioPortfolioSizer
@@ -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