fsb-core 0.4.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.
- fsb_core-0.4.0/Cargo.lock +224 -0
- fsb_core-0.4.0/Cargo.toml +21 -0
- fsb_core-0.4.0/PKG-INFO +48 -0
- fsb_core-0.4.0/README.md +22 -0
- fsb_core-0.4.0/pyproject.toml +32 -0
- fsb_core-0.4.0/src/lib.rs +882 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "autocfg"
|
|
7
|
+
version = "1.5.1"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "crossbeam-deque"
|
|
13
|
+
version = "0.8.8"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"crossbeam-epoch",
|
|
18
|
+
"crossbeam-utils",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[package]]
|
|
22
|
+
name = "crossbeam-epoch"
|
|
23
|
+
version = "0.9.21"
|
|
24
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
25
|
+
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"crossbeam-utils",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[package]]
|
|
31
|
+
name = "crossbeam-utils"
|
|
32
|
+
version = "0.8.23"
|
|
33
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
34
|
+
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
|
35
|
+
|
|
36
|
+
[[package]]
|
|
37
|
+
name = "either"
|
|
38
|
+
version = "1.19.0"
|
|
39
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
40
|
+
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|
41
|
+
|
|
42
|
+
[[package]]
|
|
43
|
+
name = "fsb_core"
|
|
44
|
+
version = "0.4.0"
|
|
45
|
+
dependencies = [
|
|
46
|
+
"pyo3",
|
|
47
|
+
"rayon",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[[package]]
|
|
51
|
+
name = "heck"
|
|
52
|
+
version = "0.5.0"
|
|
53
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
54
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
55
|
+
|
|
56
|
+
[[package]]
|
|
57
|
+
name = "indoc"
|
|
58
|
+
version = "2.0.8"
|
|
59
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
60
|
+
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
|
|
61
|
+
dependencies = [
|
|
62
|
+
"rustversion",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[[package]]
|
|
66
|
+
name = "libc"
|
|
67
|
+
version = "0.2.191"
|
|
68
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
69
|
+
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "memoffset"
|
|
73
|
+
version = "0.9.1"
|
|
74
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
75
|
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
76
|
+
dependencies = [
|
|
77
|
+
"autocfg",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[[package]]
|
|
81
|
+
name = "once_cell"
|
|
82
|
+
version = "1.21.4"
|
|
83
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
84
|
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "portable-atomic"
|
|
88
|
+
version = "1.15.0"
|
|
89
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
90
|
+
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|
91
|
+
|
|
92
|
+
[[package]]
|
|
93
|
+
name = "proc-macro2"
|
|
94
|
+
version = "1.0.107"
|
|
95
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
96
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
97
|
+
dependencies = [
|
|
98
|
+
"unicode-ident",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[[package]]
|
|
102
|
+
name = "pyo3"
|
|
103
|
+
version = "0.27.2"
|
|
104
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
105
|
+
checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
|
|
106
|
+
dependencies = [
|
|
107
|
+
"indoc",
|
|
108
|
+
"libc",
|
|
109
|
+
"memoffset",
|
|
110
|
+
"once_cell",
|
|
111
|
+
"portable-atomic",
|
|
112
|
+
"pyo3-build-config",
|
|
113
|
+
"pyo3-ffi",
|
|
114
|
+
"pyo3-macros",
|
|
115
|
+
"unindent",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
[[package]]
|
|
119
|
+
name = "pyo3-build-config"
|
|
120
|
+
version = "0.27.2"
|
|
121
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
122
|
+
checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
|
|
123
|
+
dependencies = [
|
|
124
|
+
"target-lexicon",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[[package]]
|
|
128
|
+
name = "pyo3-ffi"
|
|
129
|
+
version = "0.27.2"
|
|
130
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
131
|
+
checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
|
|
132
|
+
dependencies = [
|
|
133
|
+
"libc",
|
|
134
|
+
"pyo3-build-config",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
[[package]]
|
|
138
|
+
name = "pyo3-macros"
|
|
139
|
+
version = "0.27.2"
|
|
140
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
141
|
+
checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
|
|
142
|
+
dependencies = [
|
|
143
|
+
"proc-macro2",
|
|
144
|
+
"pyo3-macros-backend",
|
|
145
|
+
"quote",
|
|
146
|
+
"syn",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
[[package]]
|
|
150
|
+
name = "pyo3-macros-backend"
|
|
151
|
+
version = "0.27.2"
|
|
152
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
153
|
+
checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
|
|
154
|
+
dependencies = [
|
|
155
|
+
"heck",
|
|
156
|
+
"proc-macro2",
|
|
157
|
+
"pyo3-build-config",
|
|
158
|
+
"quote",
|
|
159
|
+
"syn",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
[[package]]
|
|
163
|
+
name = "quote"
|
|
164
|
+
version = "1.0.47"
|
|
165
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
166
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
167
|
+
dependencies = [
|
|
168
|
+
"proc-macro2",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
[[package]]
|
|
172
|
+
name = "rayon"
|
|
173
|
+
version = "1.12.0"
|
|
174
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
175
|
+
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
|
176
|
+
dependencies = [
|
|
177
|
+
"either",
|
|
178
|
+
"rayon-core",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
[[package]]
|
|
182
|
+
name = "rayon-core"
|
|
183
|
+
version = "1.13.0"
|
|
184
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
185
|
+
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
|
186
|
+
dependencies = [
|
|
187
|
+
"crossbeam-deque",
|
|
188
|
+
"crossbeam-utils",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
[[package]]
|
|
192
|
+
name = "rustversion"
|
|
193
|
+
version = "1.0.23"
|
|
194
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
195
|
+
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
196
|
+
|
|
197
|
+
[[package]]
|
|
198
|
+
name = "syn"
|
|
199
|
+
version = "2.0.119"
|
|
200
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
201
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
202
|
+
dependencies = [
|
|
203
|
+
"proc-macro2",
|
|
204
|
+
"quote",
|
|
205
|
+
"unicode-ident",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
[[package]]
|
|
209
|
+
name = "target-lexicon"
|
|
210
|
+
version = "0.13.5"
|
|
211
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
212
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
213
|
+
|
|
214
|
+
[[package]]
|
|
215
|
+
name = "unicode-ident"
|
|
216
|
+
version = "1.0.27"
|
|
217
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
218
|
+
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
|
219
|
+
|
|
220
|
+
[[package]]
|
|
221
|
+
name = "unindent"
|
|
222
|
+
version = "0.2.4"
|
|
223
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
224
|
+
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "fsb_core"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Native indicator and backtest helpers for ForexSmartBot (PyO3)"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = ["VoxHash <contact@voxhash.dev>"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
name = "fsb_core"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
pyo3 = { version = "0.27", features = ["extension-module"] }
|
|
16
|
+
rayon = "1.10"
|
|
17
|
+
|
|
18
|
+
[profile.release]
|
|
19
|
+
lto = true
|
|
20
|
+
codegen-units = 1
|
|
21
|
+
opt-level = 3
|
fsb_core-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fsb-core
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Classifier: Development Status :: 4 - Beta
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Rust
|
|
9
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
16
|
+
Summary: Native Rust indicator and equity helpers for ForexSmartBot (PyO3)
|
|
17
|
+
Keywords: forex,indicators,backtest,pyo3,rust
|
|
18
|
+
Author-email: VoxHash <contact@voxhash.dev>
|
|
19
|
+
License: MIT
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
22
|
+
Project-URL: Homepage, https://github.com/VoxHash/ForexSmartBot
|
|
23
|
+
Project-URL: Issues, https://github.com/VoxHash/ForexSmartBot/issues
|
|
24
|
+
Project-URL: Repository, https://github.com/VoxHash/ForexSmartBot
|
|
25
|
+
|
|
26
|
+
# fsb_core
|
|
27
|
+
|
|
28
|
+
Optional Rust (PyO3) extension for ForexSmartBot indicator and equity hot paths.
|
|
29
|
+
|
|
30
|
+
**0.4.0** adds EMA + MACD indicators and EMA/MACD crossover equity sweeps (on top of 0.3.x RSI mean-reversion and 0.2.x SMA walk-forward / multi-symbol workers).
|
|
31
|
+
|
|
32
|
+
## Build
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Requires rustup + maturin
|
|
36
|
+
pip install maturin
|
|
37
|
+
maturin develop --release
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Smoke test
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python -c "import fsb_core; print(fsb_core.__version__, fsb_core.ema([1,2,3,4,5], 3))"
|
|
44
|
+
python -c "from forexsmartbot.utils.native_indicators import sweep_ema_crossover, sweep_macd; import numpy as np; c=np.linspace(1.1,1.2,200); print(sweep_ema_crossover(c,[5,8],[15,20]).shape, sweep_macd(c,[8],[17],[5]).shape)"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python wrappers with NumPy fallback live in `forexsmartbot.utils.native_indicators`.
|
|
48
|
+
|
fsb_core-0.4.0/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# fsb_core
|
|
2
|
+
|
|
3
|
+
Optional Rust (PyO3) extension for ForexSmartBot indicator and equity hot paths.
|
|
4
|
+
|
|
5
|
+
**0.4.0** adds EMA + MACD indicators and EMA/MACD crossover equity sweeps (on top of 0.3.x RSI mean-reversion and 0.2.x SMA walk-forward / multi-symbol workers).
|
|
6
|
+
|
|
7
|
+
## Build
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Requires rustup + maturin
|
|
11
|
+
pip install maturin
|
|
12
|
+
maturin develop --release
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Smoke test
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -c "import fsb_core; print(fsb_core.__version__, fsb_core.ema([1,2,3,4,5], 3))"
|
|
19
|
+
python -c "from forexsmartbot.utils.native_indicators import sweep_ema_crossover, sweep_macd; import numpy as np; c=np.linspace(1.1,1.2,200); print(sweep_ema_crossover(c,[5,8],[15,20]).shape, sweep_macd(c,[8],[17],[5]).shape)"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python wrappers with NumPy fallback live in `forexsmartbot.utils.native_indicators`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.7,<2.0"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fsb-core"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Native Rust indicator and equity helpers for ForexSmartBot (PyO3)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "VoxHash", email = "contact@voxhash.dev" }]
|
|
13
|
+
keywords = ["forex", "indicators", "backtest", "pyo3", "rust"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Rust",
|
|
20
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
27
|
+
]
|
|
28
|
+
urls = { Homepage = "https://github.com/VoxHash/ForexSmartBot", Repository = "https://github.com/VoxHash/ForexSmartBot", Issues = "https://github.com/VoxHash/ForexSmartBot/issues" }
|
|
29
|
+
|
|
30
|
+
[tool.maturin]
|
|
31
|
+
features = ["pyo3/extension-module"]
|
|
32
|
+
module-name = "fsb_core"
|
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
//! Native CPU-bound helpers for ForexSmartBot.
|
|
2
|
+
//!
|
|
3
|
+
//! Implements rolling indicators (SMA, EMA, ATR, RSI, MACD), batch SMA via rayon,
|
|
4
|
+
//! SMA/EMA-crossover equity, MACD-signal crossover equity, walk-forward sweeps,
|
|
5
|
+
//! multi-symbol portfolio aggregation, and RSI mean-reversion equity/sweeps so
|
|
6
|
+
//! indicator/backtest hot paths can run with lower overhead than pure
|
|
7
|
+
//! pandas while the PyQt UI stays responsive.
|
|
8
|
+
|
|
9
|
+
use pyo3::prelude::*;
|
|
10
|
+
use pyo3::exceptions::PyValueError;
|
|
11
|
+
use rayon::prelude::*;
|
|
12
|
+
|
|
13
|
+
/// O(n) simple moving average. Leading `window-1` samples are NaN.
|
|
14
|
+
fn sma_slice(values: &[f64], window: usize) -> Vec<f64> {
|
|
15
|
+
let n = values.len();
|
|
16
|
+
let mut out = vec![f64::NAN; n];
|
|
17
|
+
if window == 0 || n < window {
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
let mut sum = 0.0_f64;
|
|
21
|
+
for i in 0..n {
|
|
22
|
+
sum += values[i];
|
|
23
|
+
if i + 1 >= window {
|
|
24
|
+
if i + 1 > window {
|
|
25
|
+
sum -= values[i - window];
|
|
26
|
+
}
|
|
27
|
+
out[i] = sum / window as f64;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/// Exponential moving average. Leading `period-1` samples are NaN; seed uses SMA.
|
|
35
|
+
fn ema_slice(values: &[f64], period: usize) -> Vec<f64> {
|
|
36
|
+
let n = values.len();
|
|
37
|
+
let mut out = vec![f64::NAN; n];
|
|
38
|
+
if period == 0 || n < period {
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
let mut sum = 0.0_f64;
|
|
42
|
+
for i in 0..period {
|
|
43
|
+
sum += values[i];
|
|
44
|
+
}
|
|
45
|
+
let mut prev = sum / period as f64;
|
|
46
|
+
out[period - 1] = prev;
|
|
47
|
+
let alpha = 2.0 / (period as f64 + 1.0);
|
|
48
|
+
for i in period..n {
|
|
49
|
+
prev = alpha * values[i] + (1.0 - alpha) * prev;
|
|
50
|
+
out[i] = prev;
|
|
51
|
+
}
|
|
52
|
+
out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// MACD line, signal line, and histogram. Fast/slow/signal periods must be >= 1
|
|
56
|
+
/// and `fast < slow`.
|
|
57
|
+
fn macd_slices(
|
|
58
|
+
close: &[f64],
|
|
59
|
+
fast: usize,
|
|
60
|
+
slow: usize,
|
|
61
|
+
signal: usize,
|
|
62
|
+
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), String> {
|
|
63
|
+
if fast == 0 || slow == 0 || signal == 0 || fast >= slow {
|
|
64
|
+
return Err("require fast >= 1, slow >= 1, signal >= 1, and fast < slow".into());
|
|
65
|
+
}
|
|
66
|
+
let ema_f = ema_slice(close, fast);
|
|
67
|
+
let ema_s = ema_slice(close, slow);
|
|
68
|
+
let n = close.len();
|
|
69
|
+
let mut macd = vec![f64::NAN; n];
|
|
70
|
+
for i in 0..n {
|
|
71
|
+
let f = ema_f[i];
|
|
72
|
+
let s = ema_s[i];
|
|
73
|
+
if !f.is_nan() && !s.is_nan() {
|
|
74
|
+
macd[i] = f - s;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Signal EMA over the MACD series, skipping leading NaNs.
|
|
78
|
+
let mut signal_line = vec![f64::NAN; n];
|
|
79
|
+
let mut hist = vec![f64::NAN; n];
|
|
80
|
+
let mut seed_sum = 0.0_f64;
|
|
81
|
+
let mut seed_count = 0_usize;
|
|
82
|
+
let mut prev_signal = f64::NAN;
|
|
83
|
+
let alpha = 2.0 / (signal as f64 + 1.0);
|
|
84
|
+
for i in 0..n {
|
|
85
|
+
let m = macd[i];
|
|
86
|
+
if m.is_nan() {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if seed_count < signal {
|
|
90
|
+
seed_sum += m;
|
|
91
|
+
seed_count += 1;
|
|
92
|
+
if seed_count == signal {
|
|
93
|
+
prev_signal = seed_sum / signal as f64;
|
|
94
|
+
signal_line[i] = prev_signal;
|
|
95
|
+
hist[i] = m - prev_signal;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
prev_signal = alpha * m + (1.0 - alpha) * prev_signal;
|
|
100
|
+
signal_line[i] = prev_signal;
|
|
101
|
+
hist[i] = m - prev_signal;
|
|
102
|
+
}
|
|
103
|
+
Ok((macd, signal_line, hist))
|
|
104
|
+
}
|
|
105
|
+
/// Average True Range over OHLC series (Wilder-style simple mean of TR).
|
|
106
|
+
fn atr_slice(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>, String> {
|
|
107
|
+
let n = high.len();
|
|
108
|
+
if low.len() != n || close.len() != n {
|
|
109
|
+
return Err("high, low, and close must have the same length".into());
|
|
110
|
+
}
|
|
111
|
+
let mut tr = vec![0.0_f64; n];
|
|
112
|
+
for i in 0..n {
|
|
113
|
+
let hl = high[i] - low[i];
|
|
114
|
+
if i == 0 {
|
|
115
|
+
tr[i] = hl;
|
|
116
|
+
} else {
|
|
117
|
+
let hc = (high[i] - close[i - 1]).abs();
|
|
118
|
+
let lc = (low[i] - close[i - 1]).abs();
|
|
119
|
+
tr[i] = hl.max(hc).max(lc);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
Ok(sma_slice(&tr, period))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Classic RSI using simple rolling mean of gains/losses.
|
|
126
|
+
fn rsi_slice(values: &[f64], period: usize) -> Vec<f64> {
|
|
127
|
+
let n = values.len();
|
|
128
|
+
let mut out = vec![f64::NAN; n];
|
|
129
|
+
if period == 0 || n < period + 1 {
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
let mut gains = vec![0.0_f64; n];
|
|
133
|
+
let mut losses = vec![0.0_f64; n];
|
|
134
|
+
for i in 1..n {
|
|
135
|
+
let d = values[i] - values[i - 1];
|
|
136
|
+
if d >= 0.0 {
|
|
137
|
+
gains[i] = d;
|
|
138
|
+
} else {
|
|
139
|
+
losses[i] = -d;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let avg_gain = sma_slice(&gains, period);
|
|
143
|
+
let avg_loss = sma_slice(&losses, period);
|
|
144
|
+
for i in 0..n {
|
|
145
|
+
let g = avg_gain[i];
|
|
146
|
+
let l = avg_loss[i];
|
|
147
|
+
if g.is_nan() || l.is_nan() {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if l == 0.0 {
|
|
151
|
+
out[i] = if g == 0.0 { 50.0 } else { 100.0 };
|
|
152
|
+
} else {
|
|
153
|
+
let rs = g / l;
|
|
154
|
+
out[i] = 100.0 - (100.0 / (1.0 + rs));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Compact SMA crossover equity over aligned series. Returns mark-to-market curve.
|
|
161
|
+
fn equity_sma_crossover_slice(
|
|
162
|
+
close: &[f64],
|
|
163
|
+
sma_fast: &[f64],
|
|
164
|
+
sma_slow: &[f64],
|
|
165
|
+
initial_balance: f64,
|
|
166
|
+
position_fraction: f64,
|
|
167
|
+
) -> Vec<f64> {
|
|
168
|
+
let n = close.len();
|
|
169
|
+
let mut balance = initial_balance;
|
|
170
|
+
let mut side: i8 = 0;
|
|
171
|
+
let mut size = 0.0_f64;
|
|
172
|
+
let mut entry = 0.0_f64;
|
|
173
|
+
let mut equity = Vec::with_capacity(n);
|
|
174
|
+
|
|
175
|
+
for i in 0..n {
|
|
176
|
+
let px = close[i];
|
|
177
|
+
if i >= 1 {
|
|
178
|
+
let f = sma_fast[i];
|
|
179
|
+
let s = sma_slow[i];
|
|
180
|
+
let pf = sma_fast[i - 1];
|
|
181
|
+
let ps = sma_slow[i - 1];
|
|
182
|
+
if !f.is_nan() && !s.is_nan() && !pf.is_nan() && !ps.is_nan() {
|
|
183
|
+
let cross_up = f > s && pf <= ps;
|
|
184
|
+
let cross_dn = f < s && pf >= ps;
|
|
185
|
+
if cross_up && side <= 0 {
|
|
186
|
+
if side < 0 {
|
|
187
|
+
balance += -size * (px - entry);
|
|
188
|
+
}
|
|
189
|
+
size = (balance * position_fraction) / px;
|
|
190
|
+
entry = px;
|
|
191
|
+
side = 1;
|
|
192
|
+
} else if cross_dn && side >= 0 {
|
|
193
|
+
if side > 0 {
|
|
194
|
+
balance += size * (px - entry);
|
|
195
|
+
}
|
|
196
|
+
size = (balance * position_fraction) / px;
|
|
197
|
+
entry = px;
|
|
198
|
+
side = -1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let mtm = if side != 0 {
|
|
203
|
+
side as f64 * size * (px - entry)
|
|
204
|
+
} else {
|
|
205
|
+
0.0
|
|
206
|
+
};
|
|
207
|
+
equity.push(balance + mtm);
|
|
208
|
+
}
|
|
209
|
+
equity
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn max_drawdown(equity: &[f64]) -> f64 {
|
|
213
|
+
let mut peak = f64::NEG_INFINITY;
|
|
214
|
+
let mut max_dd = 0.0_f64;
|
|
215
|
+
for &e in equity {
|
|
216
|
+
if e > peak {
|
|
217
|
+
peak = e;
|
|
218
|
+
}
|
|
219
|
+
if peak > 0.0 {
|
|
220
|
+
let dd = (peak - e) / peak;
|
|
221
|
+
if dd > max_dd {
|
|
222
|
+
max_dd = dd;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
max_dd
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Run one SMA-crossover backtest; return (final_equity, total_return, max_drawdown).
|
|
230
|
+
fn sma_crossover_metrics(
|
|
231
|
+
close: &[f64],
|
|
232
|
+
fast: usize,
|
|
233
|
+
slow: usize,
|
|
234
|
+
initial_balance: f64,
|
|
235
|
+
position_fraction: f64,
|
|
236
|
+
) -> (f64, f64, f64) {
|
|
237
|
+
if close.is_empty() || fast == 0 || slow == 0 || fast >= slow {
|
|
238
|
+
return (initial_balance, 0.0, 0.0);
|
|
239
|
+
}
|
|
240
|
+
let sma_f = sma_slice(close, fast);
|
|
241
|
+
let sma_s = sma_slice(close, slow);
|
|
242
|
+
let equity = equity_sma_crossover_slice(close, &sma_f, &sma_s, initial_balance, position_fraction);
|
|
243
|
+
let final_eq = *equity.last().unwrap_or(&initial_balance);
|
|
244
|
+
let total_return = if initial_balance != 0.0 {
|
|
245
|
+
(final_eq - initial_balance) / initial_balance
|
|
246
|
+
} else {
|
|
247
|
+
0.0
|
|
248
|
+
};
|
|
249
|
+
(final_eq, total_return, max_drawdown(&equity))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fn build_param_pairs(fast_periods: &[usize], slow_periods: &[usize]) -> Vec<(usize, usize)> {
|
|
253
|
+
let mut pairs = Vec::new();
|
|
254
|
+
for &f in fast_periods {
|
|
255
|
+
for &s in slow_periods {
|
|
256
|
+
if f >= 1 && s >= 1 && f < s {
|
|
257
|
+
pairs.push((f, s));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
pairs
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[pyfunction]
|
|
265
|
+
fn sma(values: Vec<f64>, window: usize) -> PyResult<Vec<f64>> {
|
|
266
|
+
if window == 0 {
|
|
267
|
+
return Err(PyValueError::new_err("window must be >= 1"));
|
|
268
|
+
}
|
|
269
|
+
Ok(sma_slice(&values, window))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[pyfunction]
|
|
273
|
+
fn atr(high: Vec<f64>, low: Vec<f64>, close: Vec<f64>, period: usize) -> PyResult<Vec<f64>> {
|
|
274
|
+
if period == 0 {
|
|
275
|
+
return Err(PyValueError::new_err("period must be >= 1"));
|
|
276
|
+
}
|
|
277
|
+
atr_slice(&high, &low, &close, period).map_err(PyValueError::new_err)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[pyfunction]
|
|
281
|
+
fn rsi(values: Vec<f64>, period: usize) -> PyResult<Vec<f64>> {
|
|
282
|
+
if period == 0 {
|
|
283
|
+
return Err(PyValueError::new_err("period must be >= 1"));
|
|
284
|
+
}
|
|
285
|
+
Ok(rsi_slice(&values, period))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Compute SMA for many series in parallel (rayon). Each series shares `window`.
|
|
289
|
+
#[pyfunction]
|
|
290
|
+
fn batch_sma(series: Vec<Vec<f64>>, window: usize) -> PyResult<Vec<Vec<f64>>> {
|
|
291
|
+
if window == 0 {
|
|
292
|
+
return Err(PyValueError::new_err("window must be >= 1"));
|
|
293
|
+
}
|
|
294
|
+
let out: Vec<Vec<f64>> = series
|
|
295
|
+
.par_iter()
|
|
296
|
+
.map(|s| sma_slice(s, window))
|
|
297
|
+
.collect();
|
|
298
|
+
Ok(out)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Compact SMA crossover backtest loop over precomputed series (no allocations per bar).
|
|
302
|
+
/// Returns mark-to-market equity curve starting from `initial_balance`.
|
|
303
|
+
#[pyfunction]
|
|
304
|
+
fn equity_sma_crossover(
|
|
305
|
+
close: Vec<f64>,
|
|
306
|
+
sma_fast: Vec<f64>,
|
|
307
|
+
sma_slow: Vec<f64>,
|
|
308
|
+
initial_balance: f64,
|
|
309
|
+
position_fraction: f64,
|
|
310
|
+
) -> PyResult<Vec<f64>> {
|
|
311
|
+
let n = close.len();
|
|
312
|
+
if sma_fast.len() != n || sma_slow.len() != n {
|
|
313
|
+
return Err(PyValueError::new_err("close, sma_fast, and sma_slow lengths must match"));
|
|
314
|
+
}
|
|
315
|
+
if n == 0 {
|
|
316
|
+
return Ok(Vec::new());
|
|
317
|
+
}
|
|
318
|
+
Ok(equity_sma_crossover_slice(
|
|
319
|
+
&close,
|
|
320
|
+
&sma_fast,
|
|
321
|
+
&sma_slow,
|
|
322
|
+
initial_balance,
|
|
323
|
+
position_fraction,
|
|
324
|
+
))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Parallel SMA-crossover parameter grid sweep.
|
|
328
|
+
///
|
|
329
|
+
/// Each result row: `[fast, slow, final_equity, total_return, max_drawdown]`.
|
|
330
|
+
/// Only pairs with `fast < slow` are evaluated (rayon over the grid).
|
|
331
|
+
#[pyfunction]
|
|
332
|
+
fn sweep_sma_crossover(
|
|
333
|
+
close: Vec<f64>,
|
|
334
|
+
fast_periods: Vec<usize>,
|
|
335
|
+
slow_periods: Vec<usize>,
|
|
336
|
+
initial_balance: f64,
|
|
337
|
+
position_fraction: f64,
|
|
338
|
+
) -> PyResult<Vec<Vec<f64>>> {
|
|
339
|
+
if close.is_empty() {
|
|
340
|
+
return Ok(Vec::new());
|
|
341
|
+
}
|
|
342
|
+
let pairs = build_param_pairs(&fast_periods, &slow_periods);
|
|
343
|
+
let out: Vec<Vec<f64>> = pairs
|
|
344
|
+
.par_iter()
|
|
345
|
+
.map(|&(fast, slow)| {
|
|
346
|
+
let (final_eq, ret, dd) =
|
|
347
|
+
sma_crossover_metrics(&close, fast, slow, initial_balance, position_fraction);
|
|
348
|
+
vec![fast as f64, slow as f64, final_eq, ret, dd]
|
|
349
|
+
})
|
|
350
|
+
.collect();
|
|
351
|
+
Ok(out)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/// Walk-forward SMA parameter sweep.
|
|
355
|
+
///
|
|
356
|
+
/// `windows` rows are `[train_start, train_end, test_start, test_end]` (end exclusive).
|
|
357
|
+
/// For each window: grid-search on the train slice (max total_return), evaluate best
|
|
358
|
+
/// pair on the test slice. Result row:
|
|
359
|
+
/// `[best_fast, best_slow, train_return, test_return, test_final_equity, test_max_dd]`.
|
|
360
|
+
#[pyfunction]
|
|
361
|
+
fn walk_forward_sma_sweep(
|
|
362
|
+
close: Vec<f64>,
|
|
363
|
+
fast_periods: Vec<usize>,
|
|
364
|
+
slow_periods: Vec<usize>,
|
|
365
|
+
windows: Vec<Vec<usize>>,
|
|
366
|
+
initial_balance: f64,
|
|
367
|
+
position_fraction: f64,
|
|
368
|
+
) -> PyResult<Vec<Vec<f64>>> {
|
|
369
|
+
let n = close.len();
|
|
370
|
+
let pairs = build_param_pairs(&fast_periods, &slow_periods);
|
|
371
|
+
if pairs.is_empty() {
|
|
372
|
+
return Ok(Vec::new());
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let mut parsed: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(windows.len());
|
|
376
|
+
for (i, w) in windows.iter().enumerate() {
|
|
377
|
+
if w.len() != 4 {
|
|
378
|
+
return Err(PyValueError::new_err(format!(
|
|
379
|
+
"window {i} must have 4 indices [train_start, train_end, test_start, test_end]"
|
|
380
|
+
)));
|
|
381
|
+
}
|
|
382
|
+
let (ts, te, xs, xe) = (w[0], w[1], w[2], w[3]);
|
|
383
|
+
if te > n || xe > n || ts >= te || xs >= xe {
|
|
384
|
+
return Err(PyValueError::new_err(format!(
|
|
385
|
+
"window {i} has invalid bounds for series length {n}"
|
|
386
|
+
)));
|
|
387
|
+
}
|
|
388
|
+
parsed.push((ts, te, xs, xe));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let out: Vec<Vec<f64>> = parsed
|
|
392
|
+
.par_iter()
|
|
393
|
+
.map(|&(ts, te, xs, xe)| {
|
|
394
|
+
let train = &close[ts..te];
|
|
395
|
+
let mut best_fast = pairs[0].0;
|
|
396
|
+
let mut best_slow = pairs[0].1;
|
|
397
|
+
let mut best_train_ret = f64::NEG_INFINITY;
|
|
398
|
+
for &(fast, slow) in &pairs {
|
|
399
|
+
let (_eq, ret, _dd) =
|
|
400
|
+
sma_crossover_metrics(train, fast, slow, initial_balance, position_fraction);
|
|
401
|
+
if ret > best_train_ret {
|
|
402
|
+
best_train_ret = ret;
|
|
403
|
+
best_fast = fast;
|
|
404
|
+
best_slow = slow;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
let test = &close[xs..xe];
|
|
408
|
+
let (test_eq, test_ret, test_dd) = sma_crossover_metrics(
|
|
409
|
+
test,
|
|
410
|
+
best_fast,
|
|
411
|
+
best_slow,
|
|
412
|
+
initial_balance,
|
|
413
|
+
position_fraction,
|
|
414
|
+
);
|
|
415
|
+
vec![
|
|
416
|
+
best_fast as f64,
|
|
417
|
+
best_slow as f64,
|
|
418
|
+
best_train_ret,
|
|
419
|
+
test_ret,
|
|
420
|
+
test_eq,
|
|
421
|
+
test_dd,
|
|
422
|
+
]
|
|
423
|
+
})
|
|
424
|
+
.collect();
|
|
425
|
+
Ok(out)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// Multi-symbol SMA-crossover workers + portfolio aggregation.
|
|
429
|
+
///
|
|
430
|
+
/// Runs each symbol equity in parallel, then builds a weighted portfolio equity
|
|
431
|
+
/// (equal weight when `weights` is empty). Lengths must match across symbols.
|
|
432
|
+
///
|
|
433
|
+
/// Returns `(per_symbol_equity, portfolio_equity)`.
|
|
434
|
+
#[pyfunction]
|
|
435
|
+
fn multi_symbol_equity_aggregate(
|
|
436
|
+
closes: Vec<Vec<f64>>,
|
|
437
|
+
fast_period: usize,
|
|
438
|
+
slow_period: usize,
|
|
439
|
+
initial_balance: f64,
|
|
440
|
+
position_fraction: f64,
|
|
441
|
+
weights: Vec<f64>,
|
|
442
|
+
) -> PyResult<(Vec<Vec<f64>>, Vec<f64>)> {
|
|
443
|
+
if closes.is_empty() {
|
|
444
|
+
return Ok((Vec::new(), Vec::new()));
|
|
445
|
+
}
|
|
446
|
+
if fast_period == 0 || slow_period == 0 || fast_period >= slow_period {
|
|
447
|
+
return Err(PyValueError::new_err(
|
|
448
|
+
"require fast_period >= 1, slow_period >= 1, and fast_period < slow_period",
|
|
449
|
+
));
|
|
450
|
+
}
|
|
451
|
+
let n = closes[0].len();
|
|
452
|
+
for (i, c) in closes.iter().enumerate() {
|
|
453
|
+
if c.len() != n {
|
|
454
|
+
return Err(PyValueError::new_err(format!(
|
|
455
|
+
"symbol {i} length {} != first symbol length {n}",
|
|
456
|
+
c.len()
|
|
457
|
+
)));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let m = closes.len();
|
|
462
|
+
let mut w = weights;
|
|
463
|
+
if w.is_empty() {
|
|
464
|
+
w = vec![1.0 / m as f64; m];
|
|
465
|
+
} else if w.len() != m {
|
|
466
|
+
return Err(PyValueError::new_err(
|
|
467
|
+
"weights length must match number of symbols (or be empty for equal weight)",
|
|
468
|
+
));
|
|
469
|
+
}
|
|
470
|
+
let wsum: f64 = w.iter().sum();
|
|
471
|
+
if wsum <= 0.0 || !wsum.is_finite() {
|
|
472
|
+
return Err(PyValueError::new_err("weights must sum to a positive finite value"));
|
|
473
|
+
}
|
|
474
|
+
for wi in &mut w {
|
|
475
|
+
*wi /= wsum;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let per_symbol: Vec<Vec<f64>> = closes
|
|
479
|
+
.par_iter()
|
|
480
|
+
.map(|close| {
|
|
481
|
+
let sma_f = sma_slice(close, fast_period);
|
|
482
|
+
let sma_s = sma_slice(close, slow_period);
|
|
483
|
+
equity_sma_crossover_slice(close, &sma_f, &sma_s, initial_balance, position_fraction)
|
|
484
|
+
})
|
|
485
|
+
.collect();
|
|
486
|
+
|
|
487
|
+
let mut portfolio = vec![0.0_f64; n];
|
|
488
|
+
for (sym_eq, &wi) in per_symbol.iter().zip(w.iter()) {
|
|
489
|
+
for i in 0..n {
|
|
490
|
+
// Normalize each curve to returns vs initial, then re-scale to portfolio capital.
|
|
491
|
+
let ret = if initial_balance != 0.0 {
|
|
492
|
+
(sym_eq[i] / initial_balance) - 1.0
|
|
493
|
+
} else {
|
|
494
|
+
0.0
|
|
495
|
+
};
|
|
496
|
+
portfolio[i] += wi * initial_balance * (1.0 + ret);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
Ok((per_symbol, portfolio))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/// RSI mean-reversion equity: long when RSI crosses up through `oversold`,
|
|
503
|
+
/// short when RSI crosses down through `overbought`.
|
|
504
|
+
fn equity_rsi_mean_reversion_slice(
|
|
505
|
+
close: &[f64],
|
|
506
|
+
rsi: &[f64],
|
|
507
|
+
oversold: f64,
|
|
508
|
+
overbought: f64,
|
|
509
|
+
initial_balance: f64,
|
|
510
|
+
position_fraction: f64,
|
|
511
|
+
) -> Vec<f64> {
|
|
512
|
+
let n = close.len();
|
|
513
|
+
let mut balance = initial_balance;
|
|
514
|
+
let mut side: i8 = 0;
|
|
515
|
+
let mut size = 0.0_f64;
|
|
516
|
+
let mut entry = 0.0_f64;
|
|
517
|
+
let mut equity = Vec::with_capacity(n);
|
|
518
|
+
|
|
519
|
+
for i in 0..n {
|
|
520
|
+
let px = close[i];
|
|
521
|
+
if i >= 1 {
|
|
522
|
+
let r = rsi[i];
|
|
523
|
+
let pr = rsi[i - 1];
|
|
524
|
+
if !r.is_nan() && !pr.is_nan() {
|
|
525
|
+
let cross_up = pr <= oversold && r > oversold;
|
|
526
|
+
let cross_dn = pr >= overbought && r < overbought;
|
|
527
|
+
if cross_up && side <= 0 {
|
|
528
|
+
if side < 0 {
|
|
529
|
+
balance += -size * (px - entry);
|
|
530
|
+
}
|
|
531
|
+
size = (balance * position_fraction) / px;
|
|
532
|
+
entry = px;
|
|
533
|
+
side = 1;
|
|
534
|
+
} else if cross_dn && side >= 0 {
|
|
535
|
+
if side > 0 {
|
|
536
|
+
balance += size * (px - entry);
|
|
537
|
+
}
|
|
538
|
+
size = (balance * position_fraction) / px;
|
|
539
|
+
entry = px;
|
|
540
|
+
side = -1;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
let mtm = if side != 0 {
|
|
545
|
+
side as f64 * size * (px - entry)
|
|
546
|
+
} else {
|
|
547
|
+
0.0
|
|
548
|
+
};
|
|
549
|
+
equity.push(balance + mtm);
|
|
550
|
+
}
|
|
551
|
+
equity
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fn rsi_mean_reversion_metrics(
|
|
555
|
+
close: &[f64],
|
|
556
|
+
period: usize,
|
|
557
|
+
oversold: f64,
|
|
558
|
+
overbought: f64,
|
|
559
|
+
initial_balance: f64,
|
|
560
|
+
position_fraction: f64,
|
|
561
|
+
) -> (f64, f64, f64) {
|
|
562
|
+
if close.is_empty()
|
|
563
|
+
|| period == 0
|
|
564
|
+
|| !(oversold.is_finite() && overbought.is_finite())
|
|
565
|
+
|| oversold >= overbought
|
|
566
|
+
{
|
|
567
|
+
return (initial_balance, 0.0, 0.0);
|
|
568
|
+
}
|
|
569
|
+
let rsi_vals = rsi_slice(close, period);
|
|
570
|
+
let equity = equity_rsi_mean_reversion_slice(
|
|
571
|
+
close,
|
|
572
|
+
&rsi_vals,
|
|
573
|
+
oversold,
|
|
574
|
+
overbought,
|
|
575
|
+
initial_balance,
|
|
576
|
+
position_fraction,
|
|
577
|
+
);
|
|
578
|
+
let final_eq = *equity.last().unwrap_or(&initial_balance);
|
|
579
|
+
let total_return = if initial_balance != 0.0 {
|
|
580
|
+
(final_eq - initial_balance) / initial_balance
|
|
581
|
+
} else {
|
|
582
|
+
0.0
|
|
583
|
+
};
|
|
584
|
+
(final_eq, total_return, max_drawdown(&equity))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/// Compact RSI mean-reversion equity over aligned close/RSI series.
|
|
588
|
+
#[pyfunction]
|
|
589
|
+
fn equity_rsi_mean_reversion(
|
|
590
|
+
close: Vec<f64>,
|
|
591
|
+
rsi: Vec<f64>,
|
|
592
|
+
oversold: f64,
|
|
593
|
+
overbought: f64,
|
|
594
|
+
initial_balance: f64,
|
|
595
|
+
position_fraction: f64,
|
|
596
|
+
) -> PyResult<Vec<f64>> {
|
|
597
|
+
let n = close.len();
|
|
598
|
+
if rsi.len() != n {
|
|
599
|
+
return Err(PyValueError::new_err("close and rsi lengths must match"));
|
|
600
|
+
}
|
|
601
|
+
if !(oversold.is_finite() && overbought.is_finite()) || oversold >= overbought {
|
|
602
|
+
return Err(PyValueError::new_err(
|
|
603
|
+
"require finite oversold < overbought",
|
|
604
|
+
));
|
|
605
|
+
}
|
|
606
|
+
if n == 0 {
|
|
607
|
+
return Ok(Vec::new());
|
|
608
|
+
}
|
|
609
|
+
Ok(equity_rsi_mean_reversion_slice(
|
|
610
|
+
&close,
|
|
611
|
+
&rsi,
|
|
612
|
+
oversold,
|
|
613
|
+
overbought,
|
|
614
|
+
initial_balance,
|
|
615
|
+
position_fraction,
|
|
616
|
+
))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/// Parallel RSI mean-reversion parameter sweep.
|
|
620
|
+
///
|
|
621
|
+
/// Each result row:
|
|
622
|
+
/// `[period, oversold, overbought, final_equity, total_return, max_drawdown]`.
|
|
623
|
+
/// Only triples with `period >= 1` and `oversold < overbought` are evaluated.
|
|
624
|
+
#[pyfunction]
|
|
625
|
+
fn sweep_rsi_mean_reversion(
|
|
626
|
+
close: Vec<f64>,
|
|
627
|
+
rsi_periods: Vec<usize>,
|
|
628
|
+
oversold_levels: Vec<f64>,
|
|
629
|
+
overbought_levels: Vec<f64>,
|
|
630
|
+
initial_balance: f64,
|
|
631
|
+
position_fraction: f64,
|
|
632
|
+
) -> PyResult<Vec<Vec<f64>>> {
|
|
633
|
+
if close.is_empty() {
|
|
634
|
+
return Ok(Vec::new());
|
|
635
|
+
}
|
|
636
|
+
let mut triples: Vec<(usize, f64, f64)> = Vec::new();
|
|
637
|
+
for &period in &rsi_periods {
|
|
638
|
+
if period == 0 {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
for &os in &oversold_levels {
|
|
642
|
+
for &ob in &overbought_levels {
|
|
643
|
+
if os.is_finite() && ob.is_finite() && os < ob {
|
|
644
|
+
triples.push((period, os, ob));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
let out: Vec<Vec<f64>> = triples
|
|
650
|
+
.par_iter()
|
|
651
|
+
.map(|&(period, os, ob)| {
|
|
652
|
+
let (final_eq, ret, dd) = rsi_mean_reversion_metrics(
|
|
653
|
+
&close,
|
|
654
|
+
period,
|
|
655
|
+
os,
|
|
656
|
+
ob,
|
|
657
|
+
initial_balance,
|
|
658
|
+
position_fraction,
|
|
659
|
+
);
|
|
660
|
+
vec![period as f64, os, ob, final_eq, ret, dd]
|
|
661
|
+
})
|
|
662
|
+
.collect();
|
|
663
|
+
Ok(out)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
/// EMA-crossover metrics (final_equity, total_return, max_drawdown).
|
|
668
|
+
fn ema_crossover_metrics(
|
|
669
|
+
close: &[f64],
|
|
670
|
+
fast: usize,
|
|
671
|
+
slow: usize,
|
|
672
|
+
initial_balance: f64,
|
|
673
|
+
position_fraction: f64,
|
|
674
|
+
) -> (f64, f64, f64) {
|
|
675
|
+
if close.is_empty() || fast == 0 || slow == 0 || fast >= slow {
|
|
676
|
+
return (initial_balance, 0.0, 0.0);
|
|
677
|
+
}
|
|
678
|
+
let ema_f = ema_slice(close, fast);
|
|
679
|
+
let ema_s = ema_slice(close, slow);
|
|
680
|
+
let equity = equity_sma_crossover_slice(close, &ema_f, &ema_s, initial_balance, position_fraction);
|
|
681
|
+
let final_eq = *equity.last().unwrap_or(&initial_balance);
|
|
682
|
+
let total_return = if initial_balance != 0.0 {
|
|
683
|
+
(final_eq - initial_balance) / initial_balance
|
|
684
|
+
} else {
|
|
685
|
+
0.0
|
|
686
|
+
};
|
|
687
|
+
(final_eq, total_return, max_drawdown(&equity))
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// MACD vs signal crossover equity (same side logic as SMA crossover).
|
|
691
|
+
fn equity_macd_crossover_slice(
|
|
692
|
+
close: &[f64],
|
|
693
|
+
macd: &[f64],
|
|
694
|
+
signal: &[f64],
|
|
695
|
+
initial_balance: f64,
|
|
696
|
+
position_fraction: f64,
|
|
697
|
+
) -> Vec<f64> {
|
|
698
|
+
equity_sma_crossover_slice(close, macd, signal, initial_balance, position_fraction)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
fn macd_crossover_metrics(
|
|
702
|
+
close: &[f64],
|
|
703
|
+
fast: usize,
|
|
704
|
+
slow: usize,
|
|
705
|
+
signal: usize,
|
|
706
|
+
initial_balance: f64,
|
|
707
|
+
position_fraction: f64,
|
|
708
|
+
) -> (f64, f64, f64) {
|
|
709
|
+
if close.is_empty() {
|
|
710
|
+
return (initial_balance, 0.0, 0.0);
|
|
711
|
+
}
|
|
712
|
+
let Ok((macd, sig, _)) = macd_slices(close, fast, slow, signal) else {
|
|
713
|
+
return (initial_balance, 0.0, 0.0);
|
|
714
|
+
};
|
|
715
|
+
let equity = equity_macd_crossover_slice(
|
|
716
|
+
close,
|
|
717
|
+
&macd,
|
|
718
|
+
&sig,
|
|
719
|
+
initial_balance,
|
|
720
|
+
position_fraction,
|
|
721
|
+
);
|
|
722
|
+
let final_eq = *equity.last().unwrap_or(&initial_balance);
|
|
723
|
+
let total_return = if initial_balance != 0.0 {
|
|
724
|
+
(final_eq - initial_balance) / initial_balance
|
|
725
|
+
} else {
|
|
726
|
+
0.0
|
|
727
|
+
};
|
|
728
|
+
(final_eq, total_return, max_drawdown(&equity))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#[pyfunction]
|
|
732
|
+
fn ema(values: Vec<f64>, period: usize) -> PyResult<Vec<f64>> {
|
|
733
|
+
if period == 0 {
|
|
734
|
+
return Err(PyValueError::new_err("period must be >= 1"));
|
|
735
|
+
}
|
|
736
|
+
Ok(ema_slice(&values, period))
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/// Returns `(macd_line, signal_line, histogram)`.
|
|
740
|
+
#[pyfunction]
|
|
741
|
+
fn macd(
|
|
742
|
+
close: Vec<f64>,
|
|
743
|
+
fast_period: usize,
|
|
744
|
+
slow_period: usize,
|
|
745
|
+
signal_period: usize,
|
|
746
|
+
) -> PyResult<(Vec<f64>, Vec<f64>, Vec<f64>)> {
|
|
747
|
+
macd_slices(&close, fast_period, slow_period, signal_period).map_err(PyValueError::new_err)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/// Compact EMA crossover equity over aligned close/EMA series.
|
|
751
|
+
#[pyfunction]
|
|
752
|
+
fn equity_ema_crossover(
|
|
753
|
+
close: Vec<f64>,
|
|
754
|
+
ema_fast: Vec<f64>,
|
|
755
|
+
ema_slow: Vec<f64>,
|
|
756
|
+
initial_balance: f64,
|
|
757
|
+
position_fraction: f64,
|
|
758
|
+
) -> PyResult<Vec<f64>> {
|
|
759
|
+
equity_sma_crossover(close, ema_fast, ema_slow, initial_balance, position_fraction)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/// Parallel EMA-crossover parameter grid sweep.
|
|
763
|
+
///
|
|
764
|
+
/// Each result row: `[fast, slow, final_equity, total_return, max_drawdown]`.
|
|
765
|
+
#[pyfunction]
|
|
766
|
+
fn sweep_ema_crossover(
|
|
767
|
+
close: Vec<f64>,
|
|
768
|
+
fast_periods: Vec<usize>,
|
|
769
|
+
slow_periods: Vec<usize>,
|
|
770
|
+
initial_balance: f64,
|
|
771
|
+
position_fraction: f64,
|
|
772
|
+
) -> PyResult<Vec<Vec<f64>>> {
|
|
773
|
+
if close.is_empty() {
|
|
774
|
+
return Ok(Vec::new());
|
|
775
|
+
}
|
|
776
|
+
let pairs = build_param_pairs(&fast_periods, &slow_periods);
|
|
777
|
+
let out: Vec<Vec<f64>> = pairs
|
|
778
|
+
.par_iter()
|
|
779
|
+
.map(|&(fast, slow)| {
|
|
780
|
+
let (final_eq, ret, dd) =
|
|
781
|
+
ema_crossover_metrics(&close, fast, slow, initial_balance, position_fraction);
|
|
782
|
+
vec![fast as f64, slow as f64, final_eq, ret, dd]
|
|
783
|
+
})
|
|
784
|
+
.collect();
|
|
785
|
+
Ok(out)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/// Compact MACD vs signal crossover equity.
|
|
789
|
+
#[pyfunction]
|
|
790
|
+
fn equity_macd_crossover(
|
|
791
|
+
close: Vec<f64>,
|
|
792
|
+
macd_line: Vec<f64>,
|
|
793
|
+
signal_line: Vec<f64>,
|
|
794
|
+
initial_balance: f64,
|
|
795
|
+
position_fraction: f64,
|
|
796
|
+
) -> PyResult<Vec<f64>> {
|
|
797
|
+
let n = close.len();
|
|
798
|
+
if macd_line.len() != n || signal_line.len() != n {
|
|
799
|
+
return Err(PyValueError::new_err(
|
|
800
|
+
"close, macd_line, and signal_line lengths must match",
|
|
801
|
+
));
|
|
802
|
+
}
|
|
803
|
+
if n == 0 {
|
|
804
|
+
return Ok(Vec::new());
|
|
805
|
+
}
|
|
806
|
+
Ok(equity_macd_crossover_slice(
|
|
807
|
+
&close,
|
|
808
|
+
&macd_line,
|
|
809
|
+
&signal_line,
|
|
810
|
+
initial_balance,
|
|
811
|
+
position_fraction,
|
|
812
|
+
))
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/// Parallel MACD parameter sweep.
|
|
816
|
+
///
|
|
817
|
+
/// Each result row:
|
|
818
|
+
/// `[fast, slow, signal, final_equity, total_return, max_drawdown]`.
|
|
819
|
+
/// Only triples with `fast < slow` and all periods >= 1 are evaluated.
|
|
820
|
+
#[pyfunction]
|
|
821
|
+
fn sweep_macd(
|
|
822
|
+
close: Vec<f64>,
|
|
823
|
+
fast_periods: Vec<usize>,
|
|
824
|
+
slow_periods: Vec<usize>,
|
|
825
|
+
signal_periods: Vec<usize>,
|
|
826
|
+
initial_balance: f64,
|
|
827
|
+
position_fraction: f64,
|
|
828
|
+
) -> PyResult<Vec<Vec<f64>>> {
|
|
829
|
+
if close.is_empty() {
|
|
830
|
+
return Ok(Vec::new());
|
|
831
|
+
}
|
|
832
|
+
let mut triples: Vec<(usize, usize, usize)> = Vec::new();
|
|
833
|
+
for &fast in &fast_periods {
|
|
834
|
+
for &slow in &slow_periods {
|
|
835
|
+
if fast == 0 || slow == 0 || fast >= slow {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
for &sig in &signal_periods {
|
|
839
|
+
if sig >= 1 {
|
|
840
|
+
triples.push((fast, slow, sig));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
let out: Vec<Vec<f64>> = triples
|
|
846
|
+
.par_iter()
|
|
847
|
+
.map(|&(fast, slow, sig)| {
|
|
848
|
+
let (final_eq, ret, dd) = macd_crossover_metrics(
|
|
849
|
+
&close,
|
|
850
|
+
fast,
|
|
851
|
+
slow,
|
|
852
|
+
sig,
|
|
853
|
+
initial_balance,
|
|
854
|
+
position_fraction,
|
|
855
|
+
);
|
|
856
|
+
vec![fast as f64, slow as f64, sig as f64, final_eq, ret, dd]
|
|
857
|
+
})
|
|
858
|
+
.collect();
|
|
859
|
+
Ok(out)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
#[pymodule]
|
|
863
|
+
fn fsb_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
864
|
+
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
865
|
+
m.add_function(wrap_pyfunction!(sma, m)?)?;
|
|
866
|
+
m.add_function(wrap_pyfunction!(atr, m)?)?;
|
|
867
|
+
m.add_function(wrap_pyfunction!(rsi, m)?)?;
|
|
868
|
+
m.add_function(wrap_pyfunction!(batch_sma, m)?)?;
|
|
869
|
+
m.add_function(wrap_pyfunction!(equity_sma_crossover, m)?)?;
|
|
870
|
+
m.add_function(wrap_pyfunction!(sweep_sma_crossover, m)?)?;
|
|
871
|
+
m.add_function(wrap_pyfunction!(walk_forward_sma_sweep, m)?)?;
|
|
872
|
+
m.add_function(wrap_pyfunction!(multi_symbol_equity_aggregate, m)?)?;
|
|
873
|
+
m.add_function(wrap_pyfunction!(equity_rsi_mean_reversion, m)?)?;
|
|
874
|
+
m.add_function(wrap_pyfunction!(sweep_rsi_mean_reversion, m)?)?;
|
|
875
|
+
m.add_function(wrap_pyfunction!(ema, m)?)?;
|
|
876
|
+
m.add_function(wrap_pyfunction!(macd, m)?)?;
|
|
877
|
+
m.add_function(wrap_pyfunction!(equity_ema_crossover, m)?)?;
|
|
878
|
+
m.add_function(wrap_pyfunction!(sweep_ema_crossover, m)?)?;
|
|
879
|
+
m.add_function(wrap_pyfunction!(equity_macd_crossover, m)?)?;
|
|
880
|
+
m.add_function(wrap_pyfunction!(sweep_macd, m)?)?;
|
|
881
|
+
Ok(())
|
|
882
|
+
}
|