robottraderslab-envelope 0.1.0__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: robottraderslab-envelope
3
+ Version: 0.1.0
4
+ Summary: Envelope trading strategy plugin for the RobotTradersLab engine
5
+ License-Expression: Apache-2.0
6
+ Requires-Dist: robottraderslab>=0.2.1
7
+ Requires-Dist: pandas>=3.0.0
8
+ Requires-Dist: numpy>=2.4.2
9
+ Requires-Python: >=3.13, <3.14
10
+ Project-URL: Homepage, https://robottraders.io/lab/envelope
11
+ Project-URL: Documentation, https://robottraders.io/lab/docs/envelope.html
12
+ Project-URL: Repository, https://github.com/RobotTraders/RobotTradersLab-Envelope
13
+ Description-Content-Type: text/markdown
14
+
15
+ # robottraderslab-envelope
16
+
17
+ Mean reversion strategy plugin for the RobotTradersLab engine, registered under the name `envelope`.
18
+
19
+ ## Documentation
20
+
21
+ The strategy, its parameters and an example config are on its page at [robottraders.io/lab/docs/envelope](https://robottraders.io/lab/docs/envelope.html).
22
+
23
+ ## Disclaimer
24
+
25
+ Nothing in this software constitutes financial, investment, or trading advice, nor a recommendation of any trading strategy. Trading involves substantial risk: deployed capital can be lost entirely, and backtested results do not predict live performance. The software is used at your own risk; the authors and affiliates accept no responsibility for trading results.
26
+
27
+ Use of this software is subject to the [Disclaimer](https://robottraders.io/disclaimer).
28
+
29
+ ## License
30
+
31
+ Distributed under the [Apache License 2.0](LICENSE).
@@ -0,0 +1,17 @@
1
+ # robottraderslab-envelope
2
+
3
+ Mean reversion strategy plugin for the RobotTradersLab engine, registered under the name `envelope`.
4
+
5
+ ## Documentation
6
+
7
+ The strategy, its parameters and an example config are on its page at [robottraders.io/lab/docs/envelope](https://robottraders.io/lab/docs/envelope.html).
8
+
9
+ ## Disclaimer
10
+
11
+ Nothing in this software constitutes financial, investment, or trading advice, nor a recommendation of any trading strategy. Trading involves substantial risk: deployed capital can be lost entirely, and backtested results do not predict live performance. The software is used at your own risk; the authors and affiliates accept no responsibility for trading results.
12
+
13
+ Use of this software is subject to the [Disclaimer](https://robottraders.io/disclaimer).
14
+
15
+ ## License
16
+
17
+ Distributed under the [Apache License 2.0](LICENSE).
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "robottraderslab-envelope"
3
+ version = "0.1.0"
4
+ description = "Envelope trading strategy plugin for the RobotTradersLab engine"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.14"
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "robottraderslab>=0.2.1",
10
+ "pandas>=3.0.0",
11
+ "numpy>=2.4.2",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://robottraders.io/lab/envelope"
16
+ Documentation = "https://robottraders.io/lab/docs/envelope.html"
17
+ Repository = "https://github.com/RobotTraders/RobotTradersLab-Envelope"
18
+
19
+ [project.entry-points."robot_traders_lab.strategies"]
20
+ envelope = "robottraderslab_envelope:EnvelopeStrategy"
21
+
22
+ [project.entry-points."robot_traders_lab.indicators"]
23
+ envelope = "robottraderslab_envelope.chart_indicators:get_lightweight_chart_indicators"
24
+
25
+ [project.entry-points."robot_traders_lab.examples"]
26
+ envelope = "robottraderslab_envelope"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.4,<0.11.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.build-backend]
33
+ module-name = "robottraderslab_envelope"
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "robottraderslab-envelope"
3
+ version = "0.1.0"
4
+ description = "Envelope trading strategy plugin for the RobotTradersLab engine"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.14"
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "robottraderslab>=0.2.1",
10
+ "pandas>=3.0.0",
11
+ "numpy>=2.4.2",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://robottraders.io/lab/envelope"
16
+ Documentation = "https://robottraders.io/lab/docs/envelope.html"
17
+ Repository = "https://github.com/RobotTraders/RobotTradersLab-Envelope"
18
+
19
+ [project.entry-points."robot_traders_lab.strategies"]
20
+ envelope ="robottraderslab_envelope:EnvelopeStrategy"
21
+
22
+ [project.entry-points."robot_traders_lab.indicators"]
23
+ envelope = "robottraderslab_envelope.chart_indicators:get_lightweight_chart_indicators"
24
+
25
+ [project.entry-points."robot_traders_lab.examples"]
26
+ envelope = "robottraderslab_envelope"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.4,<0.11.0"]
30
+ build-backend = "uv_build"
31
+
32
+
33
+ [tool.uv.build-backend]
34
+ module-name = "robottraderslab_envelope"
@@ -0,0 +1,5 @@
1
+ from .envelope import EnvelopeStrategy
2
+
3
+ __all__ = [
4
+ "EnvelopeStrategy",
5
+ ]
@@ -0,0 +1,124 @@
1
+ from collections.abc import Callable
2
+
3
+ from robottraderslab.strategies import (
4
+ BookKeeper,
5
+ OrderProtocol,
6
+ OrderSide,
7
+ )
8
+ from robottraderslab.strategies.futures import (
9
+ BatchableOrderAction,
10
+ FuturesAccount,
11
+ OrderModification,
12
+ )
13
+
14
+ from .profile_config import ProfileConfig
15
+ from .profile_snapshot import ProfileSnapshot
16
+ from .rungs import WantedRung, rung_band_index
17
+
18
+ type RungUpdater = Callable[
19
+ [
20
+ FuturesAccount,
21
+ ProfileConfig,
22
+ ProfileSnapshot,
23
+ list[WantedRung],
24
+ BookKeeper,
25
+ ],
26
+ None,
27
+ ]
28
+
29
+
30
+ def book_cancel_and_place(
31
+ account: FuturesAccount,
32
+ profile: ProfileConfig,
33
+ profile_snapshot: ProfileSnapshot,
34
+ wanted: list[WantedRung],
35
+ bookkeeper: BookKeeper,
36
+ ) -> None:
37
+ """Drop the resting rungs and place the wanted set once they are gone."""
38
+ symbol = profile.symbol
39
+ if profile_snapshot.position is None:
40
+ cancels = [bookkeeper.add(account.cancel_orders(symbol), after=())]
41
+ else:
42
+ cancels = [
43
+ bookkeeper.add(
44
+ account.cancel_order(symbol, rung.order_id),
45
+ after=(),
46
+ )
47
+ for rung in profile_snapshot.pending_rungs
48
+ ]
49
+
50
+ orders: list[BatchableOrderAction] = [rung.order for rung in wanted]
51
+ if orders:
52
+ bookkeeper.add(account.place_orders(orders), after=cancels)
53
+
54
+
55
+ def book_modify_in_place(
56
+ account: FuturesAccount,
57
+ profile: ProfileConfig,
58
+ profile_snapshot: ProfileSnapshot,
59
+ wanted: list[WantedRung],
60
+ bookkeeper: BookKeeper,
61
+ ) -> None:
62
+ """Move each resting rung to its own band, so the book is never empty."""
63
+ symbol = profile.symbol
64
+ holding = profile_snapshot.position is not None
65
+
66
+ modifications: list[OrderModification] = []
67
+ cancel_ids: list[str] = [] if holding else list(profile_snapshot.stray_order_ids)
68
+ missing: list[BatchableOrderAction] = []
69
+ for side in (OrderSide.BUY, OrderSide.SELL):
70
+ resting, unclaimed = _resting_by_band(profile_snapshot.pending_rungs, side)
71
+ cancel_ids.extend(unclaimed)
72
+ for rung in wanted:
73
+ if rung.order.side != side:
74
+ continue
75
+ claimed = resting.pop(rung.band_index, None)
76
+ if claimed is None:
77
+ missing.append(rung.order)
78
+ else:
79
+ modifications.append(
80
+ OrderModification(order_id=claimed.order_id, order=rung.order)
81
+ )
82
+ cancel_ids.extend(rung.order_id for rung in resting.values())
83
+
84
+ if modifications:
85
+ bookkeeper.add(account.modify_orders(modifications), after=())
86
+ cancels = [
87
+ bookkeeper.add(account.cancel_order(symbol, order_id), after=())
88
+ for order_id in cancel_ids
89
+ ]
90
+ if missing:
91
+ bookkeeper.add(account.place_orders(missing), after=cancels)
92
+
93
+
94
+ def book_cancel_entries(
95
+ account: FuturesAccount,
96
+ profile: ProfileConfig,
97
+ profile_snapshot: ProfileSnapshot,
98
+ bookkeeper: BookKeeper,
99
+ ) -> None:
100
+ """Clear the resting rungs without placing anything in their place."""
101
+ if profile_snapshot.position is None:
102
+ bookkeeper.add(account.cancel_orders(profile.symbol), after=())
103
+ return
104
+ for rung in profile_snapshot.pending_rungs:
105
+ bookkeeper.add(
106
+ account.cancel_order(profile.symbol, rung.order_id),
107
+ after=(),
108
+ )
109
+
110
+
111
+ def _resting_by_band(
112
+ pending_rungs: list[OrderProtocol], side: OrderSide
113
+ ) -> tuple[dict[int, OrderProtocol], list[str]]:
114
+ resting: dict[int, OrderProtocol] = {}
115
+ unclaimed: list[str] = []
116
+ for rung in pending_rungs:
117
+ if rung.side != side:
118
+ continue
119
+ band_index = rung_band_index(rung.client_order_id)
120
+ if band_index is None or band_index in resting:
121
+ unclaimed.append(rung.order_id)
122
+ else:
123
+ resting[band_index] = rung
124
+ return resting, unclaimed
@@ -0,0 +1,49 @@
1
+ from typing import Any
2
+
3
+ from robottraderslab.strategies import Candles, ChartLine, PositionSide
4
+
5
+ from .envelope_indicator import compute_envelope, compute_reference
6
+ from .profile_config import BAND_LABEL, AverageType, envelopes_by_side
7
+
8
+ _BAND_COLOUR = {PositionSide.LONG: "green", PositionSide.SHORT: "red"}
9
+
10
+
11
+ def get_lightweight_chart_indicators(
12
+ candles: Candles,
13
+ indicator_params: dict[str, Any],
14
+ ) -> list[ChartLine]:
15
+ """Draw the reference average and the band a rung rests on beside the candles.
16
+
17
+ Args:
18
+ indicator_params: `average_type`, `average_period` and either
19
+ `envelopes` or `long_envelopes` / `short_envelopes`, spelled as a
20
+ profile spells them.
21
+ """
22
+ average_type = AverageType(indicator_params["average_type"])
23
+ average_period = indicator_params["average_period"]
24
+ reference = compute_reference(
25
+ candles.close,
26
+ candles.high,
27
+ candles.low,
28
+ average_type,
29
+ average_period,
30
+ )
31
+ levels = compute_envelope(reference, envelopes_by_side(indicator_params))
32
+
33
+ lines = [
34
+ ChartLine(
35
+ name=f"Reference {average_type} {average_period}",
36
+ values=levels.reference,
37
+ colour="orange",
38
+ )
39
+ ]
40
+ for side, bands in levels.bands.items():
41
+ for index, band in enumerate(bands, start=1):
42
+ lines.append(
43
+ ChartLine(
44
+ name=f"Band {index} {BAND_LABEL[side]}",
45
+ values=band,
46
+ colour=_BAND_COLOUR[side],
47
+ )
48
+ )
49
+ return lines
@@ -0,0 +1,25 @@
1
+ from robottraderslab.strategies import OrderType
2
+ from robottraderslab.strategies.futures import FuturesOrderBuilder
3
+
4
+
5
+ class LimitEntryOrder:
6
+ def matches(self, order_kind: OrderType | None) -> bool:
7
+ return order_kind == OrderType.LIMIT
8
+
9
+ def rest_at_band(
10
+ self, entry: FuturesOrderBuilder, band_price: float
11
+ ) -> FuturesOrderBuilder:
12
+ return entry.limit(price=band_price)
13
+
14
+
15
+ class TriggerEntryOrder:
16
+ def matches(self, order_kind: OrderType | None) -> bool:
17
+ return order_kind == OrderType.TRIGGER
18
+
19
+ def rest_at_band(
20
+ self, entry: FuturesOrderBuilder, band_price: float
21
+ ) -> FuturesOrderBuilder:
22
+ return entry.trigger(price=band_price)
23
+
24
+
25
+ type EntryOrder = LimitEntryOrder | TriggerEntryOrder
@@ -0,0 +1,256 @@
1
+ import logging
2
+ from collections import Counter
3
+ from collections.abc import Sequence
4
+ from datetime import datetime
5
+ from typing import ClassVar, Literal
6
+
7
+ from robottraderslab import Symbol
8
+ from robottraderslab.exceptions import StrategyCriticalError
9
+ from robottraderslab.strategies import (
10
+ AccountSnapshots,
11
+ BookKeeper,
12
+ Execution,
13
+ MarketType,
14
+ OHLCVs,
15
+ OrderType,
16
+ ProfileStrategy,
17
+ StrategyRequirements,
18
+ )
19
+ from robottraderslab.strategies.futures import (
20
+ FuturesAccount,
21
+ MarginSettings,
22
+ TotalBalanceRatio,
23
+ )
24
+
25
+ from .book_rungs import (
26
+ RungUpdater,
27
+ book_cancel_and_place,
28
+ book_cancel_entries,
29
+ book_modify_in_place,
30
+ )
31
+ from .entry_order import EntryOrder, LimitEntryOrder, TriggerEntryOrder
32
+ from .envelope_indicator import compute_envelope, compute_reference
33
+ from .position_protection import book_position_protections
34
+ from .profile_config import ProfileConfig
35
+ from .profile_snapshot import ProfileSnapshot
36
+ from .reentry_guard import ReentryGuard
37
+ from .rungs import (
38
+ entered_side,
39
+ rung_band_index,
40
+ wanted_while_flat,
41
+ wanted_while_holding,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _REFERENCE_EXIT_REASON = "moving average reached"
47
+ _ENTRY_ORDERS: dict[str, EntryOrder] = {
48
+ "limit": LimitEntryOrder(),
49
+ "trigger": TriggerEntryOrder(),
50
+ }
51
+ _RUNG_UPDATERS: dict[str, RungUpdater] = {
52
+ "modify": book_modify_in_place,
53
+ "cancel_replace": book_cancel_and_place,
54
+ }
55
+ _WHOLE_BALANCE = 1.0
56
+
57
+
58
+ class EnvelopeStrategy(ProfileStrategy[ProfileConfig]):
59
+ """Mean-reversion envelope strategy: rests one entry rung per band around a
60
+ moving average.
61
+
62
+ The `entry_order` setting chooses what a rung rests as: "trigger" fires a
63
+ market order once price reaches the band, "limit" rests at the band price
64
+ and fills there or better.
65
+
66
+ The `update_mode` setting chooses how the resting rungs follow the bands:
67
+ "modify" moves each resting rung to its band so the book is never empty,
68
+ "cancel_replace" cancels the resting set and places the wanted one afresh.
69
+ """
70
+
71
+ market_type: ClassVar[MarketType] = "futures"
72
+
73
+ account: FuturesAccount
74
+ entry_order: Literal["trigger", "limit"] = "trigger"
75
+ update_mode: Literal["modify", "cancel_replace"] = "modify"
76
+
77
+ _entry_order: EntryOrder
78
+ _profiles_by_symbol: dict[Symbol, ProfileConfig]
79
+ _reentry_guard: ReentryGuard
80
+ _update_rungs: RungUpdater
81
+
82
+ async def setup(self, requirements: StrategyRequirements) -> None:
83
+ """Resolve the settings, validate the profiles and declare the OHLCV and
84
+ account state requirements.
85
+ """
86
+ self._entry_order = _ENTRY_ORDERS[self.entry_order]
87
+ self._update_rungs = _RUNG_UPDATERS[self.update_mode]
88
+ self._reentry_guard = ReentryGuard()
89
+ self._profiles_by_symbol = {
90
+ profile.symbol: profile for profile in self.profiles
91
+ }
92
+ _validate_profiles(self.profiles)
93
+ if self.account.capabilities.reserves_margin_on_pending_orders:
94
+ logger.info(
95
+ "The exchange reserves margin on pending orders; the envelope "
96
+ "keeps many resting orders open at once, so only the rungs "
97
+ "covered by free margin will fill."
98
+ )
99
+ _warn_when_resting_rungs_outgrow_the_balance(self.profiles)
100
+ for profile in self.profiles:
101
+ requirements.ohlcv.add(
102
+ profile.symbol, profile.timeframe, profile.average_period
103
+ )
104
+ requirements.account.add(
105
+ self.account,
106
+ symbols=[profile.symbol for profile in self.profiles],
107
+ positions=True,
108
+ open_orders=True,
109
+ balances=True,
110
+ sizing=[profile.sizing for profile in self.profiles],
111
+ executions=True,
112
+ notify_entry_fills=True,
113
+ describe_fills=self._describe_fill,
114
+ margin_targets=_margin_targets(self.profiles),
115
+ )
116
+
117
+ def generate_profile_signals(self, profile: ProfileConfig, ohlcvs: OHLCVs) -> None:
118
+ """Compute the profile's reference moving average and envelope bands."""
119
+ symbol = profile.symbol
120
+ timeframe = profile.timeframe
121
+ reference = compute_reference(
122
+ ohlcvs.column(symbol, timeframe, "close"),
123
+ ohlcvs.column(symbol, timeframe, "high"),
124
+ ohlcvs.column(symbol, timeframe, "low"),
125
+ profile.average_type,
126
+ profile.average_period,
127
+ )
128
+ levels = compute_envelope(reference, profile.offsets_by_side)
129
+ ohlcvs.add_column(symbol, timeframe, profile.reference_column, levels.reference)
130
+ for side, columns in profile.band_columns.items():
131
+ for column, band in zip(columns, levels.bands[side], strict=True):
132
+ ohlcvs.add_column(symbol, timeframe, column, band)
133
+
134
+ def book_profile_actions(
135
+ self,
136
+ profile: ProfileConfig,
137
+ ohlcvs: OHLCVs,
138
+ account_snapshots: AccountSnapshots,
139
+ timestamp: datetime,
140
+ bookkeeper: BookKeeper,
141
+ ) -> None:
142
+ """Emit the profile's rung-update, entry and protection actions."""
143
+ account_snapshot = account_snapshots.of(self.account)
144
+ profile_snapshot = ProfileSnapshot.build(
145
+ profile, ohlcvs, account_snapshot, self._entry_order
146
+ )
147
+ if profile_snapshot.has_nan_levels:
148
+ logger.warning(
149
+ f"{profile.profile_id} ({profile.symbol}): envelope levels are NaN, "
150
+ "cancelling entries and waiting for data to recover"
151
+ )
152
+ book_cancel_entries(self.account, profile, profile_snapshot, bookkeeper)
153
+ return
154
+
155
+ reentry_state = self._reentry_guard.state_for(
156
+ profile.symbol,
157
+ profile.timeframe,
158
+ profile.reference_column,
159
+ ohlcvs,
160
+ account_snapshot.stop_loss_fills(profile.symbol),
161
+ timestamp,
162
+ )
163
+ position = profile_snapshot.position
164
+ if position is not None:
165
+ book_position_protections(
166
+ self.account,
167
+ profile,
168
+ profile_snapshot,
169
+ position,
170
+ bookkeeper,
171
+ )
172
+ wanted = wanted_while_holding(
173
+ self.account,
174
+ profile,
175
+ profile_snapshot,
176
+ position,
177
+ reentry_state,
178
+ account_snapshot,
179
+ self._entry_order,
180
+ )
181
+ else:
182
+ wanted = wanted_while_flat(
183
+ self.account,
184
+ profile,
185
+ profile_snapshot,
186
+ reentry_state,
187
+ account_snapshot,
188
+ self._entry_order,
189
+ )
190
+
191
+ self._update_rungs(
192
+ self.account,
193
+ profile,
194
+ profile_snapshot,
195
+ wanted,
196
+ bookkeeper,
197
+ )
198
+
199
+ def _describe_fill(self, fill: Execution) -> str | None:
200
+ """A take-profit sits at the reference, so its fill is the envelope's exit.
201
+
202
+ The venue reports every fill on the symbol, so a fill of the configured
203
+ entry kind is one of the envelope's rungs only when the tag on its
204
+ client order id says so.
205
+ """
206
+ if fill.kind == OrderType.TAKE_PROFIT:
207
+ return _REFERENCE_EXIT_REASON
208
+ if not self._entry_order.matches(fill.kind):
209
+ return None
210
+ band_index = rung_band_index(fill.client_order_id)
211
+ if band_index is None:
212
+ return None
213
+ offsets = self._profiles_by_symbol[fill.symbol].offsets_by_side
214
+ return f"entry rung {band_index} of {len(offsets[entered_side(fill.side)])}"
215
+
216
+
217
+ def _validate_profiles(profiles: Sequence[ProfileConfig]) -> None:
218
+ counts = Counter(profile.symbol for profile in profiles)
219
+ duplicated = sorted(str(symbol) for symbol, count in counts.items() if count > 1)
220
+ if duplicated:
221
+ raise StrategyCriticalError(
222
+ f"Envelope strategy accepts one profile per symbol, "
223
+ f"got several for: {', '.join(duplicated)}."
224
+ )
225
+
226
+
227
+ def _warn_when_resting_rungs_outgrow_the_balance(
228
+ profiles: Sequence[ProfileConfig],
229
+ ) -> None:
230
+ share = sum(_resting_balance_share(profile) for profile in profiles)
231
+ if share > _WHOLE_BALANCE:
232
+ logger.warning(
233
+ f"The resting rungs of every profile lock {share:.0%} of the "
234
+ "balance; the venue refuses the rungs beyond it."
235
+ )
236
+
237
+
238
+ def _resting_balance_share(profile: ProfileConfig) -> float:
239
+ """A resting rung locks the margin of what it would open, and only a
240
+ share of the total balance is known before the first candle.
241
+ """
242
+ if not isinstance(profile.sizing, TotalBalanceRatio):
243
+ return 0.0
244
+ traded_sides = len(profile.rung_counts)
245
+ return profile.sizing.ratio * traded_sides / profile.leverage
246
+
247
+
248
+ def _margin_targets(
249
+ profiles: Sequence[ProfileConfig],
250
+ ) -> dict[Symbol, MarginSettings]:
251
+ return {
252
+ profile.symbol: MarginSettings(
253
+ leverage=profile.leverage, margin_mode=profile.margin_mode
254
+ )
255
+ for profile in profiles
256
+ }
@@ -0,0 +1,56 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from dataclasses import dataclass
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+
7
+ from robottraderslab.indicators import MAType, donchian_midline, moving_average
8
+ from robottraderslab.strategies import PositionSide
9
+
10
+ from .profile_config import AverageType
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class EnvelopeLevels:
15
+ """Each side's bands keep the order of the offsets they were computed from."""
16
+
17
+ reference: npt.NDArray[np.float64]
18
+ bands: dict[PositionSide, list[npt.NDArray[np.float64]]]
19
+
20
+
21
+ def compute_envelope(
22
+ reference: npt.NDArray[np.float64],
23
+ envelopes: Mapping[PositionSide, Iterable[float]],
24
+ ) -> EnvelopeLevels:
25
+ """A band at offset `e` is a fall of `e` in price: from the reference down
26
+ to the long band, and from the short band back down to the reference, so
27
+ the short band sits `e / (1 - e)` above the reference.
28
+ """
29
+ return EnvelopeLevels(
30
+ reference=reference,
31
+ bands={
32
+ side: [_band(reference, side, offset) for offset in offsets]
33
+ for side, offsets in envelopes.items()
34
+ },
35
+ )
36
+
37
+
38
+ def compute_reference(
39
+ close: npt.NDArray[np.float64],
40
+ high: npt.NDArray[np.float64],
41
+ low: npt.NDArray[np.float64],
42
+ average_type: AverageType,
43
+ period: int,
44
+ ) -> npt.NDArray[np.float64]:
45
+ """Return the reference the bands sit around, of the kind the profile asks for."""
46
+ if average_type is AverageType.DCM:
47
+ return donchian_midline(high, low, period)
48
+ return moving_average(close, period, MAType(average_type.value))
49
+
50
+
51
+ def _band(
52
+ reference: npt.NDArray[np.float64], side: PositionSide, offset: float
53
+ ) -> npt.NDArray[np.float64]:
54
+ if side is PositionSide.LONG:
55
+ return reference * (1 - offset)
56
+ return reference / (1 - offset)
@@ -0,0 +1,34 @@
1
+ [strategy]
2
+ strategy_class = "envelope"
3
+
4
+ [backtest]
5
+ initial_balance = { USDT = 1000.0 }
6
+ maker_fee_rate = 0.0002
7
+ taker_fee_rate = 0.0005
8
+ start_date = "2025-09-01"
9
+ end_date = "2026-09-01"
10
+
11
+ [backtest.ohlcv_provider]
12
+ ohlcv_provider = "ccxt_bitget"
13
+
14
+ [live.ohlcv_provider]
15
+ ohlcv_provider = "bitget"
16
+
17
+ [live.trading_account]
18
+ exchange = "bitget"
19
+ demo_trading = true
20
+ secret_name = "envelope-example"
21
+
22
+ [report]
23
+ reference_symbol = "SOL/USDT:USDT"
24
+
25
+ [[strategy.profiles]]
26
+ symbol = "SOL/USDT:USDT"
27
+ timeframe = "4h"
28
+ total_balance_ratio = 1.0
29
+ leverage = 1.0
30
+ margin_mode = "isolated"
31
+ average_type = "DCM"
32
+ average_period = 5
33
+ envelopes = [0.07, 0.11, 0.15]
34
+ stop_loss_pct = 0.25