robottraderslab-impulse 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.
Files changed (20) hide show
  1. robottraderslab_impulse-0.1.0/PKG-INFO +30 -0
  2. robottraderslab_impulse-0.1.0/README.md +17 -0
  3. robottraderslab_impulse-0.1.0/pyproject.toml +32 -0
  4. robottraderslab_impulse-0.1.0/pyproject.toml.orig +33 -0
  5. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/__init__.py +5 -0
  6. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/chart_indicators.py +62 -0
  7. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/impulse-bot-example.toml +89 -0
  8. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/impulse-optimisation-example.toml +38 -0
  9. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/registry.example.toml +3 -0
  10. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/run_backtest.ipynb +141 -0
  11. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/run_optimisation.ipynb +92 -0
  12. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/secrets.example.toml +5 -0
  13. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/impulse.py +209 -0
  14. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/impulse_indicator.py +62 -0
  15. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_orders.py +84 -0
  16. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_protection.py +86 -0
  17. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_tracking.py +18 -0
  18. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/profile_config.py +72 -0
  19. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/profile_snapshot.py +42 -0
  20. robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/py.typed +0 -0
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: robottraderslab-impulse
3
+ Version: 0.1.0
4
+ Summary: Impulse trading strategy plugin for the RobotTradersLab engine
5
+ License-Expression: Apache-2.0
6
+ Requires-Dist: robottraderslab>=0.1.0
7
+ Requires-Dist: pandas>=3.0.0
8
+ Requires-Python: >=3.13, <3.14
9
+ Project-URL: Homepage, https://robottraders.io/lab/impulse
10
+ Project-URL: Documentation, https://robottraders.io/lab/docs/impulse.html
11
+ Project-URL: Repository, https://github.com/RobotTraders/RobotTradersLab-Impulse
12
+ Description-Content-Type: text/markdown
13
+
14
+ # robottraderslab-impulse
15
+
16
+ Trend following momentum strategy plugin for the RobotTradersLab engine, based on the TRIX indicator and registered under the name `impulse`.
17
+
18
+ ## Documentation
19
+
20
+ The strategy, its parameters and an example config are on its page at [robottraders.io/lab/docs/impulse](https://robottraders.io/lab/docs/impulse.html).
21
+
22
+ ## Disclaimer
23
+
24
+ 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.
25
+
26
+ Use of this software is subject to the [Disclaimer](https://robottraders.io/disclaimer).
27
+
28
+ ## License
29
+
30
+ Distributed under the [Apache License 2.0](LICENSE).
@@ -0,0 +1,17 @@
1
+ # robottraderslab-impulse
2
+
3
+ Trend following momentum strategy plugin for the RobotTradersLab engine, based on the TRIX indicator and registered under the name `impulse`.
4
+
5
+ ## Documentation
6
+
7
+ The strategy, its parameters and an example config are on its page at [robottraders.io/lab/docs/impulse](https://robottraders.io/lab/docs/impulse.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,32 @@
1
+ [project]
2
+ name = "robottraderslab-impulse"
3
+ version = "0.1.0"
4
+ description = "Impulse 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.1.0",
10
+ "pandas>=3.0.0",
11
+ ]
12
+
13
+ [project.urls]
14
+ Homepage = "https://robottraders.io/lab/impulse"
15
+ Documentation = "https://robottraders.io/lab/docs/impulse.html"
16
+ Repository = "https://github.com/RobotTraders/RobotTradersLab-Impulse"
17
+
18
+ [project.entry-points."robot_traders_lab.strategies"]
19
+ impulse = "robottraderslab_impulse:ImpulseStrategy"
20
+
21
+ [project.entry-points."robot_traders_lab.indicators"]
22
+ impulse = "robottraderslab_impulse.chart_indicators:get_lightweight_chart_indicators"
23
+
24
+ [project.entry-points."robot_traders_lab.examples"]
25
+ impulse = "robottraderslab_impulse"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.10.4,<0.11.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "robottraderslab_impulse"
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "robottraderslab-impulse"
3
+ version = "0.1.0"
4
+ description = "Impulse 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.1.0",
10
+ "pandas>=3.0.0",
11
+ ]
12
+
13
+ [project.urls]
14
+ Homepage = "https://robottraders.io/lab/impulse"
15
+ Documentation = "https://robottraders.io/lab/docs/impulse.html"
16
+ Repository = "https://github.com/RobotTraders/RobotTradersLab-Impulse"
17
+
18
+ [project.entry-points."robot_traders_lab.strategies"]
19
+ impulse ="robottraderslab_impulse:ImpulseStrategy"
20
+
21
+ [project.entry-points."robot_traders_lab.indicators"]
22
+ impulse = "robottraderslab_impulse.chart_indicators:get_lightweight_chart_indicators"
23
+
24
+ [project.entry-points."robot_traders_lab.examples"]
25
+ impulse = "robottraderslab_impulse"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.10.4,<0.11.0"]
29
+ build-backend = "uv_build"
30
+
31
+
32
+ [tool.uv.build-backend]
33
+ module-name = "robottraderslab_impulse"
@@ -0,0 +1,5 @@
1
+ from .impulse import ImpulseStrategy
2
+
3
+ __all__ = [
4
+ "ImpulseStrategy",
5
+ ]
@@ -0,0 +1,62 @@
1
+ from typing import Any
2
+
3
+ from robottraderslab.indicators import MAType, moving_average, trix
4
+ from robottraderslab.strategies import Candles, ChartLine
5
+
6
+ from .profile_config import (
7
+ DEFAULT_SIGNAL_TYPE,
8
+ DEFAULT_TREND_LENGTH,
9
+ DEFAULT_TREND_TYPE,
10
+ )
11
+
12
+
13
+ def get_lightweight_chart_indicators(
14
+ candles: Candles,
15
+ indicator_params: dict[str, Any],
16
+ ) -> list[ChartLine]:
17
+ """Draw the trend average beside the candles and TRIX below them.
18
+
19
+ Args:
20
+ indicator_params: `trix_length` and `signal_length` as a profile
21
+ requires them; `signal_type`, `trend_length` and `trend_type`
22
+ fall back to the profile defaults.
23
+ """
24
+ close = candles.close
25
+ trix_length = indicator_params["trix_length"]
26
+ signal_length = indicator_params["signal_length"]
27
+ signal_type = MAType(indicator_params.get("signal_type", DEFAULT_SIGNAL_TYPE))
28
+ trend_length = indicator_params.get("trend_length", DEFAULT_TREND_LENGTH)
29
+ trend_type = MAType(indicator_params.get("trend_type", DEFAULT_TREND_TYPE))
30
+
31
+ indicator = trix(
32
+ close,
33
+ trix_length,
34
+ signal_length,
35
+ signal_type=signal_type,
36
+ )
37
+ return [
38
+ ChartLine(
39
+ name=f"Trend {trend_type} {trend_length}",
40
+ values=moving_average(close, trend_length, trend_type),
41
+ colour="orange",
42
+ ),
43
+ ChartLine(
44
+ name="TRIX",
45
+ values=indicator.trix,
46
+ colour="blue",
47
+ pane="separate",
48
+ ),
49
+ ChartLine(
50
+ name="Signal",
51
+ values=indicator.signal,
52
+ colour="red",
53
+ pane="separate",
54
+ ),
55
+ ChartLine(
56
+ name="Histogram",
57
+ values=indicator.histogram,
58
+ colour="gray",
59
+ pane="separate",
60
+ shape="histogram",
61
+ ),
62
+ ]
@@ -0,0 +1,89 @@
1
+ [strategy]
2
+ strategy_class = "impulse"
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-04-01"
9
+ end_date = "2026-04-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 = "impulse-example"
21
+
22
+ [report]
23
+ reference_symbol = "ETH/USDT:USDT"
24
+
25
+ [[strategy.profiles]]
26
+ symbol = "ETH/USDT:USDT"
27
+ timeframe = "4h"
28
+ tag = "alpha"
29
+ total_balance_ratio = 0.15
30
+ leverage = 1.0
31
+ trix_length = 8
32
+ signal_length = 12
33
+ trend_length = 180
34
+ trend_type = "EMA"
35
+
36
+ [[strategy.profiles]]
37
+ symbol = "ETH/USDT:USDT"
38
+ timeframe = "4h"
39
+ tag = "beta"
40
+ total_balance_ratio = 0.15
41
+ leverage = 1.0
42
+ trix_length = 37
43
+ signal_length = 42
44
+ trend_length = 180
45
+ trend_type = "EMA"
46
+
47
+ [[strategy.profiles]]
48
+ symbol = "ETH/USDT:USDT"
49
+ timeframe = "2h"
50
+ tag = "alpha"
51
+ total_balance_ratio = 0.15
52
+ leverage = 1.0
53
+ trix_length = 15
54
+ signal_length = 27
55
+ trend_length = 360
56
+ trend_type = "EMA"
57
+
58
+ [[strategy.profiles]]
59
+ symbol = "ETH/USDT:USDT"
60
+ timeframe = "2h"
61
+ tag = "beta"
62
+ total_balance_ratio = 0.15
63
+ leverage = 1.0
64
+ trix_length = 43
65
+ signal_length = 16
66
+ trend_length = 360
67
+ trend_type = "EMA"
68
+
69
+ [[strategy.profiles]]
70
+ symbol = "ETH/USDT:USDT"
71
+ timeframe = "1h"
72
+ tag = "alpha"
73
+ total_balance_ratio = 0.15
74
+ leverage = 1.0
75
+ trix_length = 30
76
+ signal_length = 45
77
+ trend_length = 720
78
+ trend_type = "EMA"
79
+
80
+ [[strategy.profiles]]
81
+ symbol = "ETH/USDT:USDT"
82
+ timeframe = "1h"
83
+ tag = "beta"
84
+ total_balance_ratio = 0.15
85
+ leverage = 1.0
86
+ trix_length = 32
87
+ signal_length = 33
88
+ trend_length = 720
89
+ trend_type = "EMA"
@@ -0,0 +1,38 @@
1
+ [strategy]
2
+ strategy_class = "impulse"
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-04-01"
9
+ end_date = "2026-04-01"
10
+
11
+ [backtest.ohlcv_provider]
12
+ ohlcv_provider = "ccxt_bitget"
13
+
14
+ [[strategy.profiles]]
15
+ symbol = "ETH/USDT:USDT"
16
+ timeframe = "4h"
17
+ total_balance_ratio = 1.0
18
+ leverage = 1.0
19
+ trix_length = 8
20
+ signal_length = 12
21
+ trend_length = 180
22
+ trend_type = "EMA"
23
+
24
+ [optimisation]
25
+
26
+ [[optimisation.parameter_configs]]
27
+ parameter = "strategy.profiles.trix_length"
28
+ sampling = "linear"
29
+ min = 5.0
30
+ max = 50.0
31
+ num_samples = 10
32
+
33
+ [[optimisation.parameter_configs]]
34
+ parameter = "strategy.profiles.signal_length"
35
+ sampling = "linear"
36
+ min = 5.0
37
+ max = 50.0
38
+ num_samples = 10
@@ -0,0 +1,3 @@
1
+ [[bot]]
2
+ config = "impulse-bot-example/impulse-bot-example.toml"
3
+ timeframes = ["1h", "2h", "4h"]
@@ -0,0 +1,141 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "7fb27b941602401d91542211134fc71a",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Impulse Strategy — Backtest\n",
9
+ "\n",
10
+ "Runs the strategy defined in `impulse-bot-example.toml` over historical data and reviews the results."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "acae54e37e7d407bbb7b55eff062a284",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "from robottraderslab import BotConfig, run_backtest"
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": null,
26
+ "id": "9a63283cbaf04dbcab1f6479b197f3a8",
27
+ "metadata": {},
28
+ "outputs": [],
29
+ "source": [
30
+ "results = run_backtest(BotConfig.from_file(\"impulse-bot-example.toml\"))"
31
+ ]
32
+ },
33
+ {
34
+ "cell_type": "markdown",
35
+ "id": "8dd0d8092fe74a7c96281538738b07e2",
36
+ "metadata": {},
37
+ "source": [
38
+ "## Performance Overview"
39
+ ]
40
+ },
41
+ {
42
+ "cell_type": "code",
43
+ "execution_count": null,
44
+ "id": "72eea5119410473aa328ad9291626812",
45
+ "metadata": {},
46
+ "outputs": [],
47
+ "source": [
48
+ "analyser = results.create_analyser()"
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "id": "8edb47106e1a46a883d545849b8ab81b",
55
+ "metadata": {},
56
+ "outputs": [],
57
+ "source": [
58
+ "analyser.plot_performance_summary()"
59
+ ]
60
+ },
61
+ {
62
+ "cell_type": "code",
63
+ "execution_count": null,
64
+ "id": "10185d26023b46108eb7d9f57d49d2b3",
65
+ "metadata": {},
66
+ "outputs": [],
67
+ "source": [
68
+ "analyser.print_performance_summary()"
69
+ ]
70
+ },
71
+ {
72
+ "cell_type": "code",
73
+ "execution_count": null,
74
+ "id": "8763a12b2bbd4a93a75aff182afb95dc",
75
+ "metadata": {},
76
+ "outputs": [],
77
+ "source": [
78
+ "analyser.plot_equity_curve()\n",
79
+ "analyser.plot_drawdown()"
80
+ ]
81
+ },
82
+ {
83
+ "cell_type": "code",
84
+ "execution_count": null,
85
+ "id": "7623eae2785240b9bd12b16a66d81610",
86
+ "metadata": {},
87
+ "outputs": [],
88
+ "source": [
89
+ "analyser.plot_cumulative_pnl_by_trade()\n",
90
+ "analyser.plot_monthly_performance(year=\"all\")"
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "markdown",
95
+ "id": "7cdc8c89c7104fffa095e18ddfef8986",
96
+ "metadata": {},
97
+ "source": [
98
+ "## Indicator Chart"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "code",
103
+ "execution_count": null,
104
+ "id": "b118ea5561624da68c537baed56e602f",
105
+ "metadata": {},
106
+ "outputs": [],
107
+ "source": "analyser.plot_candlesticks(indicators_name=\"impulse\")"
108
+ },
109
+ {
110
+ "cell_type": "markdown",
111
+ "id": "938c804e27f84196a10c8828c723f798",
112
+ "metadata": {},
113
+ "source": [
114
+ "## Trades"
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": null,
120
+ "id": "504fb2a444614c0babb325280ed9130a",
121
+ "metadata": {},
122
+ "outputs": [],
123
+ "source": [
124
+ "analyser.trades"
125
+ ]
126
+ }
127
+ ],
128
+ "metadata": {
129
+ "kernelspec": {
130
+ "display_name": "Python 3",
131
+ "language": "python",
132
+ "name": "python3"
133
+ },
134
+ "language_info": {
135
+ "name": "python",
136
+ "version": "3.12"
137
+ }
138
+ },
139
+ "nbformat": 4,
140
+ "nbformat_minor": 5
141
+ }
@@ -0,0 +1,92 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "7fb27b941602401d91542211134fc71a",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Impulse Strategy — Parameter Optimisation\n",
9
+ "\n",
10
+ "Grid-searches the parameter ranges in `impulse-optimisation-example.toml` and plots the results."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "acae54e37e7d407bbb7b55eff062a284",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "from robottraderslab import BotConfig, run_grid_search"
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "id": "9a63283cbaf04dbcab1f6479b197f3a8",
26
+ "metadata": {},
27
+ "source": [
28
+ "## Run Grid Search"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": null,
34
+ "id": "8dd0d8092fe74a7c96281538738b07e2",
35
+ "metadata": {},
36
+ "outputs": [],
37
+ "source": [
38
+ "results = run_grid_search(BotConfig.from_file(\"impulse-optimisation-example.toml\"))"
39
+ ]
40
+ },
41
+ {
42
+ "cell_type": "markdown",
43
+ "id": "72eea5119410473aa328ad9291626812",
44
+ "metadata": {},
45
+ "source": [
46
+ "## Parameter Heatmaps"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "execution_count": null,
52
+ "id": "8edb47106e1a46a883d545849b8ab81b",
53
+ "metadata": {},
54
+ "outputs": [],
55
+ "source": [
56
+ "results.plot_heatmap_summary(smoothing=1.0)"
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "markdown",
61
+ "id": "10185d26023b46108eb7d9f57d49d2b3",
62
+ "metadata": {},
63
+ "source": [
64
+ "## Export Results"
65
+ ]
66
+ },
67
+ {
68
+ "cell_type": "code",
69
+ "execution_count": null,
70
+ "id": "8763a12b2bbd4a93a75aff182afb95dc",
71
+ "metadata": {},
72
+ "outputs": [],
73
+ "source": [
74
+ "csv_path = results.to_csv()\n",
75
+ "print(f\"Exported to {csv_path}\")"
76
+ ]
77
+ }
78
+ ],
79
+ "metadata": {
80
+ "kernelspec": {
81
+ "display_name": "Python 3",
82
+ "language": "python",
83
+ "name": "python3"
84
+ },
85
+ "language_info": {
86
+ "name": "python",
87
+ "version": "3.12"
88
+ }
89
+ },
90
+ "nbformat": 4,
91
+ "nbformat_minor": 5
92
+ }
@@ -0,0 +1,5 @@
1
+ [[secrets]]
2
+ name = "impulse-example"
3
+ api_key = ""
4
+ secret_key = ""
5
+ passphrase = ""
@@ -0,0 +1,209 @@
1
+ from collections.abc import Sequence
2
+ from datetime import datetime
3
+ from typing import ClassVar
4
+
5
+ from robottraderslab import Symbol, TimeFrame
6
+ from robottraderslab.exceptions import StrategyCriticalError
7
+ from robottraderslab.strategies import (
8
+ AccountSnapshots,
9
+ BookKeeper,
10
+ MarketType,
11
+ OHLCVs,
12
+ PositionTracker,
13
+ ProfileStrategy,
14
+ StrategyRequirements,
15
+ SymbolTimeframe,
16
+ TrackingId,
17
+ )
18
+ from robottraderslab.strategies.futures import FuturesAccount, MarginSettings
19
+
20
+ from .impulse_indicator import compute_signals
21
+ from .position_orders import book_entry, book_exit, wanted_entry_side, wants_exit
22
+ from .position_protection import book_position_protections
23
+ from .position_tracking import tags_by_profile_id, tracked_symbols
24
+ from .profile_config import ProfileConfig
25
+ from .profile_snapshot import ProfileSnapshot
26
+
27
+
28
+ class ImpulseStrategy(ProfileStrategy[ProfileConfig]):
29
+ """Momentum strategy: enters on a TRIX crossover that agrees with the trend.
30
+
31
+ Each profile covers one symbol on one timeframe. Profiles may stack on the
32
+ same symbol, each opening and closing its own position independently.
33
+ """
34
+
35
+ market_type: ClassVar[MarketType] = "futures"
36
+
37
+ account: FuturesAccount
38
+ _position_tracker: PositionTracker
39
+ _closing_profile_ids: set[TrackingId]
40
+
41
+ async def setup(self, requirements: StrategyRequirements) -> None:
42
+ """
43
+ Raises:
44
+ StrategyCriticalError: If a profile's tag is too long, or two
45
+ profiles on the same symbol resolve to the same tag or disagree
46
+ on stop_loss_pct, leverage or margin_mode.
47
+ """
48
+ _validate_stop_loss_agreement(self.profiles)
49
+ margin_targets = _margin_targets(self.profiles)
50
+
51
+ lookbacks = _lookbacks_by_timeframe(self.profiles)
52
+ for symbol_timeframe in _symbol_timeframes(self.profiles):
53
+ requirements.ohlcv.add(
54
+ symbol_timeframe.symbol,
55
+ symbol_timeframe.timeframe,
56
+ lookbacks[symbol_timeframe.timeframe],
57
+ )
58
+
59
+ requirements.account.add(
60
+ self.account,
61
+ symbols=[profile.symbol for profile in self.profiles],
62
+ positions=True,
63
+ open_orders=True,
64
+ balances=True,
65
+ sizing=[profile.sizing for profile in self.profiles],
66
+ margin_targets=margin_targets,
67
+ )
68
+ self._position_tracker = requirements.tracker.add(
69
+ account=self.account,
70
+ symbols=tracked_symbols(self.profiles),
71
+ tags=tags_by_profile_id(self.profiles),
72
+ )
73
+
74
+ def generate_profile_signals(self, profile: ProfileConfig, ohlcvs: OHLCVs) -> None:
75
+ """Compute the profile's TRIX entry and exit signal columns."""
76
+ symbol = profile.symbol
77
+ timeframe = profile.timeframe
78
+ signals = compute_signals(
79
+ ohlcvs.column(symbol, timeframe, "close"),
80
+ profile.trix_length,
81
+ profile.signal_length,
82
+ profile.signal_type,
83
+ profile.trend_length,
84
+ profile.trend_type,
85
+ )
86
+ ohlcvs.add_column(
87
+ symbol, timeframe, profile.long_entry_column, signals.long_entry
88
+ )
89
+ ohlcvs.add_column(
90
+ symbol, timeframe, profile.long_exit_column, signals.long_exit
91
+ )
92
+ ohlcvs.add_column(
93
+ symbol, timeframe, profile.short_entry_column, signals.short_entry
94
+ )
95
+ ohlcvs.add_column(
96
+ symbol, timeframe, profile.short_exit_column, signals.short_exit
97
+ )
98
+
99
+ def book_general_actions(
100
+ self,
101
+ ohlcvs: OHLCVs,
102
+ account_snapshots: AccountSnapshots,
103
+ timestamp: datetime,
104
+ bookkeeper: BookKeeper,
105
+ triggered_timeframes: list[TimeFrame],
106
+ ) -> None:
107
+ self._closing_profile_ids = set()
108
+
109
+ def book_profile_actions(
110
+ self,
111
+ profile: ProfileConfig,
112
+ ohlcvs: OHLCVs,
113
+ account_snapshots: AccountSnapshots,
114
+ timestamp: datetime,
115
+ bookkeeper: BookKeeper,
116
+ ) -> None:
117
+ """Emit the profile's entry or exit action for the current candle."""
118
+ profile_snapshot = ProfileSnapshot.build(
119
+ profile, ohlcvs, self._position_tracker
120
+ )
121
+ if profile_snapshot.has_no_price:
122
+ return
123
+
124
+ position = profile_snapshot.position
125
+ if position is not None:
126
+ if wants_exit(position, profile_snapshot):
127
+ self._closing_profile_ids.add(profile.profile_id)
128
+ book_exit(self.account, profile, position, bookkeeper)
129
+ return
130
+
131
+ side = wanted_entry_side(profile, profile_snapshot)
132
+ if side is None:
133
+ return
134
+ book_entry(
135
+ self.account,
136
+ profile,
137
+ side,
138
+ profile_snapshot,
139
+ account_snapshots.of(self.account),
140
+ bookkeeper,
141
+ )
142
+
143
+ def book_final_actions(
144
+ self,
145
+ ohlcvs: OHLCVs,
146
+ account_snapshots: AccountSnapshots,
147
+ timestamp: datetime,
148
+ bookkeeper: BookKeeper,
149
+ triggered_timeframes: list[TimeFrame],
150
+ ) -> None:
151
+ """Protect every open position, sparing the ones closing this candle."""
152
+ book_position_protections(
153
+ self.account,
154
+ self.profiles,
155
+ self._position_tracker,
156
+ account_snapshots.of(self.account),
157
+ self._closing_profile_ids,
158
+ bookkeeper,
159
+ )
160
+
161
+
162
+ def _validate_stop_loss_agreement(profiles: Sequence[ProfileConfig]) -> None:
163
+ percentages_by_symbol: dict[Symbol, set[float | None]] = {}
164
+ for profile in profiles:
165
+ percentages_by_symbol.setdefault(profile.symbol, set()).add(
166
+ profile.stop_loss_pct
167
+ )
168
+ for symbol, percentages in percentages_by_symbol.items():
169
+ if len(percentages) > 1:
170
+ raise StrategyCriticalError(
171
+ f"Profiles on {symbol} set conflicting stop_loss_pct values. A symbol "
172
+ f"has a single net position with one stop-loss, so stacked profiles "
173
+ f"must all use the same stop_loss_pct (or all leave it unset)."
174
+ )
175
+
176
+
177
+ def _margin_targets(profiles: Sequence[ProfileConfig]) -> dict[Symbol, MarginSettings]:
178
+ targets: dict[Symbol, MarginSettings] = {}
179
+ for profile in profiles:
180
+ wanted = MarginSettings(
181
+ leverage=profile.leverage, margin_mode=profile.margin_mode
182
+ )
183
+ declared = targets.setdefault(profile.symbol, wanted)
184
+ if declared != wanted:
185
+ raise StrategyCriticalError(
186
+ f"Profiles on {profile.symbol} set conflicting leverage or "
187
+ f"margin_mode values. A symbol trades at one leverage in one "
188
+ f"margin mode, so stacked profiles must all use the same ones."
189
+ )
190
+ return targets
191
+
192
+
193
+ def _lookbacks_by_timeframe(profiles: Sequence[ProfileConfig]) -> dict[TimeFrame, int]:
194
+ lookbacks: dict[TimeFrame, int] = {}
195
+ for profile in profiles:
196
+ needed = max(profile.trix_length, profile.signal_length, profile.trend_length)
197
+ lookbacks[profile.timeframe] = max(lookbacks.get(profile.timeframe, 0), needed)
198
+ return lookbacks
199
+
200
+
201
+ def _symbol_timeframes(profiles: Sequence[ProfileConfig]) -> list[SymbolTimeframe]:
202
+ unique: dict[tuple[Symbol, TimeFrame], SymbolTimeframe] = {}
203
+ for profile in profiles:
204
+ key = (profile.symbol, profile.timeframe)
205
+ unique.setdefault(
206
+ key,
207
+ SymbolTimeframe(symbol=profile.symbol, timeframe=profile.timeframe),
208
+ )
209
+ return list(unique.values())
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+
6
+ from robottraderslab.indicators import MAType, moving_average, trix
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ImpulseSignals:
11
+ """The candles a side may be entered or exited on, one boolean per candle."""
12
+
13
+ long_entry: npt.NDArray[np.bool_]
14
+ long_exit: npt.NDArray[np.bool_]
15
+ short_entry: npt.NDArray[np.bool_]
16
+ short_exit: npt.NDArray[np.bool_]
17
+
18
+
19
+ def compute_signals(
20
+ close: npt.NDArray[np.float64],
21
+ trix_length: int,
22
+ signal_length: int,
23
+ signal_type: MAType,
24
+ trend_length: int,
25
+ trend_type: MAType,
26
+ ) -> ImpulseSignals:
27
+ """Return the entry and exit candles for both sides.
28
+
29
+ A side is open while the TRIX histogram agrees with price sitting the
30
+ same side of the trend average, so a run of agreeing candles trades once.
31
+ """
32
+ indicator = trix(
33
+ close,
34
+ trix_length,
35
+ signal_length,
36
+ signal_type=signal_type,
37
+ )
38
+ trend = moving_average(close, trend_length, trend_type)
39
+
40
+ long_open = (indicator.histogram > 0) & (close > trend)
41
+ short_open = (indicator.histogram < 0) & (close < trend)
42
+
43
+ return ImpulseSignals(
44
+ long_entry=_starts(long_open),
45
+ long_exit=_ends(long_open),
46
+ short_entry=_starts(short_open),
47
+ short_exit=_ends(short_open),
48
+ )
49
+
50
+
51
+ def _starts(open_bars: npt.NDArray[np.bool_]) -> npt.NDArray[np.bool_]:
52
+ return open_bars & ~_previous(open_bars)
53
+
54
+
55
+ def _ends(open_bars: npt.NDArray[np.bool_]) -> npt.NDArray[np.bool_]:
56
+ return ~open_bars & _previous(open_bars)
57
+
58
+
59
+ def _previous(open_bars: npt.NDArray[np.bool_]) -> npt.NDArray[np.bool_]:
60
+ shifted = np.roll(open_bars, 1)
61
+ shifted[0] = False
62
+ return shifted
@@ -0,0 +1,84 @@
1
+ from robottraderslab.strategies import (
2
+ AccountSnapshot,
3
+ BookKeeper,
4
+ PositionSide,
5
+ TrackedPosition,
6
+ )
7
+ from robottraderslab.strategies.futures import FuturesAccount
8
+
9
+ from .position_protection import stop_loss_trigger_price
10
+ from .profile_config import ProfileConfig
11
+ from .profile_snapshot import ProfileSnapshot
12
+
13
+ _ENTRY_REASONS = {
14
+ PositionSide.LONG: "impulse long entry",
15
+ PositionSide.SHORT: "impulse short entry",
16
+ }
17
+ _EXIT_REASONS = {
18
+ PositionSide.LONG: "impulse long exit",
19
+ PositionSide.SHORT: "impulse short exit",
20
+ }
21
+
22
+
23
+ def wanted_entry_side(
24
+ profile: ProfileConfig, profile_snapshot: ProfileSnapshot
25
+ ) -> PositionSide | None:
26
+ """Return the side this profile wants to open on this candle, or None.
27
+
28
+ A profile decides alone. Profiles stacked on one symbol may disagree, and
29
+ the venue nets their orders into the single position it holds.
30
+ """
31
+ if not profile.short_only and profile_snapshot.long_entry:
32
+ return PositionSide.LONG
33
+ if not profile.long_only and profile_snapshot.short_entry:
34
+ return PositionSide.SHORT
35
+ return None
36
+
37
+
38
+ def wants_exit(position: TrackedPosition, profile_snapshot: ProfileSnapshot) -> bool:
39
+ """Report whether the held position's own side is being told to leave."""
40
+ if position.side == PositionSide.LONG:
41
+ return profile_snapshot.long_exit
42
+ return profile_snapshot.short_exit
43
+
44
+
45
+ def book_entry(
46
+ account: FuturesAccount,
47
+ profile: ProfileConfig,
48
+ side: PositionSide,
49
+ profile_snapshot: ProfileSnapshot,
50
+ account_snapshot: AccountSnapshot,
51
+ bookkeeper: BookKeeper,
52
+ ) -> None:
53
+ symbol = profile.symbol
54
+ entry = (
55
+ account.long_entry(symbol)
56
+ if side == PositionSide.LONG
57
+ else account.short_entry(symbol)
58
+ )
59
+ entry = (
60
+ entry.size(profile.sizing, profile_snapshot.price, account_snapshot)
61
+ .tag(profile.order_tag)
62
+ .reason(_ENTRY_REASONS[side])
63
+ )
64
+ if profile.stop_loss_pct is not None:
65
+ entry = entry.stop_loss(
66
+ price=stop_loss_trigger_price(
67
+ side, profile_snapshot.price, profile.stop_loss_pct
68
+ )
69
+ )
70
+ bookkeeper.add(entry.build())
71
+
72
+
73
+ def book_exit(
74
+ account: FuturesAccount,
75
+ profile: ProfileConfig,
76
+ position: TrackedPosition,
77
+ bookkeeper: BookKeeper,
78
+ ) -> None:
79
+ exit_order = (
80
+ account.close_tracked_position(profile.symbol, position)
81
+ .tag(profile.order_tag)
82
+ .reason(_EXIT_REASONS[position.side])
83
+ )
84
+ bookkeeper.add(exit_order.build())
@@ -0,0 +1,86 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+ from robottraderslab import Symbol
5
+ from robottraderslab.strategies import (
6
+ AccountSnapshot,
7
+ BookKeeper,
8
+ PositionSide,
9
+ PositionTracker,
10
+ TrackingId,
11
+ )
12
+ from robottraderslab.strategies.futures import FuturesAccount
13
+
14
+ from .profile_config import ProfileConfig
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _Protection:
19
+ """The stop-loss one symbol's open profiles ask for, and who is asking."""
20
+
21
+ stop_loss_pct: float
22
+ profile_ids: frozenset[TrackingId]
23
+
24
+
25
+ def book_position_protections(
26
+ account: FuturesAccount,
27
+ profiles: Sequence[ProfileConfig],
28
+ position_tracker: PositionTracker,
29
+ account_snapshot: AccountSnapshot,
30
+ closing_profile_ids: set[TrackingId],
31
+ bookkeeper: BookKeeper,
32
+ ) -> None:
33
+ """Move each held position's stop-loss to its configured distance.
34
+
35
+ A symbol whose every open profile is closing this candle keeps no position
36
+ to protect, so its stop-loss is cancelled.
37
+ """
38
+ for symbol, protection in _protections(profiles, position_tracker).items():
39
+ if protection.profile_ids <= closing_profile_ids:
40
+ for order in account_snapshot.stop_loss_orders(symbol):
41
+ bookkeeper.add(account.cancel_order(symbol, order.order_id))
42
+ continue
43
+ position = account_snapshot.position(symbol)
44
+ if position is None:
45
+ continue
46
+ bookkeeper.add(
47
+ account.move_stop_loss(
48
+ symbol,
49
+ stop_loss_trigger_price(
50
+ position.side,
51
+ position.average_entry_price,
52
+ protection.stop_loss_pct,
53
+ ),
54
+ )
55
+ )
56
+
57
+
58
+ def stop_loss_trigger_price(
59
+ side: PositionSide, reference_price: float, stop_loss_pct: float
60
+ ) -> float:
61
+ """Return the trigger price a stop-loss sits at, on either side."""
62
+ if side == PositionSide.LONG:
63
+ return reference_price * (1 - stop_loss_pct)
64
+ return reference_price * (1 + stop_loss_pct)
65
+
66
+
67
+ def _protections(
68
+ profiles: Sequence[ProfileConfig], position_tracker: PositionTracker
69
+ ) -> dict[Symbol, _Protection]:
70
+ """Collect the stop-loss each symbol holding a position asks for.
71
+
72
+ Profiles sharing a symbol are validated to agree on the distance, so the
73
+ first of them speaks for all.
74
+ """
75
+ open_profiles: dict[Symbol, list[ProfileConfig]] = {}
76
+ for profile in profiles:
77
+ if position_tracker.get(profile.profile_id) is not None:
78
+ open_profiles.setdefault(profile.symbol, []).append(profile)
79
+ return {
80
+ symbol: _Protection(
81
+ stop_loss_pct=stop_loss_pct,
82
+ profile_ids=frozenset(profile.profile_id for profile in on_symbol),
83
+ )
84
+ for symbol, on_symbol in open_profiles.items()
85
+ if (stop_loss_pct := on_symbol[0].stop_loss_pct) is not None
86
+ }
@@ -0,0 +1,18 @@
1
+ from collections.abc import Sequence
2
+
3
+ from robottraderslab import Symbol
4
+ from robottraderslab.strategies import ProfileTag, TrackingId
5
+
6
+ from .profile_config import ProfileConfig
7
+
8
+
9
+ def tags_by_profile_id(
10
+ profiles: Sequence[ProfileConfig],
11
+ ) -> dict[TrackingId, ProfileTag]:
12
+ """Return the tag each profile's own orders carry."""
13
+ return {profile.profile_id: profile.order_tag for profile in profiles}
14
+
15
+
16
+ def tracked_symbols(profiles: Sequence[ProfileConfig]) -> dict[TrackingId, Symbol]:
17
+ """Return the symbol each profile's tracked position trades."""
18
+ return {profile.profile_id: profile.symbol for profile in profiles}
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass
2
+ from functools import cached_property
3
+
4
+ from robottraderslab.exceptions import StrategyCriticalError
5
+ from robottraderslab.indicators import MAType
6
+ from robottraderslab.strategies import Profile
7
+ from robottraderslab.strategies.futures import (
8
+ MarginMode,
9
+ SizingRule,
10
+ )
11
+
12
+ DEFAULT_SIGNAL_TYPE = MAType.SMA
13
+ DEFAULT_TREND_LENGTH = 200
14
+ DEFAULT_TREND_TYPE = MAType.SMA
15
+
16
+
17
+ @dataclass(frozen=True, kw_only=True)
18
+ class TrixParameters:
19
+ """The lengths and averages the TRIX signals are built from."""
20
+
21
+ trix_length: int
22
+ signal_length: int
23
+ signal_type: MAType = DEFAULT_SIGNAL_TYPE
24
+ trend_length: int = DEFAULT_TREND_LENGTH
25
+ trend_type: MAType = DEFAULT_TREND_TYPE
26
+
27
+
28
+ @dataclass(frozen=True, kw_only=True)
29
+ class ProfileConfig(Profile, TrixParameters):
30
+ """One symbol traded on one timeframe, with its own TRIX settings and sizing.
31
+
32
+ Profiles may stack on the same symbol, each holding its own position.
33
+ """
34
+
35
+ sizing: SizingRule
36
+ leverage: float = 1.0
37
+ margin_mode: MarginMode = MarginMode.ISOLATED
38
+ long_only: bool = False
39
+ short_only: bool = False
40
+ stop_loss_pct: float | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ """
44
+ Raises:
45
+ StrategyCriticalError: If both direction flags are set, or the
46
+ stop-loss is not a fraction of the entry price.
47
+ """
48
+ if self.long_only and self.short_only:
49
+ raise StrategyCriticalError(
50
+ "long_only and short_only cannot both be true; leave both out to "
51
+ "trade both directions"
52
+ )
53
+ if self.stop_loss_pct is not None and not 0 < self.stop_loss_pct < 1:
54
+ raise StrategyCriticalError(
55
+ f"stop_loss_pct must be in (0, 1), got {self.stop_loss_pct}"
56
+ )
57
+
58
+ @cached_property
59
+ def long_entry_column(self) -> str:
60
+ return f"{self.profile_id}_long_entry"
61
+
62
+ @cached_property
63
+ def long_exit_column(self) -> str:
64
+ return f"{self.profile_id}_long_exit"
65
+
66
+ @cached_property
67
+ def short_entry_column(self) -> str:
68
+ return f"{self.profile_id}_short_entry"
69
+
70
+ @cached_property
71
+ def short_exit_column(self) -> str:
72
+ return f"{self.profile_id}_short_exit"
@@ -0,0 +1,42 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+ from typing import Self
4
+
5
+ from robottraderslab.strategies import OHLCVs, PositionTracker, TrackedPosition
6
+
7
+ from .profile_config import ProfileConfig
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ProfileSnapshot:
12
+ """Per-profile view of the current candle: its signals plus tracked state."""
13
+
14
+ price: float
15
+ long_entry: bool
16
+ long_exit: bool
17
+ short_entry: bool
18
+ short_exit: bool
19
+ position: TrackedPosition | None
20
+
21
+ @classmethod
22
+ def build(
23
+ cls,
24
+ profile: ProfileConfig,
25
+ ohlcvs: OHLCVs,
26
+ position_tracker: PositionTracker,
27
+ ) -> Self:
28
+ """Read one profile's signals and tracked position for the current candle."""
29
+ symbol = profile.symbol
30
+ timeframe = profile.timeframe
31
+ return cls(
32
+ price=ohlcvs.current(symbol, timeframe, "close"),
33
+ long_entry=ohlcvs.signal(symbol, timeframe, profile.long_entry_column),
34
+ long_exit=ohlcvs.signal(symbol, timeframe, profile.long_exit_column),
35
+ short_entry=ohlcvs.signal(symbol, timeframe, profile.short_entry_column),
36
+ short_exit=ohlcvs.signal(symbol, timeframe, profile.short_exit_column),
37
+ position=position_tracker.get(profile.profile_id),
38
+ )
39
+
40
+ @property
41
+ def has_no_price(self) -> bool:
42
+ return math.isnan(self.price)