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.
- robottraderslab_envelope-0.1.0/PKG-INFO +31 -0
- robottraderslab_envelope-0.1.0/README.md +17 -0
- robottraderslab_envelope-0.1.0/pyproject.toml +33 -0
- robottraderslab_envelope-0.1.0/pyproject.toml.orig +34 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/__init__.py +5 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/book_rungs.py +124 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/chart_indicators.py +49 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/entry_order.py +25 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/envelope.py +256 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/envelope_indicator.py +56 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/examples/envelope-bot-example.toml +34 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/examples/run_backtest.ipynb +144 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/position_protection.py +37 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/profile_config.py +195 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/profile_snapshot.py +65 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/py.typed +0 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/reentry_guard.py +129 -0
- robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/rungs.py +140 -0
|
@@ -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,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)
|
robottraderslab_envelope-0.1.0/src/robottraderslab_envelope/examples/envelope-bot-example.toml
ADDED
|
@@ -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
|