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.
- robottraderslab_impulse-0.1.0/PKG-INFO +30 -0
- robottraderslab_impulse-0.1.0/README.md +17 -0
- robottraderslab_impulse-0.1.0/pyproject.toml +32 -0
- robottraderslab_impulse-0.1.0/pyproject.toml.orig +33 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/__init__.py +5 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/chart_indicators.py +62 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/impulse-bot-example.toml +89 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/impulse-optimisation-example.toml +38 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/registry.example.toml +3 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/run_backtest.ipynb +141 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/run_optimisation.ipynb +92 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/secrets.example.toml +5 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/impulse.py +209 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/impulse_indicator.py +62 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_orders.py +84 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_protection.py +86 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/position_tracking.py +18 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/profile_config.py +72 -0
- robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/profile_snapshot.py +42 -0
- 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,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"
|
robottraderslab_impulse-0.1.0/src/robottraderslab_impulse/examples/impulse-optimisation-example.toml
ADDED
|
@@ -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,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,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)
|
|
File without changes
|