Initial backtesting engine
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug
|
||||
target
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Generated by cargo mutants
|
||||
# Contains mutation testing data
|
||||
**/mutants.out*/
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Python
|
||||
.venv
|
||||
Generated
+828
@@ -0,0 +1,828 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.15.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef41cbb417ea83b30525259e30ccef6af39b31c240bda578889494c5392d331"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"ndarray",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pyo3",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
"numpy",
|
||||
"pyo3",
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "raptorbt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "High-performance Rust backtesting engine for quant5"
|
||||
authors = ["quant5 team"]
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "raptorbt"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.20", features = ["extension-module"] }
|
||||
numpy = "0.20"
|
||||
rayon = "1.8"
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
approx = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "backtest_benchmark"
|
||||
harness = false
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
@@ -0,0 +1,794 @@
|
||||
# RaptorBT
|
||||
|
||||
**RaptorBT** is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It serves as a drop-in replacement for VectorBT, providing significant performance improvements while maintaining full metric parity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Performance](#performance)
|
||||
- [Architecture](#architecture)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Strategy Types](#strategy-types)
|
||||
- [Metrics](#metrics)
|
||||
- [Indicators](#indicators)
|
||||
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
|
||||
- [Python Integration](#python-integration)
|
||||
- [VectorBT Drop-in Replacement](#vectorbt-drop-in-replacement)
|
||||
- [API Reference](#api-reference)
|
||||
- [Building from Source](#building-from-source)
|
||||
- [Testing](#testing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RaptorBT was built to address the performance limitations of VectorBT in production environments:
|
||||
|
||||
| Metric | VectorBT | RaptorBT | Improvement |
|
||||
| ----------------------------- | ------------------- | ------------ | ------------------------- |
|
||||
| **Disk Footprint** | ~450MB | <10MB | **45x smaller** |
|
||||
| **Startup Latency** | 200-600ms | <10ms | **20-60x faster** |
|
||||
| **Backtest Speed (1K bars)** | 1460ms | 0.25ms | **5,800x faster** |
|
||||
| **Backtest Speed (50K bars)** | 43ms | 1.7ms | **25x faster** |
|
||||
| **Memory Usage** | High (JIT + pandas) | Low (native) | **Significant reduction** |
|
||||
|
||||
### Key Features
|
||||
|
||||
- **5 Strategy Types**: Single instrument, basket/collective, pairs trading, options, and multi-strategy
|
||||
- **30+ Metrics**: Full parity with VectorBT including Sharpe, Sortino, Calmar, Omega, SQN, and more
|
||||
- **10 Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend
|
||||
- **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets
|
||||
- **100% Deterministic**: No JIT compilation variance between runs
|
||||
- **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Benchmark Results
|
||||
|
||||
Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy:
|
||||
|
||||
```
|
||||
┌─────────────┬────────────┬───────────┬──────────┐
|
||||
│ Data Size │ VectorBT │ RaptorBT │ Speedup │
|
||||
├─────────────┼────────────┼───────────┼──────────┤
|
||||
│ 1,000 bars │ 1,460 ms │ 0.25 ms │ 5,827x │
|
||||
│ 5,000 bars │ 36 ms │ 0.24 ms │ 153x │
|
||||
│ 10,000 bars │ 37 ms │ 0.46 ms │ 80x │
|
||||
│ 50,000 bars │ 43 ms │ 1.68 ms │ 26x │
|
||||
└─────────────┴────────────┴───────────┴──────────┘
|
||||
```
|
||||
|
||||
> **Note**: First VectorBT run includes Numba JIT compilation overhead. Subsequent runs are faster but still significantly slower than RaptorBT.
|
||||
|
||||
### Metric Accuracy
|
||||
|
||||
RaptorBT produces **identical results** to VectorBT:
|
||||
|
||||
```
|
||||
VectorBT Total Return: 7.2764%
|
||||
RaptorBT Total Return: 7.2764%
|
||||
Difference: 0.0000% ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
raptorbt/
|
||||
├── src/
|
||||
│ ├── core/ # Core types and error handling
|
||||
│ │ ├── types.rs # BacktestConfig, BacktestResult, Trade, Metrics
|
||||
│ │ ├── error.rs # RaptorError enum
|
||||
│ │ └── timeseries.rs # Time series utilities
|
||||
│ │
|
||||
│ ├── strategies/ # Strategy implementations
|
||||
│ │ ├── single.rs # Single instrument backtest
|
||||
│ │ ├── basket.rs # Basket/collective strategies
|
||||
│ │ ├── pairs.rs # Pairs trading
|
||||
│ │ ├── options.rs # Options strategies
|
||||
│ │ └── multi.rs # Multi-strategy combining
|
||||
│ │
|
||||
│ ├── indicators/ # Technical indicators
|
||||
│ │ ├── trend.rs # SMA, EMA, Supertrend
|
||||
│ │ ├── momentum.rs # RSI, MACD, Stochastic
|
||||
│ │ ├── volatility.rs # ATR, Bollinger Bands
|
||||
│ │ ├── strength.rs # ADX
|
||||
│ │ └── volume.rs # VWAP
|
||||
│ │
|
||||
│ ├── metrics/ # Performance metrics
|
||||
│ │ ├── streaming.rs # Streaming metric calculations
|
||||
│ │ ├── drawdown.rs # Drawdown analysis
|
||||
│ │ └── trade_stats.rs # Trade statistics
|
||||
│ │
|
||||
│ ├── signals/ # Signal processing
|
||||
│ │ ├── processor.rs # Entry/exit signal processing
|
||||
│ │ ├── synchronizer.rs # Multi-instrument sync
|
||||
│ │ └── expression.rs # Signal expressions
|
||||
│ │
|
||||
│ ├── stops/ # Stop-loss implementations
|
||||
│ │ ├── fixed.rs # Fixed percentage stops
|
||||
│ │ ├── atr.rs # ATR-based stops
|
||||
│ │ └── trailing.rs # Trailing stops
|
||||
│ │
|
||||
│ ├── python/ # PyO3 bindings
|
||||
│ │ ├── bindings.rs # Python function exports
|
||||
│ │ └── numpy_bridge.rs # NumPy array conversion
|
||||
│ │
|
||||
│ └── lib.rs # Library entry point
|
||||
│
|
||||
├── Cargo.toml # Rust dependencies
|
||||
└── pyproject.toml # Python package config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From Pre-built Wheel
|
||||
|
||||
```bash
|
||||
pip install raptorbt
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```python
|
||||
import raptorbt
|
||||
print("RaptorBT installed successfully!")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Single Instrument Backtest
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import raptorbt
|
||||
|
||||
# Prepare data
|
||||
df = pd.read_csv("your_data.csv", index_col=0, parse_dates=True)
|
||||
|
||||
# Generate signals (SMA crossover example)
|
||||
sma_fast = df['close'].rolling(10).mean()
|
||||
sma_slow = df['close'].rolling(20).mean()
|
||||
entries = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1))
|
||||
exits = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1))
|
||||
|
||||
# Configure backtest
|
||||
config = raptorbt.PyBacktestConfig(
|
||||
initial_capital=100000,
|
||||
fees=0.001, # 0.1% per trade
|
||||
slippage=0.0005, # 0.05% slippage
|
||||
upon_bar_close=True
|
||||
)
|
||||
|
||||
# Optional: Add stop-loss
|
||||
config.set_fixed_stop(0.02) # 2% stop-loss
|
||||
|
||||
# Optional: Add take-profit
|
||||
config.set_fixed_target(0.04) # 4% take-profit
|
||||
|
||||
# Run backtest
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=df.index.astype('int64').values,
|
||||
open=df['open'].values,
|
||||
high=df['high'].values,
|
||||
low=df['low'].values,
|
||||
close=df['close'].values,
|
||||
volume=df['volume'].values,
|
||||
entries=entries.values,
|
||||
exits=exits.values,
|
||||
direction=1, # 1 = Long, -1 = Short
|
||||
weight=1.0,
|
||||
symbol="AAPL",
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Access results
|
||||
print(f"Total Return: {result.metrics.total_return_pct:.2f}%")
|
||||
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.2f}")
|
||||
print(f"Max Drawdown: {result.metrics.max_drawdown_pct:.2f}%")
|
||||
print(f"Win Rate: {result.metrics.win_rate_pct:.2f}%")
|
||||
print(f"Total Trades: {result.metrics.total_trades}")
|
||||
|
||||
# Get equity curve
|
||||
equity = result.equity_curve() # Returns numpy array
|
||||
|
||||
# Get trades
|
||||
trades = result.trades() # Returns list of PyTrade objects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy Types
|
||||
|
||||
### 1. Single Instrument
|
||||
|
||||
Basic long or short strategy on a single instrument.
|
||||
|
||||
```python
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=timestamps,
|
||||
open=open_prices, high=high_prices, low=low_prices,
|
||||
close=close_prices, volume=volume,
|
||||
entries=entries, exits=exits,
|
||||
direction=1, # 1=Long, -1=Short
|
||||
weight=1.0,
|
||||
symbol="SYMBOL",
|
||||
config=config,
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Basket/Collective
|
||||
|
||||
Trade multiple instruments with synchronized signals.
|
||||
|
||||
```python
|
||||
instruments = [
|
||||
(timestamps, open1, high1, low1, close1, volume1, entries1, exits1, 1, 0.33, "AAPL"),
|
||||
(timestamps, open2, high2, low2, close2, volume2, entries2, exits2, 1, 0.33, "GOOGL"),
|
||||
(timestamps, open3, high3, low3, close3, volume3, entries3, exits3, 1, 0.34, "MSFT"),
|
||||
]
|
||||
|
||||
result = raptorbt.run_basket_backtest(
|
||||
instruments=instruments,
|
||||
config=config,
|
||||
sync_mode="all", # "all", "any", "majority", "master"
|
||||
)
|
||||
```
|
||||
|
||||
**Sync Modes:**
|
||||
|
||||
- `all`: Enter only when ALL instruments signal
|
||||
- `any`: Enter when ANY instrument signals
|
||||
- `majority`: Enter when >50% of instruments signal
|
||||
- `master`: Follow the first instrument's signals
|
||||
|
||||
### 3. Pairs Trading
|
||||
|
||||
Long one instrument, short another with optional hedge ratio.
|
||||
|
||||
```python
|
||||
result = raptorbt.run_pairs_backtest(
|
||||
# Long leg
|
||||
leg1_timestamps=timestamps,
|
||||
leg1_open=long_open, leg1_high=long_high,
|
||||
leg1_low=long_low, leg1_close=long_close,
|
||||
leg1_volume=long_volume,
|
||||
# Short leg
|
||||
leg2_timestamps=timestamps,
|
||||
leg2_open=short_open, leg2_high=short_high,
|
||||
leg2_low=short_low, leg2_close=short_close,
|
||||
leg2_volume=short_volume,
|
||||
# Signals
|
||||
entries=entries, exits=exits,
|
||||
direction=1,
|
||||
symbol="TCS_INFY",
|
||||
config=config,
|
||||
hedge_ratio=1.5, # Short 1.5x the long position
|
||||
dynamic_hedge=False, # Use rolling hedge ratio
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Options
|
||||
|
||||
Backtest options strategies with strike selection.
|
||||
|
||||
```python
|
||||
result = raptorbt.run_options_backtest(
|
||||
timestamps=timestamps,
|
||||
open=underlying_open, high=underlying_high,
|
||||
low=underlying_low, close=underlying_close,
|
||||
volume=volume,
|
||||
option_prices=option_prices, # Option premium series
|
||||
entries=entries, exits=exits,
|
||||
direction=1,
|
||||
symbol="NIFTY_CE",
|
||||
config=config,
|
||||
option_type="call", # "call" or "put"
|
||||
strike_selection="atm", # "atm", "otm1", "otm2", "itm1", "itm2"
|
||||
size_type="percent", # "percent", "contracts", "notional", "risk"
|
||||
size_value=0.1, # 10% of capital
|
||||
lot_size=50, # Options lot size
|
||||
strike_interval=50.0, # Strike interval (e.g., 50 for NIFTY)
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Multi-Strategy
|
||||
|
||||
Combine multiple strategies on the same instrument.
|
||||
|
||||
```python
|
||||
strategies = [
|
||||
(entries_sma, exits_sma, 1, 0.4, "SMA_Crossover"), # 40% weight
|
||||
(entries_rsi, exits_rsi, 1, 0.35, "RSI_MeanRev"), # 35% weight
|
||||
(entries_bb, exits_bb, 1, 0.25, "BB_Breakout"), # 25% weight
|
||||
]
|
||||
|
||||
result = raptorbt.run_multi_backtest(
|
||||
timestamps=timestamps,
|
||||
open=open_prices, high=high_prices,
|
||||
low=low_prices, close=close_prices,
|
||||
volume=volume,
|
||||
strategies=strategies,
|
||||
config=config,
|
||||
combine_mode="any", # "any", "all", "majority", "weighted", "independent"
|
||||
)
|
||||
```
|
||||
|
||||
**Combine Modes:**
|
||||
|
||||
- `any`: Enter when any strategy signals
|
||||
- `all`: Enter only when all strategies signal
|
||||
- `majority`: Enter when >50% of strategies signal
|
||||
- `weighted`: Weight signals by strategy weight
|
||||
- `independent`: Run strategies independently (aggregate PnL)
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
RaptorBT calculates 30+ performance metrics:
|
||||
|
||||
### Core Performance
|
||||
|
||||
| Metric | Description |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `total_return_pct` | Total return as percentage |
|
||||
| `sharpe_ratio` | Risk-adjusted return (annualized) |
|
||||
| `sortino_ratio` | Downside risk-adjusted return |
|
||||
| `calmar_ratio` | Return / Max Drawdown |
|
||||
| `omega_ratio` | Probability-weighted gains/losses |
|
||||
|
||||
### Drawdown
|
||||
|
||||
| Metric | Description |
|
||||
| ----------------------- | ------------------------------ |
|
||||
| `max_drawdown_pct` | Maximum peak-to-trough decline |
|
||||
| `max_drawdown_duration` | Longest drawdown period (bars) |
|
||||
|
||||
### Trade Statistics
|
||||
|
||||
| Metric | Description |
|
||||
| --------------------- | ---------------------------- |
|
||||
| `total_trades` | Total number of trades |
|
||||
| `total_closed_trades` | Number of closed trades |
|
||||
| `total_open_trades` | Number of open positions |
|
||||
| `winning_trades` | Number of profitable trades |
|
||||
| `losing_trades` | Number of losing trades |
|
||||
| `win_rate_pct` | Percentage of winning trades |
|
||||
|
||||
### Trade Performance
|
||||
|
||||
| Metric | Description |
|
||||
| ---------------------- | --------------------------------- |
|
||||
| `profit_factor` | Gross profit / Gross loss |
|
||||
| `expectancy` | Average expected profit per trade |
|
||||
| `sqn` | System Quality Number |
|
||||
| `avg_trade_return_pct` | Average trade return |
|
||||
| `avg_win_pct` | Average winning trade return |
|
||||
| `avg_loss_pct` | Average losing trade return |
|
||||
| `best_trade_pct` | Best single trade return |
|
||||
| `worst_trade_pct` | Worst single trade return |
|
||||
|
||||
### Duration
|
||||
|
||||
| Metric | Description |
|
||||
| ---------------------- | ------------------------------ |
|
||||
| `avg_holding_period` | Average trade duration (bars) |
|
||||
| `avg_winning_duration` | Average winning trade duration |
|
||||
| `avg_losing_duration` | Average losing trade duration |
|
||||
|
||||
### Streaks
|
||||
|
||||
| Metric | Description |
|
||||
| ------------------------ | ---------------------- |
|
||||
| `max_consecutive_wins` | Longest winning streak |
|
||||
| `max_consecutive_losses` | Longest losing streak |
|
||||
|
||||
### Other
|
||||
|
||||
| Metric | Description |
|
||||
| ----------------- | ---------------------------------- |
|
||||
| `start_value` | Initial portfolio value |
|
||||
| `end_value` | Final portfolio value |
|
||||
| `total_fees_paid` | Total transaction costs |
|
||||
| `open_trade_pnl` | Unrealized PnL from open positions |
|
||||
| `exposure_pct` | Percentage of time in market |
|
||||
|
||||
---
|
||||
|
||||
## Indicators
|
||||
|
||||
RaptorBT includes optimized technical indicators:
|
||||
|
||||
```python
|
||||
import raptorbt
|
||||
|
||||
# Trend indicators
|
||||
sma = raptorbt.sma(close, period=20)
|
||||
ema = raptorbt.ema(close, period=20)
|
||||
supertrend, direction = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0)
|
||||
|
||||
# Momentum indicators
|
||||
rsi = raptorbt.rsi(close, period=14)
|
||||
macd_line, signal_line, histogram = raptorbt.macd(close, fast=12, slow=26, signal=9)
|
||||
stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3)
|
||||
|
||||
# Volatility indicators
|
||||
atr = raptorbt.atr(high, low, close, period=14)
|
||||
upper, middle, lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0)
|
||||
|
||||
# Strength indicators
|
||||
adx = raptorbt.adx(high, low, close, period=14)
|
||||
|
||||
# Volume indicators
|
||||
vwap = raptorbt.vwap(high, low, close, volume)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stop-Loss & Take-Profit
|
||||
|
||||
### Fixed Percentage
|
||||
|
||||
```python
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
config.set_fixed_stop(0.02) # 2% stop-loss
|
||||
config.set_fixed_target(0.04) # 4% take-profit
|
||||
```
|
||||
|
||||
### ATR-Based
|
||||
|
||||
```python
|
||||
config.set_atr_stop(multiplier=2.0, period=14) # 2x ATR stop
|
||||
config.set_atr_target(multiplier=3.0, period=14) # 3x ATR target
|
||||
```
|
||||
|
||||
### Trailing Stop
|
||||
|
||||
```python
|
||||
config.set_trailing_stop(0.02) # 2% trailing stop
|
||||
```
|
||||
|
||||
### Risk-Reward Target
|
||||
|
||||
```python
|
||||
config.set_risk_reward_target(ratio=2.0) # 2:1 risk-reward ratio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python Integration
|
||||
|
||||
RaptorBT integrates seamlessly with the quant5 golf runner through `rpbt.py`.
|
||||
|
||||
### Enable RaptorBT
|
||||
|
||||
```bash
|
||||
export USE_RAPTORBT=1
|
||||
```
|
||||
|
||||
Or in Python:
|
||||
|
||||
```python
|
||||
import os
|
||||
os.environ["USE_RAPTORBT"] = "1"
|
||||
```
|
||||
|
||||
### Integration Functions
|
||||
|
||||
```python
|
||||
from app.engine.golf.rpbt import (
|
||||
is_raptorbt_enabled,
|
||||
RaptorBTConfig,
|
||||
RaptorBTPortfolioWrapper,
|
||||
run_single_backtest_raptorbt,
|
||||
run_basket_backtest_raptorbt,
|
||||
run_pairs_backtest_raptorbt,
|
||||
run_options_backtest_raptorbt,
|
||||
run_multi_backtest_raptorbt,
|
||||
)
|
||||
|
||||
# Check if RaptorBT is enabled
|
||||
if is_raptorbt_enabled():
|
||||
print("Using RaptorBT backend")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VectorBT Drop-in Replacement
|
||||
|
||||
RaptorBT provides a `RaptorBTPortfolioWrapper` that mimics the VectorBT Portfolio interface:
|
||||
|
||||
```python
|
||||
from app.engine.golf.rpbt import (
|
||||
RaptorBTPortfolioWrapper,
|
||||
run_single_backtest_raptorbt,
|
||||
RaptorBTConfig,
|
||||
)
|
||||
|
||||
# Run backtest
|
||||
result = run_single_backtest_raptorbt(compiled, ohlcv_df, config, symbol)
|
||||
|
||||
# Wrap result for VectorBT compatibility
|
||||
portfolio = RaptorBTPortfolioWrapper(result)
|
||||
|
||||
# Use like VectorBT Portfolio
|
||||
stats = portfolio.stats() # Returns pd.Series with VectorBT-format keys
|
||||
equity = portfolio.value() # Returns equity curve as pd.Series
|
||||
dd = portfolio.drawdown() # Returns drawdown curve as pd.Series
|
||||
trades_df = portfolio.trades() # Returns trades as pd.DataFrame
|
||||
|
||||
# Access properties
|
||||
print(portfolio.total_return) # Total return percentage
|
||||
print(portfolio.sharpe_ratio) # Sharpe ratio
|
||||
print(portfolio.max_drawdown) # Max drawdown percentage
|
||||
print(portfolio.win_rate) # Win rate percentage
|
||||
print(portfolio.profit_factor) # Profit factor
|
||||
print(portfolio.sqn) # System Quality Number
|
||||
print(portfolio.expectancy) # Expected value per trade
|
||||
print(portfolio.omega_ratio) # Omega ratio
|
||||
```
|
||||
|
||||
### Stats Format
|
||||
|
||||
The `stats()` method returns a pandas Series with VectorBT-compatible keys:
|
||||
|
||||
```python
|
||||
stats = portfolio.stats()
|
||||
print(stats["Total Return [%]"])
|
||||
print(stats["Sharpe Ratio"])
|
||||
print(stats["Max Drawdown [%]"])
|
||||
print(stats["Win Rate [%]"])
|
||||
print(stats["Profit Factor"])
|
||||
print(stats["SQN"])
|
||||
print(stats["Omega Ratio"])
|
||||
# ... and 20+ more metrics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### PyBacktestConfig
|
||||
|
||||
```python
|
||||
config = raptorbt.PyBacktestConfig(
|
||||
initial_capital: float = 100000.0,
|
||||
fees: float = 0.001,
|
||||
slippage: float = 0.0,
|
||||
upon_bar_close: bool = True,
|
||||
)
|
||||
|
||||
# Stop methods
|
||||
config.set_fixed_stop(percent: float)
|
||||
config.set_atr_stop(multiplier: float, period: int)
|
||||
config.set_trailing_stop(percent: float)
|
||||
|
||||
# Target methods
|
||||
config.set_fixed_target(percent: float)
|
||||
config.set_atr_target(multiplier: float, period: int)
|
||||
config.set_risk_reward_target(ratio: float)
|
||||
```
|
||||
|
||||
### PyBacktestResult
|
||||
|
||||
```python
|
||||
result = raptorbt.run_single_backtest(...)
|
||||
|
||||
# Attributes
|
||||
result.metrics # PyBacktestMetrics object
|
||||
|
||||
# Methods
|
||||
result.equity_curve() # numpy.ndarray
|
||||
result.drawdown_curve() # numpy.ndarray
|
||||
result.returns() # numpy.ndarray
|
||||
result.trades() # List[PyTrade]
|
||||
```
|
||||
|
||||
### PyBacktestMetrics
|
||||
|
||||
```python
|
||||
metrics = result.metrics
|
||||
|
||||
# All available metrics
|
||||
metrics.total_return_pct
|
||||
metrics.sharpe_ratio
|
||||
metrics.sortino_ratio
|
||||
metrics.calmar_ratio
|
||||
metrics.omega_ratio
|
||||
metrics.max_drawdown_pct
|
||||
metrics.max_drawdown_duration
|
||||
metrics.win_rate_pct
|
||||
metrics.profit_factor
|
||||
metrics.expectancy
|
||||
metrics.sqn
|
||||
metrics.total_trades
|
||||
metrics.total_closed_trades
|
||||
metrics.total_open_trades
|
||||
metrics.winning_trades
|
||||
metrics.losing_trades
|
||||
metrics.start_value
|
||||
metrics.end_value
|
||||
metrics.total_fees_paid
|
||||
metrics.best_trade_pct
|
||||
metrics.worst_trade_pct
|
||||
metrics.avg_trade_return_pct
|
||||
metrics.avg_win_pct
|
||||
metrics.avg_loss_pct
|
||||
metrics.avg_holding_period
|
||||
metrics.avg_winning_duration
|
||||
metrics.avg_losing_duration
|
||||
metrics.max_consecutive_wins
|
||||
metrics.max_consecutive_losses
|
||||
metrics.exposure_pct
|
||||
metrics.open_trade_pnl
|
||||
|
||||
# Convert to dictionary (VectorBT format)
|
||||
stats_dict = metrics.to_dict()
|
||||
```
|
||||
|
||||
### PyTrade
|
||||
|
||||
```python
|
||||
for trade in result.trades():
|
||||
print(trade.id) # Trade ID
|
||||
print(trade.symbol) # Symbol
|
||||
print(trade.entry_idx) # Entry bar index
|
||||
print(trade.exit_idx) # Exit bar index
|
||||
print(trade.entry_price) # Entry price
|
||||
print(trade.exit_price) # Exit price
|
||||
print(trade.size) # Position size
|
||||
print(trade.direction) # 1=Long, -1=Short
|
||||
print(trade.pnl) # Profit/Loss
|
||||
print(trade.return_pct) # Return percentage
|
||||
print(trade.fees) # Fees paid
|
||||
print(trade.exit_reason) # "Signal", "StopLoss", "TakeProfit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.70+ (install via [rustup](https://rustup.rs/))
|
||||
- Python 3.10+
|
||||
- maturin (`pip install maturin`)
|
||||
|
||||
### Development Build
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin build --release
|
||||
pip install target/wheels/raptorbt-*.whl
|
||||
```
|
||||
|
||||
### Using the Build Script
|
||||
|
||||
```bash
|
||||
./scripts/build-engine.sh --install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Rust Unit Tests
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Python Integration Tests
|
||||
|
||||
```bash
|
||||
# Test basic functionality
|
||||
uv run python -c "
|
||||
import raptorbt
|
||||
import numpy as np
|
||||
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=np.arange(100, dtype=np.int64),
|
||||
open=np.random.randn(100).cumsum() + 100,
|
||||
high=np.random.randn(100).cumsum() + 101,
|
||||
low=np.random.randn(100).cumsum() + 99,
|
||||
close=np.random.randn(100).cumsum() + 100,
|
||||
volume=np.ones(100),
|
||||
entries=np.array([i % 20 == 0 for i in range(100)]),
|
||||
exits=np.array([i % 20 == 10 for i in range(100)]),
|
||||
direction=1,
|
||||
weight=1.0,
|
||||
symbol='TEST',
|
||||
config=config,
|
||||
)
|
||||
print(f'Total Return: {result.metrics.total_return_pct:.2f}%')
|
||||
print('RaptorBT is working correctly!')
|
||||
"
|
||||
```
|
||||
|
||||
### Comparison Test (VectorBT vs RaptorBT)
|
||||
|
||||
```bash
|
||||
USE_RAPTORBT=1 uv run python << 'EOF'
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import vectorbt as vbt
|
||||
import raptorbt
|
||||
|
||||
# Create test data
|
||||
np.random.seed(42)
|
||||
n = 500
|
||||
dates = pd.date_range('2023-01-01', periods=n, freq='D')
|
||||
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
|
||||
entries = np.zeros(n, dtype=bool)
|
||||
exits = np.zeros(n, dtype=bool)
|
||||
entries[::20] = True
|
||||
exits[10::20] = True
|
||||
|
||||
# VectorBT
|
||||
pf = vbt.Portfolio.from_signals(
|
||||
close=pd.Series(close, index=dates),
|
||||
entries=pd.Series(entries, index=dates),
|
||||
exits=pd.Series(exits, index=dates),
|
||||
init_cash=100000, fees=0.001
|
||||
)
|
||||
|
||||
# RaptorBT
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=dates.astype('int64').values,
|
||||
open=close, high=close, low=close, close=close,
|
||||
volume=np.ones(n), entries=entries, exits=exits,
|
||||
direction=1, weight=1.0, symbol="TEST", config=config
|
||||
)
|
||||
|
||||
print(f"VectorBT: {pf.stats()['Total Return [%]']:.4f}%")
|
||||
print(f"RaptorBT: {result.metrics.total_return_pct:.4f}%")
|
||||
print(f"Match: {abs(pf.stats()['Total Return [%]'] - result.metrics.total_return_pct) < 0.01}")
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
RaptorBT is proprietary software developed for the quant5 platform.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.0 (2024-01)
|
||||
|
||||
- Initial release
|
||||
- 5 strategy types: single, basket, pairs, options, multi
|
||||
- 30+ performance metrics
|
||||
- 10 technical indicators
|
||||
- Fixed, ATR, and trailing stops
|
||||
- PyO3 Python bindings
|
||||
- VectorBT-compatible wrapper
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Benchmark for RaptorBT backtesting performance.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use raptorbt::core::types::{BacktestConfig, CompiledSignals, Direction, OhlcvData};
|
||||
use raptorbt::indicators::trend::{ema, sma};
|
||||
use raptorbt::portfolio::engine::PortfolioEngine;
|
||||
|
||||
/// Generate sample OHLCV data.
|
||||
fn generate_sample_data(n: usize) -> OhlcvData {
|
||||
let mut open = vec![100.0; n];
|
||||
let mut high = vec![101.0; n];
|
||||
let mut low = vec![99.0; n];
|
||||
let mut close = vec![100.0; n];
|
||||
|
||||
// Create a trending pattern
|
||||
for i in 1..n {
|
||||
let change = (i as f64 * 0.1).sin() * 2.0;
|
||||
close[i] = close[i - 1] + change;
|
||||
open[i] = close[i - 1];
|
||||
high[i] = close[i].max(open[i]) + 1.0;
|
||||
low[i] = close[i].min(open[i]) - 1.0;
|
||||
}
|
||||
|
||||
OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume: vec![1000.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate sample trading signals based on SMA crossover.
|
||||
fn generate_sample_signals(
|
||||
close: &[f64],
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
) -> CompiledSignals {
|
||||
let n = close.len();
|
||||
let fast_sma = sma(close, fast_period).unwrap_or_else(|_| vec![0.0; n]);
|
||||
let slow_sma = sma(close, slow_period).unwrap_or_else(|_| vec![0.0; n]);
|
||||
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
|
||||
for i in 1..n {
|
||||
// Entry: fast crosses above slow
|
||||
if fast_sma[i] > slow_sma[i] && fast_sma[i - 1] <= slow_sma[i - 1] {
|
||||
entries[i] = true;
|
||||
}
|
||||
// Exit: fast crosses below slow
|
||||
if fast_sma[i] < slow_sma[i] && fast_sma[i - 1] >= slow_sma[i - 1] {
|
||||
exits[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
CompiledSignals {
|
||||
symbol: "BENCH".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_single_backtest(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("single_backtest");
|
||||
|
||||
for size in [1000, 5000, 10000, 50000].iter() {
|
||||
group.bench_with_input(BenchmarkId::new("bars", size), size, |b, &size| {
|
||||
let ohlcv = generate_sample_data(size);
|
||||
let signals = generate_sample_signals(&ohlcv.close, 10, 30);
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
|
||||
b.iter(|| {
|
||||
let result = engine.run_single(black_box(&ohlcv), black_box(&signals));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_sma(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("sma");
|
||||
|
||||
for size in [1000, 5000, 10000, 50000].iter() {
|
||||
group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| {
|
||||
let ohlcv = generate_sample_data(size);
|
||||
|
||||
b.iter(|| {
|
||||
let result = sma(black_box(&ohlcv.close), black_box(20));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ema(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ema");
|
||||
|
||||
for size in [1000, 5000, 10000, 50000].iter() {
|
||||
group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| {
|
||||
let ohlcv = generate_sample_data(size);
|
||||
|
||||
b.iter(|| {
|
||||
let result = ema(black_box(&ohlcv.close), black_box(20));
|
||||
black_box(result)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_single_backtest, bench_sma, bench_ema);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.4,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "raptorbt"
|
||||
version = "0.1.0"
|
||||
description = "High-performance Rust backtesting engine for quant5"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
[tool.maturin]
|
||||
features = ["pyo3/extension-module"]
|
||||
python-source = "python"
|
||||
module-name = "raptorbt._raptorbt"
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
RaptorBT - High-performance Rust backtesting engine for quant5.
|
||||
|
||||
This module provides Python bindings for the Rust-based backtesting engine,
|
||||
offering significant performance improvements over vectorbt:
|
||||
- Disk footprint: <10MB (vs vectorbt's ~450MB)
|
||||
- Startup latency: <10ms (vs 200-600ms)
|
||||
- 100% deterministic execution (no JIT cache)
|
||||
- Native parallelism via Rayon + explicit SIMD
|
||||
"""
|
||||
|
||||
from raptorbt._raptorbt import (
|
||||
# Config classes
|
||||
PyBacktestConfig,
|
||||
PyStopConfig,
|
||||
PyTargetConfig,
|
||||
# Result classes
|
||||
PyBacktestResult,
|
||||
PyBacktestMetrics,
|
||||
PyTrade,
|
||||
# Backtest functions
|
||||
run_single_backtest,
|
||||
run_basket_backtest,
|
||||
run_options_backtest,
|
||||
run_pairs_backtest,
|
||||
run_multi_backtest,
|
||||
# Indicator functions
|
||||
sma,
|
||||
ema,
|
||||
rsi,
|
||||
macd,
|
||||
stochastic,
|
||||
atr,
|
||||
bollinger_bands,
|
||||
adx,
|
||||
vwap,
|
||||
supertrend,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Config classes
|
||||
"PyBacktestConfig",
|
||||
"PyStopConfig",
|
||||
"PyTargetConfig",
|
||||
# Result classes
|
||||
"PyBacktestResult",
|
||||
"PyBacktestMetrics",
|
||||
"PyTrade",
|
||||
# Backtest functions
|
||||
"run_single_backtest",
|
||||
"run_basket_backtest",
|
||||
"run_options_backtest",
|
||||
"run_pairs_backtest",
|
||||
"run_multi_backtest",
|
||||
# Indicator functions
|
||||
"sma",
|
||||
"ema",
|
||||
"rsi",
|
||||
"macd",
|
||||
"stochastic",
|
||||
"atr",
|
||||
"bollinger_bands",
|
||||
"adx",
|
||||
"vwap",
|
||||
"supertrend",
|
||||
]
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Error types for RaptorBT.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type alias for RaptorBT operations.
|
||||
pub type Result<T> = std::result::Result<T, RaptorError>;
|
||||
|
||||
/// Error types for the backtesting engine.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RaptorError {
|
||||
/// Data length mismatch between arrays.
|
||||
#[error("Data length mismatch: expected {expected}, got {actual}")]
|
||||
LengthMismatch { expected: usize, actual: usize },
|
||||
|
||||
/// Invalid parameter value.
|
||||
#[error("Invalid parameter: {message}")]
|
||||
InvalidParameter { message: String },
|
||||
|
||||
/// Insufficient data for calculation.
|
||||
#[error("Insufficient data: need at least {required} elements, got {available}")]
|
||||
InsufficientData { required: usize, available: usize },
|
||||
|
||||
/// Invalid configuration.
|
||||
#[error("Invalid configuration: {message}")]
|
||||
InvalidConfig { message: String },
|
||||
|
||||
/// Division by zero error.
|
||||
#[error("Division by zero in {context}")]
|
||||
DivisionByZero { context: String },
|
||||
|
||||
/// Empty data error.
|
||||
#[error("Empty data provided for {context}")]
|
||||
EmptyData { context: String },
|
||||
|
||||
/// Invalid index access.
|
||||
#[error("Index {index} out of bounds for length {length}")]
|
||||
IndexOutOfBounds { index: usize, length: usize },
|
||||
|
||||
/// Python conversion error.
|
||||
#[error("Python conversion error: {message}")]
|
||||
PythonError { message: String },
|
||||
}
|
||||
|
||||
impl RaptorError {
|
||||
/// Create a length mismatch error.
|
||||
pub fn length_mismatch(expected: usize, actual: usize) -> Self {
|
||||
Self::LengthMismatch { expected, actual }
|
||||
}
|
||||
|
||||
/// Create an invalid parameter error.
|
||||
pub fn invalid_parameter(message: impl Into<String>) -> Self {
|
||||
Self::InvalidParameter {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an insufficient data error.
|
||||
pub fn insufficient_data(required: usize, available: usize) -> Self {
|
||||
Self::InsufficientData {
|
||||
required,
|
||||
available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invalid config error.
|
||||
pub fn invalid_config(message: impl Into<String>) -> Self {
|
||||
Self::InvalidConfig {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a division by zero error.
|
||||
pub fn division_by_zero(context: impl Into<String>) -> Self {
|
||||
Self::DivisionByZero {
|
||||
context: context.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty data error.
|
||||
pub fn empty_data(context: impl Into<String>) -> Self {
|
||||
Self::EmptyData {
|
||||
context: context.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RaptorError> for pyo3::PyErr {
|
||||
fn from(err: RaptorError) -> pyo3::PyErr {
|
||||
pyo3::exceptions::PyValueError::new_err(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Core types and utilities for RaptorBT.
|
||||
|
||||
pub mod error;
|
||||
pub mod timeseries;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{RaptorError, Result};
|
||||
pub use timeseries::TimeSeries;
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Time-indexed array wrapper for efficient operations.
|
||||
|
||||
use super::types::Timestamp;
|
||||
|
||||
/// A time-indexed series of values.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeSeries<T> {
|
||||
/// Timestamps for each value.
|
||||
pub timestamps: Vec<Timestamp>,
|
||||
/// Values.
|
||||
pub values: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone> TimeSeries<T> {
|
||||
/// Create a new time series.
|
||||
pub fn new(timestamps: Vec<Timestamp>, values: Vec<T>) -> Self {
|
||||
debug_assert_eq!(timestamps.len(), values.len());
|
||||
Self { timestamps, values }
|
||||
}
|
||||
|
||||
/// Create from values only (no timestamps).
|
||||
pub fn from_values(values: Vec<T>) -> Self {
|
||||
let timestamps = (0..values.len() as i64).collect();
|
||||
Self { timestamps, values }
|
||||
}
|
||||
|
||||
/// Get the length.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
/// Check if empty.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
|
||||
/// Get value at index.
|
||||
#[inline]
|
||||
pub fn get(&self, index: usize) -> Option<&T> {
|
||||
self.values.get(index)
|
||||
}
|
||||
|
||||
/// Get timestamp at index.
|
||||
#[inline]
|
||||
pub fn get_timestamp(&self, index: usize) -> Option<Timestamp> {
|
||||
self.timestamps.get(index).copied()
|
||||
}
|
||||
|
||||
/// Get slice of values.
|
||||
pub fn slice(&self, start: usize, end: usize) -> Self {
|
||||
Self {
|
||||
timestamps: self.timestamps[start..end].to_vec(),
|
||||
values: self.values[start..end].to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map values to a new type.
|
||||
pub fn map<U, F>(&self, f: F) -> TimeSeries<U>
|
||||
where
|
||||
F: Fn(&T) -> U,
|
||||
{
|
||||
TimeSeries {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: self.values.iter().map(f).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over (timestamp, value) pairs.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Timestamp, &T)> {
|
||||
self.timestamps.iter().copied().zip(self.values.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + Default> TimeSeries<T> {
|
||||
/// Create with default values.
|
||||
pub fn with_default(timestamps: Vec<Timestamp>) -> Self {
|
||||
let len = timestamps.len();
|
||||
Self {
|
||||
timestamps,
|
||||
values: vec![T::default(); len],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeSeries<f64> {
|
||||
/// Create a series filled with NaN.
|
||||
pub fn with_nan(len: usize) -> Self {
|
||||
Self {
|
||||
timestamps: (0..len as i64).collect(),
|
||||
values: vec![f64::NAN; len],
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate sum of all values.
|
||||
pub fn sum(&self) -> f64 {
|
||||
self.values.iter().filter(|v| !v.is_nan()).sum()
|
||||
}
|
||||
|
||||
/// Calculate mean of all values.
|
||||
pub fn mean(&self) -> f64 {
|
||||
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
|
||||
if valid.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
valid.iter().copied().sum::<f64>() / valid.len() as f64
|
||||
}
|
||||
|
||||
/// Calculate standard deviation.
|
||||
pub fn std(&self) -> f64 {
|
||||
let mean = self.mean();
|
||||
if mean.is_nan() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
|
||||
if valid.len() < 2 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let variance =
|
||||
valid.iter().map(|v| (*v - mean).powi(2)).sum::<f64>() / (valid.len() - 1) as f64;
|
||||
variance.sqrt()
|
||||
}
|
||||
|
||||
/// Get minimum value.
|
||||
pub fn min(&self) -> f64 {
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Get maximum value.
|
||||
pub fn max(&self) -> f64 {
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
}
|
||||
|
||||
/// Shift values by n positions (positive = shift forward, fill with NaN).
|
||||
pub fn shift(&self, n: isize) -> Self {
|
||||
let len = self.values.len();
|
||||
let mut result = vec![f64::NAN; len];
|
||||
|
||||
if n >= 0 {
|
||||
let n = n as usize;
|
||||
if n < len {
|
||||
for i in n..len {
|
||||
result[i] = self.values[i - n];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let n = (-n) as usize;
|
||||
if n < len {
|
||||
for i in 0..len - n {
|
||||
result[i] = self.values[i + n];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate difference from previous value.
|
||||
pub fn diff(&self) -> Self {
|
||||
let mut result = vec![f64::NAN; self.values.len()];
|
||||
for i in 1..self.values.len() {
|
||||
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() {
|
||||
result[i] = self.values[i] - self.values[i - 1];
|
||||
}
|
||||
}
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate percentage change from previous value.
|
||||
pub fn pct_change(&self) -> Self {
|
||||
let mut result = vec![f64::NAN; self.values.len()];
|
||||
for i in 1..self.values.len() {
|
||||
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() && self.values[i - 1] != 0.0
|
||||
{
|
||||
result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1];
|
||||
}
|
||||
}
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply rolling window function.
|
||||
pub fn rolling<F>(&self, window: usize, f: F) -> Self
|
||||
where
|
||||
F: Fn(&[f64]) -> f64,
|
||||
{
|
||||
let mut result = vec![f64::NAN; self.values.len()];
|
||||
if window == 0 || window > self.values.len() {
|
||||
return Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
};
|
||||
}
|
||||
|
||||
for i in (window - 1)..self.values.len() {
|
||||
let slice = &self.values[i + 1 - window..=i];
|
||||
result[i] = f(slice);
|
||||
}
|
||||
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate rolling sum.
|
||||
pub fn rolling_sum(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| slice.iter().sum())
|
||||
}
|
||||
|
||||
/// Calculate rolling mean.
|
||||
pub fn rolling_mean(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
slice.iter().sum::<f64>() / slice.len() as f64
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate rolling standard deviation.
|
||||
pub fn rolling_std(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
let mean = slice.iter().sum::<f64>() / slice.len() as f64;
|
||||
let variance =
|
||||
slice.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (slice.len() - 1) as f64;
|
||||
variance.sqrt()
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate rolling maximum.
|
||||
pub fn rolling_max(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
slice.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate rolling minimum.
|
||||
pub fn rolling_min(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
slice.iter().copied().fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeSeries<bool> {
|
||||
/// Count true values.
|
||||
pub fn count_true(&self) -> usize {
|
||||
self.values.iter().filter(|&&v| v).count()
|
||||
}
|
||||
|
||||
/// Get indices of true values.
|
||||
pub fn true_indices(&self) -> Vec<usize> {
|
||||
self.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &v)| if v { Some(i) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Logical AND with another series.
|
||||
pub fn and(&self, other: &Self) -> Self {
|
||||
debug_assert_eq!(self.len(), other.len());
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: self
|
||||
.values
|
||||
.iter()
|
||||
.zip(other.values.iter())
|
||||
.map(|(&a, &b)| a && b)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical OR with another series.
|
||||
pub fn or(&self, other: &Self) -> Self {
|
||||
debug_assert_eq!(self.len(), other.len());
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: self
|
||||
.values
|
||||
.iter()
|
||||
.zip(other.values.iter())
|
||||
.map(|(&a, &b)| a || b)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical NOT.
|
||||
pub fn not(&self) -> Self {
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: self.values.iter().map(|&v| !v).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rolling_mean() {
|
||||
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let result = ts.rolling_mean(3);
|
||||
assert!(result.values[0].is_nan());
|
||||
assert!(result.values[1].is_nan());
|
||||
assert!((result.values[2] - 2.0).abs() < 1e-10);
|
||||
assert!((result.values[3] - 3.0).abs() < 1e-10);
|
||||
assert!((result.values[4] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift() {
|
||||
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let shifted = ts.shift(2);
|
||||
assert!(shifted.values[0].is_nan());
|
||||
assert!(shifted.values[1].is_nan());
|
||||
assert!((shifted.values[2] - 1.0).abs() < 1e-10);
|
||||
assert!((shifted.values[3] - 2.0).abs() < 1e-10);
|
||||
assert!((shifted.values[4] - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pct_change() {
|
||||
let ts = TimeSeries::from_values(vec![100.0, 110.0, 99.0]);
|
||||
let pct = ts.pct_change();
|
||||
assert!(pct.values[0].is_nan());
|
||||
assert!((pct.values[1] - 0.1).abs() < 1e-10);
|
||||
assert!((pct.values[2] - (-0.1)).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Core data types for RaptorBT.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Type alias for price values.
|
||||
pub type Price = f64;
|
||||
|
||||
/// Type alias for timestamp values (nanoseconds since epoch).
|
||||
pub type Timestamp = i64;
|
||||
|
||||
/// Trading direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(i8)]
|
||||
pub enum Direction {
|
||||
/// Long position (buy to open, sell to close).
|
||||
Long = 1,
|
||||
/// Short position (sell to open, buy to close).
|
||||
Short = -1,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Convert direction to multiplier for P&L calculations.
|
||||
#[inline]
|
||||
pub fn multiplier(self) -> f64 {
|
||||
self as i8 as f64
|
||||
}
|
||||
|
||||
/// Create direction from integer.
|
||||
pub fn from_int(value: i32) -> Option<Self> {
|
||||
match value {
|
||||
1 => Some(Direction::Long),
|
||||
-1 => Some(Direction::Short),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Direction {
|
||||
fn default() -> Self {
|
||||
Direction::Long
|
||||
}
|
||||
}
|
||||
|
||||
/// OHLCV data for a single bar.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct OhlcvBar {
|
||||
pub timestamp: Timestamp,
|
||||
pub open: Price,
|
||||
pub high: Price,
|
||||
pub low: Price,
|
||||
pub close: Price,
|
||||
pub volume: f64,
|
||||
}
|
||||
|
||||
/// OHLCV data series.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhlcvData {
|
||||
pub timestamps: Vec<Timestamp>,
|
||||
pub open: Vec<Price>,
|
||||
pub high: Vec<Price>,
|
||||
pub low: Vec<Price>,
|
||||
pub close: Vec<Price>,
|
||||
pub volume: Vec<f64>,
|
||||
}
|
||||
|
||||
impl OhlcvData {
|
||||
/// Create new OHLCV data from vectors.
|
||||
pub fn new(
|
||||
timestamps: Vec<Timestamp>,
|
||||
open: Vec<Price>,
|
||||
high: Vec<Price>,
|
||||
low: Vec<Price>,
|
||||
close: Vec<Price>,
|
||||
volume: Vec<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timestamps,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of bars.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.close.len()
|
||||
}
|
||||
|
||||
/// Check if empty.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.close.is_empty()
|
||||
}
|
||||
|
||||
/// Get a single bar at index.
|
||||
pub fn get_bar(&self, index: usize) -> Option<OhlcvBar> {
|
||||
if index >= self.len() {
|
||||
return None;
|
||||
}
|
||||
Some(OhlcvBar {
|
||||
timestamp: self.timestamps[index],
|
||||
open: self.open[index],
|
||||
high: self.high[index],
|
||||
low: self.low[index],
|
||||
close: self.close[index],
|
||||
volume: self.volume[index],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compiled trading signals from strategy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledSignals {
|
||||
/// Symbol identifier.
|
||||
pub symbol: String,
|
||||
/// Entry signals (true = enter position).
|
||||
pub entries: Vec<bool>,
|
||||
/// Exit signals (true = exit position).
|
||||
pub exits: Vec<bool>,
|
||||
/// Optional position sizes (fraction of capital).
|
||||
pub position_sizes: Option<Vec<f64>>,
|
||||
/// Trading direction.
|
||||
pub direction: Direction,
|
||||
/// Weight for portfolio allocation.
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
impl CompiledSignals {
|
||||
/// Create new compiled signals.
|
||||
pub fn new(
|
||||
symbol: String,
|
||||
entries: Vec<bool>,
|
||||
exits: Vec<bool>,
|
||||
direction: Direction,
|
||||
weight: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
symbol,
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction,
|
||||
weight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set position sizes.
|
||||
pub fn with_position_sizes(mut self, sizes: Vec<f64>) -> Self {
|
||||
self.position_sizes = Some(sizes);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the number of bars.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Check if empty.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single executed trade.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Trade {
|
||||
/// Trade identifier.
|
||||
pub id: u64,
|
||||
/// Symbol traded.
|
||||
pub symbol: String,
|
||||
/// Entry bar index.
|
||||
pub entry_idx: usize,
|
||||
/// Exit bar index.
|
||||
pub exit_idx: usize,
|
||||
/// Entry price.
|
||||
pub entry_price: Price,
|
||||
/// Exit price.
|
||||
pub exit_price: Price,
|
||||
/// Position size (number of shares/contracts).
|
||||
pub size: f64,
|
||||
/// Trading direction.
|
||||
pub direction: Direction,
|
||||
/// Realized profit/loss.
|
||||
pub pnl: f64,
|
||||
/// Return percentage.
|
||||
pub return_pct: f64,
|
||||
/// Entry timestamp.
|
||||
pub entry_time: Timestamp,
|
||||
/// Exit timestamp.
|
||||
pub exit_time: Timestamp,
|
||||
/// Fees paid.
|
||||
pub fees: f64,
|
||||
/// Exit reason.
|
||||
pub exit_reason: ExitReason,
|
||||
}
|
||||
|
||||
impl Trade {
|
||||
/// Check if trade was profitable.
|
||||
#[inline]
|
||||
pub fn is_winning(&self) -> bool {
|
||||
self.pnl > 0.0
|
||||
}
|
||||
|
||||
/// Get holding period in bars.
|
||||
#[inline]
|
||||
pub fn holding_period(&self) -> usize {
|
||||
self.exit_idx - self.entry_idx
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason for exiting a trade.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ExitReason {
|
||||
/// Normal exit signal.
|
||||
Signal,
|
||||
/// Stop-loss hit.
|
||||
StopLoss,
|
||||
/// Take-profit hit.
|
||||
TakeProfit,
|
||||
/// Trailing stop hit.
|
||||
TrailingStop,
|
||||
/// End of data.
|
||||
EndOfData,
|
||||
}
|
||||
|
||||
/// Backtest configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestConfig {
|
||||
/// Initial capital.
|
||||
pub initial_capital: f64,
|
||||
/// Transaction fees as fraction (0.001 = 0.1%).
|
||||
pub fees: f64,
|
||||
/// Slippage as fraction.
|
||||
pub slippage: f64,
|
||||
/// Stop-loss configuration.
|
||||
pub stop: StopConfig,
|
||||
/// Take-profit configuration.
|
||||
pub target: TargetConfig,
|
||||
/// Whether to execute on bar close.
|
||||
pub upon_bar_close: bool,
|
||||
}
|
||||
|
||||
impl Default for BacktestConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.001,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop-loss configuration.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum StopConfig {
|
||||
/// No stop-loss.
|
||||
None,
|
||||
/// Fixed percentage stop.
|
||||
Fixed { percent: f64 },
|
||||
/// ATR-based stop.
|
||||
Atr { multiplier: f64, period: usize },
|
||||
/// Trailing stop.
|
||||
Trailing { percent: f64 },
|
||||
}
|
||||
|
||||
/// Take-profit configuration.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum TargetConfig {
|
||||
/// No take-profit.
|
||||
None,
|
||||
/// Fixed percentage target.
|
||||
Fixed { percent: f64 },
|
||||
/// ATR-based target.
|
||||
Atr { multiplier: f64, period: usize },
|
||||
/// Risk-reward ratio target.
|
||||
RiskReward { ratio: f64 },
|
||||
}
|
||||
|
||||
/// Backtest metrics.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
/// Total return percentage.
|
||||
pub total_return_pct: f64,
|
||||
/// Sharpe ratio (annualized).
|
||||
pub sharpe_ratio: f64,
|
||||
/// Sortino ratio (annualized).
|
||||
pub sortino_ratio: f64,
|
||||
/// Calmar ratio.
|
||||
pub calmar_ratio: f64,
|
||||
/// Omega ratio.
|
||||
pub omega_ratio: f64,
|
||||
/// Maximum drawdown percentage.
|
||||
pub max_drawdown_pct: f64,
|
||||
/// Maximum drawdown duration in bars.
|
||||
pub max_drawdown_duration: usize,
|
||||
/// Win rate percentage.
|
||||
pub win_rate_pct: f64,
|
||||
/// Profit factor.
|
||||
pub profit_factor: f64,
|
||||
/// Expectancy (average expected profit per trade).
|
||||
pub expectancy: f64,
|
||||
/// System Quality Number (SQN).
|
||||
pub sqn: f64,
|
||||
/// Total number of trades.
|
||||
pub total_trades: usize,
|
||||
/// Number of closed trades.
|
||||
pub total_closed_trades: usize,
|
||||
/// Number of open trades at end.
|
||||
pub total_open_trades: usize,
|
||||
/// PnL of open trades.
|
||||
pub open_trade_pnl: f64,
|
||||
/// Number of winning trades.
|
||||
pub winning_trades: usize,
|
||||
/// Number of losing trades.
|
||||
pub losing_trades: usize,
|
||||
/// Starting portfolio value.
|
||||
pub start_value: f64,
|
||||
/// Ending portfolio value.
|
||||
pub end_value: f64,
|
||||
/// Total fees paid.
|
||||
pub total_fees_paid: f64,
|
||||
/// Best trade return percentage.
|
||||
pub best_trade_pct: f64,
|
||||
/// Worst trade return percentage.
|
||||
pub worst_trade_pct: f64,
|
||||
/// Average trade return percentage.
|
||||
pub avg_trade_return_pct: f64,
|
||||
/// Average winning trade return percentage.
|
||||
pub avg_win_pct: f64,
|
||||
/// Average losing trade return percentage.
|
||||
pub avg_loss_pct: f64,
|
||||
/// Average winning trade duration in bars.
|
||||
pub avg_winning_duration: f64,
|
||||
/// Average losing trade duration in bars.
|
||||
pub avg_losing_duration: f64,
|
||||
/// Maximum consecutive wins.
|
||||
pub max_consecutive_wins: usize,
|
||||
/// Maximum consecutive losses.
|
||||
pub max_consecutive_losses: usize,
|
||||
/// Average holding period in bars.
|
||||
pub avg_holding_period: f64,
|
||||
/// Exposure time percentage (time in market).
|
||||
pub exposure_pct: f64,
|
||||
}
|
||||
|
||||
/// Complete backtest result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestResult {
|
||||
/// Computed metrics.
|
||||
pub metrics: BacktestMetrics,
|
||||
/// Equity curve (portfolio value over time).
|
||||
pub equity_curve: Vec<f64>,
|
||||
/// Drawdown curve (drawdown percentage over time).
|
||||
pub drawdown_curve: Vec<f64>,
|
||||
/// List of executed trades.
|
||||
pub trades: Vec<Trade>,
|
||||
/// Daily returns.
|
||||
pub returns: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
/// Create a new backtest result.
|
||||
pub fn new(
|
||||
metrics: BacktestMetrics,
|
||||
equity_curve: Vec<f64>,
|
||||
drawdown_curve: Vec<f64>,
|
||||
trades: Vec<Trade>,
|
||||
returns: Vec<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
metrics,
|
||||
equity_curve,
|
||||
drawdown_curve,
|
||||
trades,
|
||||
returns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position state during backtest.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Position {
|
||||
/// Whether position is open.
|
||||
pub is_open: bool,
|
||||
/// Entry bar index.
|
||||
pub entry_idx: usize,
|
||||
/// Entry price.
|
||||
pub entry_price: Price,
|
||||
/// Position size.
|
||||
pub size: f64,
|
||||
/// Trading direction.
|
||||
pub direction: Direction,
|
||||
/// Current stop price.
|
||||
pub stop_price: Option<Price>,
|
||||
/// Current target price.
|
||||
pub target_price: Option<Price>,
|
||||
/// Highest price since entry (for trailing stops).
|
||||
pub highest_since_entry: Price,
|
||||
/// Lowest price since entry (for trailing stops).
|
||||
pub lowest_since_entry: Price,
|
||||
/// Entry fees (to include in trade PnL like VectorBT).
|
||||
pub entry_fees: f64,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
/// Create a new closed position state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
is_open: false,
|
||||
entry_idx: 0,
|
||||
entry_price: 0.0,
|
||||
size: 0.0,
|
||||
direction: Direction::Long,
|
||||
stop_price: None,
|
||||
target_price: None,
|
||||
highest_since_entry: 0.0,
|
||||
lowest_since_entry: f64::MAX,
|
||||
entry_fees: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a new position.
|
||||
pub fn open(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
price: Price,
|
||||
size: f64,
|
||||
direction: Direction,
|
||||
stop_price: Option<Price>,
|
||||
target_price: Option<Price>,
|
||||
entry_fees: f64,
|
||||
) {
|
||||
self.is_open = true;
|
||||
self.entry_idx = idx;
|
||||
self.entry_price = price;
|
||||
self.size = size;
|
||||
self.direction = direction;
|
||||
self.stop_price = stop_price;
|
||||
self.target_price = target_price;
|
||||
self.highest_since_entry = price;
|
||||
self.lowest_since_entry = price;
|
||||
self.entry_fees = entry_fees;
|
||||
}
|
||||
|
||||
/// Close the position.
|
||||
pub fn close(&mut self) {
|
||||
self.is_open = false;
|
||||
}
|
||||
|
||||
/// Update highest/lowest prices for trailing stops.
|
||||
pub fn update_extremes(&mut self, high: Price, low: Price) {
|
||||
if high > self.highest_since_entry {
|
||||
self.highest_since_entry = high;
|
||||
}
|
||||
if low < self.lowest_since_entry {
|
||||
self.lowest_since_entry = low;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate unrealized P&L at given price.
|
||||
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
|
||||
if !self.is_open {
|
||||
return 0.0;
|
||||
}
|
||||
let price_change = current_price - self.entry_price;
|
||||
price_change * self.size * self.direction.multiplier()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Position {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Fee calculation models.
|
||||
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// Fee model for calculating transaction costs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FeeModel {
|
||||
/// No fees.
|
||||
None,
|
||||
/// Fixed percentage of trade value.
|
||||
Percentage(f64),
|
||||
/// Fixed fee per trade.
|
||||
Fixed(f64),
|
||||
/// Per-share/contract fee.
|
||||
PerShare(f64),
|
||||
/// Tiered fee structure based on trade value.
|
||||
Tiered(Vec<(f64, f64)>), // (threshold, rate)
|
||||
/// Custom fee function (stored as percentage for simplicity).
|
||||
Custom { base: f64, per_share: f64 },
|
||||
}
|
||||
|
||||
impl Default for FeeModel {
|
||||
fn default() -> Self {
|
||||
FeeModel::Percentage(0.001) // 0.1% default
|
||||
}
|
||||
}
|
||||
|
||||
impl FeeModel {
|
||||
/// Create a new percentage fee model.
|
||||
pub fn percentage(rate: f64) -> Self {
|
||||
FeeModel::Percentage(rate)
|
||||
}
|
||||
|
||||
/// Create a new fixed fee model.
|
||||
pub fn fixed(amount: f64) -> Self {
|
||||
FeeModel::Fixed(amount)
|
||||
}
|
||||
|
||||
/// Create a new per-share fee model.
|
||||
pub fn per_share(rate: f64) -> Self {
|
||||
FeeModel::PerShare(rate)
|
||||
}
|
||||
|
||||
/// Calculate fee for a trade.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Trade price
|
||||
/// * `size` - Position size (shares/contracts)
|
||||
/// * `direction` - Trade direction (for asymmetric fees if needed)
|
||||
///
|
||||
/// # Returns
|
||||
/// Fee amount
|
||||
pub fn calculate(&self, price: Price, size: f64, _direction: Direction) -> f64 {
|
||||
let trade_value = price * size.abs();
|
||||
|
||||
match self {
|
||||
FeeModel::None => 0.0,
|
||||
FeeModel::Percentage(rate) => trade_value * rate,
|
||||
FeeModel::Fixed(amount) => *amount,
|
||||
FeeModel::PerShare(rate) => size.abs() * rate,
|
||||
FeeModel::Tiered(tiers) => {
|
||||
// Find applicable tier
|
||||
let mut applicable_rate = 0.0;
|
||||
for (threshold, rate) in tiers {
|
||||
if trade_value >= *threshold {
|
||||
applicable_rate = *rate;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
trade_value * applicable_rate
|
||||
}
|
||||
FeeModel::Custom { base, per_share } => base + size.abs() * per_share,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate round-trip fees (entry + exit).
|
||||
pub fn round_trip(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
exit_price: Price,
|
||||
size: f64,
|
||||
direction: Direction,
|
||||
) -> f64 {
|
||||
self.calculate(entry_price, size, direction) + self.calculate(exit_price, size, direction)
|
||||
}
|
||||
}
|
||||
|
||||
/// Broker-specific fee configurations.
|
||||
pub struct BrokerFees;
|
||||
|
||||
impl BrokerFees {
|
||||
/// Interactive Brokers tiered pricing (approximate).
|
||||
pub fn interactive_brokers() -> FeeModel {
|
||||
FeeModel::Custom {
|
||||
base: 1.0,
|
||||
per_share: 0.005,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero commission broker (like Robinhood).
|
||||
pub fn zero_commission() -> FeeModel {
|
||||
FeeModel::None
|
||||
}
|
||||
|
||||
/// Indian broker (Zerodha-like).
|
||||
pub fn india_equity() -> FeeModel {
|
||||
// 0.03% or Rs 20 per trade, whichever is lower
|
||||
// Simplified as 0.03%
|
||||
FeeModel::Percentage(0.0003)
|
||||
}
|
||||
|
||||
/// Crypto exchange (typical).
|
||||
pub fn crypto_exchange() -> FeeModel {
|
||||
FeeModel::Percentage(0.001) // 0.1% maker/taker
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_percentage_fee() {
|
||||
let fee = FeeModel::percentage(0.001);
|
||||
let result = fee.calculate(100.0, 100.0, Direction::Long);
|
||||
assert!((result - 10.0).abs() < 1e-10); // 100 * 100 * 0.001 = 10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_fee() {
|
||||
let fee = FeeModel::fixed(5.0);
|
||||
let result = fee.calculate(100.0, 100.0, Direction::Long);
|
||||
assert!((result - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_share_fee() {
|
||||
let fee = FeeModel::per_share(0.01);
|
||||
let result = fee.calculate(100.0, 100.0, Direction::Long);
|
||||
assert!((result - 1.0).abs() < 1e-10); // 100 * 0.01 = 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip() {
|
||||
let fee = FeeModel::percentage(0.001);
|
||||
let result = fee.round_trip(100.0, 110.0, 100.0, Direction::Long);
|
||||
// Entry: 100 * 100 * 0.001 = 10
|
||||
// Exit: 110 * 100 * 0.001 = 11
|
||||
// Total: 21
|
||||
assert!((result - 21.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_fee() {
|
||||
let fee = FeeModel::None;
|
||||
let result = fee.calculate(100.0, 100.0, Direction::Long);
|
||||
assert!((result - 0.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Order fill simulation models.
|
||||
|
||||
use crate::core::types::{Direction, OhlcvBar, Price};
|
||||
|
||||
/// Fill price model determining at what price orders are executed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FillPrice {
|
||||
/// Execute at close price (end of bar).
|
||||
Close,
|
||||
/// Execute at open price (start of next bar).
|
||||
Open,
|
||||
/// Execute at OHLC average.
|
||||
Average,
|
||||
/// Execute at typical price (H+L+C)/3.
|
||||
Typical,
|
||||
/// Execute at VWAP (if available, otherwise typical).
|
||||
Vwap,
|
||||
/// Execute at worst price (high for buys, low for sells).
|
||||
Worst,
|
||||
/// Execute at best price (low for buys, high for sells).
|
||||
Best,
|
||||
}
|
||||
|
||||
impl Default for FillPrice {
|
||||
fn default() -> Self {
|
||||
FillPrice::Close
|
||||
}
|
||||
}
|
||||
|
||||
impl FillPrice {
|
||||
/// Get execution price from OHLCV bar.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `bar` - OHLCV bar data
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// Execution price
|
||||
pub fn get_price(&self, bar: &OhlcvBar, direction: Direction, is_entry: bool) -> Price {
|
||||
match self {
|
||||
FillPrice::Close => bar.close,
|
||||
FillPrice::Open => bar.open,
|
||||
FillPrice::Average => (bar.open + bar.high + bar.low + bar.close) / 4.0,
|
||||
FillPrice::Typical => (bar.high + bar.low + bar.close) / 3.0,
|
||||
FillPrice::Vwap => (bar.high + bar.low + bar.close) / 3.0, // Simplified
|
||||
FillPrice::Worst => {
|
||||
// Worst price for the trade
|
||||
match (direction, is_entry) {
|
||||
(Direction::Long, true) => bar.high, // Buy high
|
||||
(Direction::Long, false) => bar.low, // Sell low
|
||||
(Direction::Short, true) => bar.low, // Short at low (bad)
|
||||
(Direction::Short, false) => bar.high, // Cover at high (bad)
|
||||
}
|
||||
}
|
||||
FillPrice::Best => {
|
||||
// Best price for the trade
|
||||
match (direction, is_entry) {
|
||||
(Direction::Long, true) => bar.low, // Buy low
|
||||
(Direction::Long, false) => bar.high, // Sell high
|
||||
(Direction::Short, true) => bar.high, // Short at high (good)
|
||||
(Direction::Short, false) => bar.low, // Cover at low (good)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get execution price from separate arrays.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `open` - Open price
|
||||
/// * `high` - High price
|
||||
/// * `low` - Low price
|
||||
/// * `close` - Close price
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// Execution price
|
||||
pub fn get_price_from_arrays(
|
||||
&self,
|
||||
open: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
close: Price,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> Price {
|
||||
match self {
|
||||
FillPrice::Close => close,
|
||||
FillPrice::Open => open,
|
||||
FillPrice::Average => (open + high + low + close) / 4.0,
|
||||
FillPrice::Typical => (high + low + close) / 3.0,
|
||||
FillPrice::Vwap => (high + low + close) / 3.0,
|
||||
FillPrice::Worst => match (direction, is_entry) {
|
||||
(Direction::Long, true) => high,
|
||||
(Direction::Long, false) => low,
|
||||
(Direction::Short, true) => low,
|
||||
(Direction::Short, false) => high,
|
||||
},
|
||||
FillPrice::Best => match (direction, is_entry) {
|
||||
(Direction::Long, true) => low,
|
||||
(Direction::Long, false) => high,
|
||||
(Direction::Short, true) => high,
|
||||
(Direction::Short, false) => low,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill model combining price model with execution rules.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillModel {
|
||||
/// Price model for fills.
|
||||
pub fill_price: FillPrice,
|
||||
/// Whether to delay execution to next bar.
|
||||
pub delay_to_next_bar: bool,
|
||||
/// Partial fill ratio (1.0 = full fill).
|
||||
pub fill_ratio: f64,
|
||||
}
|
||||
|
||||
impl Default for FillModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fill_price: FillPrice::Close,
|
||||
delay_to_next_bar: false,
|
||||
fill_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FillModel {
|
||||
/// Create a fill model that executes at close.
|
||||
pub fn at_close() -> Self {
|
||||
Self {
|
||||
fill_price: FillPrice::Close,
|
||||
delay_to_next_bar: false,
|
||||
fill_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fill model that executes at next bar's open.
|
||||
pub fn at_next_open() -> Self {
|
||||
Self {
|
||||
fill_price: FillPrice::Open,
|
||||
delay_to_next_bar: true,
|
||||
fill_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set partial fill ratio.
|
||||
pub fn with_fill_ratio(mut self, ratio: f64) -> Self {
|
||||
self.fill_ratio = ratio.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a limit order would be filled.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `limit_price` - Limit price
|
||||
/// * `bar` - OHLCV bar
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// True if order would be filled
|
||||
pub fn would_fill_limit(
|
||||
&self,
|
||||
limit_price: Price,
|
||||
bar: &OhlcvBar,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> bool {
|
||||
match (direction, is_entry) {
|
||||
// Long entry: buy at or below limit
|
||||
(Direction::Long, true) => bar.low <= limit_price,
|
||||
// Long exit: sell at or above limit
|
||||
(Direction::Long, false) => bar.high >= limit_price,
|
||||
// Short entry: sell at or above limit
|
||||
(Direction::Short, true) => bar.high >= limit_price,
|
||||
// Short exit: buy at or below limit
|
||||
(Direction::Short, false) => bar.low <= limit_price,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get fill price for a limit order.
|
||||
///
|
||||
/// Returns limit price if filled, None if not filled.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `limit_price` - Limit price
|
||||
/// * `bar` - OHLCV bar
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// Fill price or None
|
||||
pub fn get_limit_fill_price(
|
||||
&self,
|
||||
limit_price: Price,
|
||||
bar: &OhlcvBar,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> Option<Price> {
|
||||
if self.would_fill_limit(limit_price, bar, direction, is_entry) {
|
||||
// For limit orders, fill at limit price (or better if gap)
|
||||
Some(limit_price)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a stop order would be triggered.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stop_price` - Stop price
|
||||
/// * `bar` - OHLCV bar
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// True if stop would be triggered
|
||||
pub fn would_trigger_stop(
|
||||
&self,
|
||||
stop_price: Price,
|
||||
bar: &OhlcvBar,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> bool {
|
||||
match (direction, is_entry) {
|
||||
// Long entry stop: buy when price rises to stop
|
||||
(Direction::Long, true) => bar.high >= stop_price,
|
||||
// Long exit stop: sell when price falls to stop
|
||||
(Direction::Long, false) => bar.low <= stop_price,
|
||||
// Short entry stop: sell when price falls to stop
|
||||
(Direction::Short, true) => bar.low <= stop_price,
|
||||
// Short exit stop: buy when price rises to stop
|
||||
(Direction::Short, false) => bar.high >= stop_price,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get fill price for a stop order.
|
||||
///
|
||||
/// Returns fill price if triggered, None if not.
|
||||
/// Uses worst-case scenario (stop price or worse).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stop_price` - Stop price
|
||||
/// * `bar` - OHLCV bar
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
///
|
||||
/// # Returns
|
||||
/// Fill price or None
|
||||
pub fn get_stop_fill_price(
|
||||
&self,
|
||||
stop_price: Price,
|
||||
bar: &OhlcvBar,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> Option<Price> {
|
||||
if !self.would_trigger_stop(stop_price, bar, direction, is_entry) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check for gap through stop
|
||||
match (direction, is_entry) {
|
||||
(Direction::Long, true) => {
|
||||
// Buy stop: fill at stop or worse (gap up through stop)
|
||||
if bar.open >= stop_price {
|
||||
Some(bar.open) // Gap up, fill at open
|
||||
} else {
|
||||
Some(stop_price)
|
||||
}
|
||||
}
|
||||
(Direction::Long, false) => {
|
||||
// Sell stop: fill at stop or worse (gap down through stop)
|
||||
if bar.open <= stop_price {
|
||||
Some(bar.open) // Gap down, fill at open
|
||||
} else {
|
||||
Some(stop_price)
|
||||
}
|
||||
}
|
||||
(Direction::Short, true) => {
|
||||
// Short stop: fill at stop or worse (gap down through stop)
|
||||
if bar.open <= stop_price {
|
||||
Some(bar.open)
|
||||
} else {
|
||||
Some(stop_price)
|
||||
}
|
||||
}
|
||||
(Direction::Short, false) => {
|
||||
// Cover stop: fill at stop or worse (gap up through stop)
|
||||
if bar.open >= stop_price {
|
||||
Some(bar.open)
|
||||
} else {
|
||||
Some(stop_price)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_bar() -> OhlcvBar {
|
||||
OhlcvBar {
|
||||
timestamp: 0,
|
||||
open: 100.0,
|
||||
high: 105.0,
|
||||
low: 95.0,
|
||||
close: 102.0,
|
||||
volume: 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_price_close() {
|
||||
let bar = test_bar();
|
||||
let fp = FillPrice::Close;
|
||||
assert!((fp.get_price(&bar, Direction::Long, true) - 102.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_price_worst() {
|
||||
let bar = test_bar();
|
||||
let fp = FillPrice::Worst;
|
||||
|
||||
// Long entry: high (105)
|
||||
assert!((fp.get_price(&bar, Direction::Long, true) - 105.0).abs() < 1e-10);
|
||||
|
||||
// Long exit: low (95)
|
||||
assert!((fp.get_price(&bar, Direction::Long, false) - 95.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_limit_fill() {
|
||||
let fill = FillModel::default();
|
||||
let bar = test_bar();
|
||||
|
||||
// Limit buy at 96 should fill (low is 95)
|
||||
assert!(fill.would_fill_limit(96.0, &bar, Direction::Long, true));
|
||||
|
||||
// Limit buy at 94 should not fill (low is 95)
|
||||
assert!(!fill.would_fill_limit(94.0, &bar, Direction::Long, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_fill() {
|
||||
let fill = FillModel::default();
|
||||
let bar = test_bar();
|
||||
|
||||
// Stop sell at 96 should trigger (low is 95)
|
||||
assert!(fill.would_trigger_stop(96.0, &bar, Direction::Long, false));
|
||||
|
||||
// Stop sell at 94 should not trigger (low is 95)
|
||||
assert!(!fill.would_trigger_stop(94.0, &bar, Direction::Long, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gap_through_stop() {
|
||||
let fill = FillModel::default();
|
||||
|
||||
// Gap down through stop
|
||||
let gap_bar = OhlcvBar {
|
||||
timestamp: 0,
|
||||
open: 90.0, // Gap down from stop at 95
|
||||
high: 92.0,
|
||||
low: 88.0,
|
||||
close: 91.0,
|
||||
volume: 1000.0,
|
||||
};
|
||||
|
||||
let fill_price = fill.get_stop_fill_price(95.0, &gap_bar, Direction::Long, false);
|
||||
// Should fill at open (90) not stop (95)
|
||||
assert_eq!(fill_price, Some(90.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Order execution simulation for RaptorBT.
|
||||
|
||||
pub mod fees;
|
||||
pub mod fill;
|
||||
pub mod slippage;
|
||||
|
||||
pub use fees::FeeModel;
|
||||
pub use fill::{FillModel, FillPrice};
|
||||
pub use slippage::SlippageModel;
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Slippage models for realistic trade execution.
|
||||
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// Slippage model for simulating execution price deviation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SlippageModel {
|
||||
/// No slippage.
|
||||
None,
|
||||
/// Fixed percentage slippage.
|
||||
Percentage(f64),
|
||||
/// Fixed point slippage.
|
||||
Fixed(f64),
|
||||
/// Volume-based slippage (higher volume = lower slippage).
|
||||
VolumeBased { base: f64, volume_factor: f64 },
|
||||
/// Spread-based slippage (uses bid-ask spread).
|
||||
SpreadBased { half_spread: f64 },
|
||||
}
|
||||
|
||||
impl Default for SlippageModel {
|
||||
fn default() -> Self {
|
||||
SlippageModel::None
|
||||
}
|
||||
}
|
||||
|
||||
impl SlippageModel {
|
||||
/// Create a new percentage slippage model.
|
||||
pub fn percentage(rate: f64) -> Self {
|
||||
SlippageModel::Percentage(rate)
|
||||
}
|
||||
|
||||
/// Create a new fixed slippage model.
|
||||
pub fn fixed(points: f64) -> Self {
|
||||
SlippageModel::Fixed(points)
|
||||
}
|
||||
|
||||
/// Create a volume-based slippage model.
|
||||
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
|
||||
SlippageModel::VolumeBased {
|
||||
base,
|
||||
volume_factor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate slippage for a trade.
|
||||
///
|
||||
/// For long entries and short exits: slippage is ADDED to price (pay more/receive less)
|
||||
/// For short entries and long exits: slippage is SUBTRACTED from price
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Base execution price
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
/// * `volume` - Optional volume for volume-based models
|
||||
///
|
||||
/// # Returns
|
||||
/// Slippage amount (positive = unfavorable)
|
||||
pub fn calculate(
|
||||
&self,
|
||||
price: Price,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
volume: Option<f64>,
|
||||
) -> f64 {
|
||||
let base_slippage = match self {
|
||||
SlippageModel::None => 0.0,
|
||||
SlippageModel::Percentage(rate) => price * rate,
|
||||
SlippageModel::Fixed(points) => *points,
|
||||
SlippageModel::VolumeBased {
|
||||
base,
|
||||
volume_factor,
|
||||
} => {
|
||||
if let Some(vol) = volume {
|
||||
if vol > 0.0 {
|
||||
base * (1.0 / (1.0 + vol * volume_factor))
|
||||
} else {
|
||||
*base
|
||||
}
|
||||
} else {
|
||||
*base
|
||||
}
|
||||
}
|
||||
SlippageModel::SpreadBased { half_spread } => *half_spread,
|
||||
};
|
||||
|
||||
// Determine sign based on trade type
|
||||
// Long entry: pay higher price (positive slippage)
|
||||
// Long exit: receive lower price (negative slippage)
|
||||
// Short entry: receive higher price (negative slippage means worse)
|
||||
// Short exit: pay higher price
|
||||
match (direction, is_entry) {
|
||||
(Direction::Long, true) => base_slippage, // Pay more
|
||||
(Direction::Long, false) => -base_slippage, // Receive less
|
||||
(Direction::Short, true) => -base_slippage, // Receive less
|
||||
(Direction::Short, false) => base_slippage, // Pay more
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply slippage to get execution price.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Base price
|
||||
/// * `direction` - Trade direction
|
||||
/// * `is_entry` - Whether this is an entry or exit
|
||||
/// * `volume` - Optional volume for volume-based models
|
||||
///
|
||||
/// # Returns
|
||||
/// Execution price after slippage
|
||||
pub fn apply(
|
||||
&self,
|
||||
price: Price,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
volume: Option<f64>,
|
||||
) -> Price {
|
||||
price + self.calculate(price, direction, is_entry, volume)
|
||||
}
|
||||
}
|
||||
|
||||
/// Market impact model for large orders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarketImpact {
|
||||
/// Temporary impact coefficient.
|
||||
pub temporary_impact: f64,
|
||||
/// Permanent impact coefficient.
|
||||
pub permanent_impact: f64,
|
||||
/// Average daily volume for normalization.
|
||||
pub avg_daily_volume: f64,
|
||||
}
|
||||
|
||||
impl MarketImpact {
|
||||
/// Create a new market impact model.
|
||||
pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self {
|
||||
Self {
|
||||
temporary_impact: temporary,
|
||||
permanent_impact: permanent,
|
||||
avg_daily_volume: adv,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate market impact for an order.
|
||||
///
|
||||
/// Uses simplified square-root model: impact = sigma * sqrt(Q / ADV)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `order_size` - Number of shares/contracts
|
||||
/// * `price` - Current price
|
||||
/// * `volatility` - Price volatility (sigma)
|
||||
///
|
||||
/// # Returns
|
||||
/// Total market impact in price terms
|
||||
pub fn calculate(&self, order_size: f64, price: Price, volatility: f64) -> f64 {
|
||||
if self.avg_daily_volume <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let participation_rate = order_size / self.avg_daily_volume;
|
||||
let sqrt_participation = participation_rate.sqrt();
|
||||
|
||||
let temporary = self.temporary_impact * volatility * price * sqrt_participation;
|
||||
let permanent = self.permanent_impact * volatility * price * participation_rate;
|
||||
|
||||
temporary + permanent
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_percentage_slippage() {
|
||||
let slip = SlippageModel::percentage(0.001);
|
||||
|
||||
// Long entry: pay more
|
||||
let entry_slip = slip.calculate(100.0, Direction::Long, true, None);
|
||||
assert!((entry_slip - 0.1).abs() < 1e-10);
|
||||
|
||||
// Long exit: receive less
|
||||
let exit_slip = slip.calculate(100.0, Direction::Long, false, None);
|
||||
assert!((exit_slip - (-0.1)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_slippage() {
|
||||
let slip = SlippageModel::percentage(0.001);
|
||||
|
||||
// Long entry at 100 should pay 100.1
|
||||
let entry_price = slip.apply(100.0, Direction::Long, true, None);
|
||||
assert!((entry_price - 100.1).abs() < 1e-10);
|
||||
|
||||
// Long exit at 100 should receive 99.9
|
||||
let exit_price = slip.apply(100.0, Direction::Long, false, None);
|
||||
assert!((exit_price - 99.9).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_slippage() {
|
||||
let slip = SlippageModel::None;
|
||||
let result = slip.apply(100.0, Direction::Long, true, None);
|
||||
assert!((result - 100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_based_slippage() {
|
||||
let slip = SlippageModel::volume_based(0.1, 0.0001);
|
||||
|
||||
// High volume should have lower slippage
|
||||
let high_vol = slip.calculate(100.0, Direction::Long, true, Some(100000.0));
|
||||
let low_vol = slip.calculate(100.0, Direction::Long, true, Some(1000.0));
|
||||
|
||||
assert!(high_vol < low_vol);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Technical indicators for RaptorBT.
|
||||
//!
|
||||
//! All indicators are implemented as pure functions that take slice inputs
|
||||
//! and return Vec outputs. NaN values are used for the warmup period.
|
||||
|
||||
pub mod momentum;
|
||||
pub mod strength;
|
||||
pub mod trend;
|
||||
pub mod volatility;
|
||||
pub mod volume;
|
||||
|
||||
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
|
||||
pub use strength::adx;
|
||||
pub use trend::{ema, sma, supertrend, SupertrendResult};
|
||||
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
|
||||
pub use volume::{obv, vwap};
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Momentum indicators: RSI, MACD, Stochastic.
|
||||
|
||||
use super::trend::ema;
|
||||
use crate::core::error::RaptorError;
|
||||
use crate::core::Result;
|
||||
|
||||
/// Relative Strength Index (RSI).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Price data (typically close prices)
|
||||
/// * `period` - Lookback period (default: 14)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of RSI values (0-100 scale, NaN for warmup period)
|
||||
pub fn rsi(data: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("RSI period must be > 0"));
|
||||
}
|
||||
if data.len() < 2 {
|
||||
return Ok(vec![f64::NAN; data.len()]);
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
// Calculate price changes
|
||||
let mut gains = vec![0.0; n];
|
||||
let mut losses = vec![0.0; n];
|
||||
|
||||
for i in 1..n {
|
||||
let change = data[i] - data[i - 1];
|
||||
if change > 0.0 {
|
||||
gains[i] = change;
|
||||
} else {
|
||||
losses[i] = -change;
|
||||
}
|
||||
}
|
||||
|
||||
if period >= n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Calculate initial average gain/loss using SMA
|
||||
let mut avg_gain: f64 = gains[1..=period].iter().sum::<f64>() / period as f64;
|
||||
let mut avg_loss: f64 = losses[1..=period].iter().sum::<f64>() / period as f64;
|
||||
|
||||
// First RSI value
|
||||
if avg_loss == 0.0 {
|
||||
result[period] = 100.0;
|
||||
} else {
|
||||
let rs = avg_gain / avg_loss;
|
||||
result[period] = 100.0 - (100.0 / (1.0 + rs));
|
||||
}
|
||||
|
||||
// Smoothed moving average for remaining values (Wilder's smoothing)
|
||||
let alpha = 1.0 / period as f64;
|
||||
for i in (period + 1)..n {
|
||||
avg_gain = alpha * gains[i] + (1.0 - alpha) * avg_gain;
|
||||
avg_loss = alpha * losses[i] + (1.0 - alpha) * avg_loss;
|
||||
|
||||
if avg_loss == 0.0 {
|
||||
result[i] = 100.0;
|
||||
} else {
|
||||
let rs = avg_gain / avg_loss;
|
||||
result[i] = 100.0 - (100.0 / (1.0 + rs));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// MACD result structure.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MacdResult {
|
||||
/// MACD line (fast EMA - slow EMA).
|
||||
pub macd_line: Vec<f64>,
|
||||
/// Signal line (EMA of MACD line).
|
||||
pub signal_line: Vec<f64>,
|
||||
/// Histogram (MACD line - signal line).
|
||||
pub histogram: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Moving Average Convergence Divergence (MACD).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Price data (typically close prices)
|
||||
/// * `fast_period` - Fast EMA period (default: 12)
|
||||
/// * `slow_period` - Slow EMA period (default: 26)
|
||||
/// * `signal_period` - Signal line EMA period (default: 9)
|
||||
///
|
||||
/// # Returns
|
||||
/// MacdResult with MACD line, signal line, and histogram
|
||||
pub fn macd(
|
||||
data: &[f64],
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
signal_period: usize,
|
||||
) -> Result<MacdResult> {
|
||||
if fast_period == 0 || slow_period == 0 || signal_period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("MACD periods must be > 0"));
|
||||
}
|
||||
if fast_period >= slow_period {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"MACD fast period must be < slow period",
|
||||
));
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
let mut macd_line = vec![f64::NAN; n];
|
||||
let mut signal_line = vec![f64::NAN; n];
|
||||
let mut histogram = vec![f64::NAN; n];
|
||||
|
||||
if slow_period > n {
|
||||
return Ok(MacdResult {
|
||||
macd_line,
|
||||
signal_line,
|
||||
histogram,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate fast and slow EMAs
|
||||
let fast_ema = ema(data, fast_period)?;
|
||||
let slow_ema = ema(data, slow_period)?;
|
||||
|
||||
// Calculate MACD line
|
||||
for i in (slow_period - 1)..n {
|
||||
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
|
||||
macd_line[i] = fast_ema[i] - slow_ema[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate signal line (EMA of MACD line)
|
||||
// Need at least signal_period valid MACD values
|
||||
let signal_start = slow_period - 1 + signal_period - 1;
|
||||
if signal_start < n {
|
||||
// Calculate initial signal using SMA of first signal_period MACD values
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0;
|
||||
for i in (slow_period - 1)..=(slow_period - 1 + signal_period - 1) {
|
||||
if i < n && !macd_line[i].is_nan() {
|
||||
sum += macd_line[i];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == signal_period {
|
||||
let initial_signal = sum / signal_period as f64;
|
||||
signal_line[signal_start] = initial_signal;
|
||||
|
||||
// EMA for remaining signal values
|
||||
let alpha = 2.0 / (signal_period as f64 + 1.0);
|
||||
for i in (signal_start + 1)..n {
|
||||
if !macd_line[i].is_nan() {
|
||||
signal_line[i] = alpha * macd_line[i] + (1.0 - alpha) * signal_line[i - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate histogram
|
||||
for i in 0..n {
|
||||
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
|
||||
histogram[i] = macd_line[i] - signal_line[i];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MacdResult {
|
||||
macd_line,
|
||||
signal_line,
|
||||
histogram,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stochastic oscillator result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StochasticResult {
|
||||
/// %K line (fast stochastic).
|
||||
pub k: Vec<f64>,
|
||||
/// %D line (slow stochastic, SMA of %K).
|
||||
pub d: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Stochastic Oscillator.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `k_period` - %K lookback period (default: 14)
|
||||
/// * `d_period` - %D smoothing period (default: 3)
|
||||
///
|
||||
/// # Returns
|
||||
/// StochasticResult with %K and %D lines (0-100 scale)
|
||||
pub fn stochastic(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
k_period: usize,
|
||||
d_period: usize,
|
||||
) -> Result<StochasticResult> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if k_period == 0 || d_period == 0 {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"Stochastic periods must be > 0",
|
||||
));
|
||||
}
|
||||
|
||||
let mut k = vec![f64::NAN; n];
|
||||
let mut d = vec![f64::NAN; n];
|
||||
|
||||
if k_period > n {
|
||||
return Ok(StochasticResult { k, d });
|
||||
}
|
||||
|
||||
// Calculate %K
|
||||
for i in (k_period - 1)..n {
|
||||
let start = i + 1 - k_period;
|
||||
|
||||
// Find highest high and lowest low in window
|
||||
let mut highest_high = f64::NEG_INFINITY;
|
||||
let mut lowest_low = f64::INFINITY;
|
||||
for j in start..=i {
|
||||
if high[j] > highest_high {
|
||||
highest_high = high[j];
|
||||
}
|
||||
if low[j] < lowest_low {
|
||||
lowest_low = low[j];
|
||||
}
|
||||
}
|
||||
|
||||
let range = highest_high - lowest_low;
|
||||
if range > 0.0 {
|
||||
k[i] = ((close[i] - lowest_low) / range) * 100.0;
|
||||
} else {
|
||||
k[i] = 50.0; // Default to middle when range is zero
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate %D (SMA of %K)
|
||||
let d_start = k_period - 1 + d_period - 1;
|
||||
if d_start < n {
|
||||
for i in d_start..n {
|
||||
let start = i + 1 - d_period;
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0;
|
||||
for j in start..=i {
|
||||
if !k[j].is_nan() {
|
||||
sum += k[j];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == d_period {
|
||||
d[i] = sum / d_period as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StochasticResult { k, d })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rsi() {
|
||||
// Test with simple increasing data
|
||||
let data = vec![
|
||||
44.0, 44.25, 44.5, 43.75, 44.5, 44.25, 44.0, 44.0, 44.25, 45.0, 45.5, 46.0, 46.5, 47.0,
|
||||
47.5,
|
||||
];
|
||||
let result = rsi(&data, 14).unwrap();
|
||||
|
||||
// RSI should be valid from index 14
|
||||
assert!(result[13].is_nan());
|
||||
assert!(!result[14].is_nan());
|
||||
assert!(result[14] >= 0.0 && result[14] <= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macd() {
|
||||
let data: Vec<f64> = (1..=50).map(|x| x as f64).collect();
|
||||
let result = macd(&data, 12, 26, 9).unwrap();
|
||||
|
||||
// MACD line should be valid from index 25 (slow_period - 1)
|
||||
assert!(result.macd_line[24].is_nan());
|
||||
assert!(!result.macd_line[25].is_nan());
|
||||
|
||||
// Signal line should be valid later
|
||||
assert!(result.signal_line[33].is_nan());
|
||||
assert!(!result.signal_line[34].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stochastic() {
|
||||
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
|
||||
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
|
||||
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
|
||||
|
||||
let result = stochastic(&high, &low, &close, 5, 3).unwrap();
|
||||
|
||||
// %K should be valid from index 4
|
||||
assert!(result.k[3].is_nan());
|
||||
assert!(!result.k[4].is_nan());
|
||||
assert!(result.k[4] >= 0.0 && result.k[4] <= 100.0);
|
||||
|
||||
// %D should be valid from index 6
|
||||
assert!(result.d[5].is_nan());
|
||||
assert!(!result.d[6].is_nan());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Strength indicators: ADX.
|
||||
|
||||
use crate::core::error::RaptorError;
|
||||
use crate::core::Result;
|
||||
|
||||
/// Average Directional Index (ADX).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `period` - Lookback period (default: 14)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of ADX values (0-100 scale, NaN for warmup period)
|
||||
pub fn adx(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("ADX period must be > 0"));
|
||||
}
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
// Need at least 2 * period for meaningful ADX
|
||||
if 2 * period > n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Calculate directional movement
|
||||
let mut plus_dm = vec![0.0; n];
|
||||
let mut minus_dm = vec![0.0; n];
|
||||
let mut tr = vec![0.0; n];
|
||||
|
||||
for i in 1..n {
|
||||
let up_move = high[i] - high[i - 1];
|
||||
let down_move = low[i - 1] - low[i];
|
||||
|
||||
// +DM
|
||||
if up_move > down_move && up_move > 0.0 {
|
||||
plus_dm[i] = up_move;
|
||||
}
|
||||
|
||||
// -DM
|
||||
if down_move > up_move && down_move > 0.0 {
|
||||
minus_dm[i] = down_move;
|
||||
}
|
||||
|
||||
// True Range
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
tr[i] = hl.max(hc).max(lc);
|
||||
}
|
||||
|
||||
// Smooth DM and TR using Wilder's smoothing
|
||||
let mut smooth_plus_dm = vec![0.0; n];
|
||||
let mut smooth_minus_dm = vec![0.0; n];
|
||||
let mut smooth_tr = vec![0.0; n];
|
||||
|
||||
// Initial sums
|
||||
let sum_plus_dm: f64 = plus_dm[1..=period].iter().sum();
|
||||
let sum_minus_dm: f64 = minus_dm[1..=period].iter().sum();
|
||||
let sum_tr: f64 = tr[1..=period].iter().sum();
|
||||
|
||||
smooth_plus_dm[period] = sum_plus_dm;
|
||||
smooth_minus_dm[period] = sum_minus_dm;
|
||||
smooth_tr[period] = sum_tr;
|
||||
|
||||
// Wilder's smoothing for remaining values
|
||||
for i in (period + 1)..n {
|
||||
smooth_plus_dm[i] =
|
||||
smooth_plus_dm[i - 1] - (smooth_plus_dm[i - 1] / period as f64) + plus_dm[i];
|
||||
smooth_minus_dm[i] =
|
||||
smooth_minus_dm[i - 1] - (smooth_minus_dm[i - 1] / period as f64) + minus_dm[i];
|
||||
smooth_tr[i] = smooth_tr[i - 1] - (smooth_tr[i - 1] / period as f64) + tr[i];
|
||||
}
|
||||
|
||||
// Calculate DI+ and DI-
|
||||
let mut plus_di = vec![0.0; n];
|
||||
let mut minus_di = vec![0.0; n];
|
||||
let mut dx = vec![0.0; n];
|
||||
|
||||
for i in period..n {
|
||||
if smooth_tr[i] > 0.0 {
|
||||
plus_di[i] = 100.0 * smooth_plus_dm[i] / smooth_tr[i];
|
||||
minus_di[i] = 100.0 * smooth_minus_dm[i] / smooth_tr[i];
|
||||
|
||||
// Calculate DX
|
||||
let di_sum = plus_di[i] + minus_di[i];
|
||||
if di_sum > 0.0 {
|
||||
dx[i] = 100.0 * (plus_di[i] - minus_di[i]).abs() / di_sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ADX (smoothed DX)
|
||||
let adx_start = 2 * period - 1;
|
||||
if adx_start < n {
|
||||
// Initial ADX is average of first 'period' DX values
|
||||
let initial_adx: f64 = dx[period..=adx_start].iter().sum::<f64>() / period as f64;
|
||||
result[adx_start] = initial_adx;
|
||||
|
||||
// Smooth ADX for remaining values
|
||||
for i in (adx_start + 1)..n {
|
||||
result[i] = (result[i - 1] * (period - 1) as f64 + dx[i]) / period as f64;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Directional Index result including +DI, -DI, and ADX.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirectionalIndexResult {
|
||||
/// +DI values.
|
||||
pub plus_di: Vec<f64>,
|
||||
/// -DI values.
|
||||
pub minus_di: Vec<f64>,
|
||||
/// ADX values.
|
||||
pub adx: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Full Directional Movement System (DI+, DI-, ADX).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `period` - Lookback period (default: 14)
|
||||
///
|
||||
/// # Returns
|
||||
/// DirectionalIndexResult with +DI, -DI, and ADX
|
||||
pub fn directional_movement(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
period: usize,
|
||||
) -> Result<DirectionalIndexResult> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("Period must be > 0"));
|
||||
}
|
||||
|
||||
let mut plus_di = vec![f64::NAN; n];
|
||||
let mut minus_di = vec![f64::NAN; n];
|
||||
let mut adx_values = vec![f64::NAN; n];
|
||||
|
||||
if 2 * period > n {
|
||||
return Ok(DirectionalIndexResult {
|
||||
plus_di,
|
||||
minus_di,
|
||||
adx: adx_values,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate directional movement
|
||||
let mut plus_dm = vec![0.0; n];
|
||||
let mut minus_dm = vec![0.0; n];
|
||||
let mut tr = vec![0.0; n];
|
||||
|
||||
for i in 1..n {
|
||||
let up_move = high[i] - high[i - 1];
|
||||
let down_move = low[i - 1] - low[i];
|
||||
|
||||
if up_move > down_move && up_move > 0.0 {
|
||||
plus_dm[i] = up_move;
|
||||
}
|
||||
if down_move > up_move && down_move > 0.0 {
|
||||
minus_dm[i] = down_move;
|
||||
}
|
||||
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
tr[i] = hl.max(hc).max(lc);
|
||||
}
|
||||
|
||||
// Smooth using Wilder's method
|
||||
let mut smooth_plus_dm: f64 = plus_dm[1..=period].iter().sum();
|
||||
let mut smooth_minus_dm: f64 = minus_dm[1..=period].iter().sum();
|
||||
let mut smooth_tr: f64 = tr[1..=period].iter().sum();
|
||||
|
||||
let mut dx = vec![0.0; n];
|
||||
|
||||
for i in period..n {
|
||||
if i > period {
|
||||
smooth_plus_dm = smooth_plus_dm - (smooth_plus_dm / period as f64) + plus_dm[i];
|
||||
smooth_minus_dm = smooth_minus_dm - (smooth_minus_dm / period as f64) + minus_dm[i];
|
||||
smooth_tr = smooth_tr - (smooth_tr / period as f64) + tr[i];
|
||||
}
|
||||
|
||||
if smooth_tr > 0.0 {
|
||||
plus_di[i] = 100.0 * smooth_plus_dm / smooth_tr;
|
||||
minus_di[i] = 100.0 * smooth_minus_dm / smooth_tr;
|
||||
|
||||
let di_sum = plus_di[i] + minus_di[i];
|
||||
if di_sum > 0.0 {
|
||||
dx[i] = 100.0 * (plus_di[i] - minus_di[i]).abs() / di_sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ADX
|
||||
let adx_start = 2 * period - 1;
|
||||
if adx_start < n {
|
||||
let initial_adx: f64 = dx[period..=adx_start].iter().sum::<f64>() / period as f64;
|
||||
adx_values[adx_start] = initial_adx;
|
||||
|
||||
for i in (adx_start + 1)..n {
|
||||
adx_values[i] = (adx_values[i - 1] * (period - 1) as f64 + dx[i]) / period as f64;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DirectionalIndexResult {
|
||||
plus_di,
|
||||
minus_di,
|
||||
adx: adx_values,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adx() {
|
||||
// Generate some trending data
|
||||
let n = 50;
|
||||
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
|
||||
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
|
||||
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
|
||||
|
||||
let result = adx(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// ADX should be valid from index 27 (2 * period - 1)
|
||||
assert!(result[26].is_nan());
|
||||
assert!(!result[27].is_nan());
|
||||
|
||||
// ADX should be positive and <= 100
|
||||
assert!(result[27] >= 0.0 && result[27] <= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_movement() {
|
||||
let n = 50;
|
||||
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
|
||||
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
|
||||
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
|
||||
|
||||
let result = directional_movement(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// Check DI values are valid
|
||||
assert!(!result.plus_di[20].is_nan());
|
||||
assert!(!result.minus_di[20].is_nan());
|
||||
|
||||
// In an uptrend, +DI should be greater than -DI
|
||||
assert!(result.plus_di[40] > result.minus_di[40]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Trend indicators: SMA, EMA, Supertrend.
|
||||
|
||||
use crate::core::error::RaptorError;
|
||||
use crate::core::Result;
|
||||
|
||||
/// Simple Moving Average.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Price data
|
||||
/// * `period` - Lookback period
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of SMA values (NaN for warmup period)
|
||||
pub fn sma(data: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("SMA period must be > 0"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if period > n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Calculate first SMA
|
||||
let mut sum: f64 = data[..period].iter().sum();
|
||||
result[period - 1] = sum / period as f64;
|
||||
|
||||
// Sliding window for remaining values
|
||||
for i in period..n {
|
||||
sum = sum - data[i - period] + data[i];
|
||||
result[i] = sum / period as f64;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Exponential Moving Average.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Price data
|
||||
/// * `period` - Lookback period (used to calculate smoothing factor)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of EMA values (NaN for warmup period)
|
||||
pub fn ema(data: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("EMA period must be > 0"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if period > n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Smoothing factor
|
||||
let alpha = 2.0 / (period as f64 + 1.0);
|
||||
|
||||
// Initialize with SMA of first 'period' values
|
||||
let initial_sma: f64 = data[..period].iter().sum::<f64>() / period as f64;
|
||||
result[period - 1] = initial_sma;
|
||||
|
||||
// Calculate EMA for remaining values
|
||||
for i in period..n {
|
||||
result[i] = alpha * data[i] + (1.0 - alpha) * result[i - 1];
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// EMA with custom smoothing factor (internal use).
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn ema_with_alpha(data: &[f64], alpha: f64, initial: f64) -> Vec<f64> {
|
||||
let n = data.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if n == 0 {
|
||||
return result;
|
||||
}
|
||||
|
||||
result[0] = initial;
|
||||
for i in 1..n {
|
||||
if data[i].is_nan() {
|
||||
result[i] = result[i - 1];
|
||||
} else {
|
||||
result[i] = alpha * data[i] + (1.0 - alpha) * result[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Supertrend indicator result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SupertrendResult {
|
||||
/// Supertrend line values.
|
||||
pub supertrend: Vec<f64>,
|
||||
/// Direction: 1 = bullish (below price), -1 = bearish (above price).
|
||||
pub direction: Vec<i8>,
|
||||
}
|
||||
|
||||
/// Supertrend indicator.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `period` - ATR period
|
||||
/// * `multiplier` - ATR multiplier
|
||||
///
|
||||
/// # Returns
|
||||
/// SupertrendResult with supertrend line and direction
|
||||
pub fn supertrend(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
) -> Result<SupertrendResult> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"Supertrend period must be > 0",
|
||||
));
|
||||
}
|
||||
|
||||
let mut supertrend = vec![f64::NAN; n];
|
||||
let mut direction = vec![0i8; n];
|
||||
|
||||
if period >= n {
|
||||
return Ok(SupertrendResult {
|
||||
supertrend,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate ATR
|
||||
let atr_values = super::volatility::atr(high, low, close, period)?;
|
||||
|
||||
// Calculate basic upper and lower bands
|
||||
let mut upper_band = vec![f64::NAN; n];
|
||||
let mut lower_band = vec![f64::NAN; n];
|
||||
|
||||
for i in (period - 1)..n {
|
||||
let hl2 = (high[i] + low[i]) / 2.0;
|
||||
let atr_val = atr_values[i];
|
||||
if !atr_val.is_nan() {
|
||||
upper_band[i] = hl2 + multiplier * atr_val;
|
||||
lower_band[i] = hl2 - multiplier * atr_val;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final bands with carryover logic
|
||||
let mut final_upper = vec![f64::NAN; n];
|
||||
let mut final_lower = vec![f64::NAN; n];
|
||||
|
||||
for i in (period - 1)..n {
|
||||
if i == period - 1 {
|
||||
final_upper[i] = upper_band[i];
|
||||
final_lower[i] = lower_band[i];
|
||||
} else {
|
||||
// Final upper band: use lower of current upper or previous final upper
|
||||
// if previous close was below previous final upper
|
||||
if !upper_band[i].is_nan() && !final_upper[i - 1].is_nan() {
|
||||
if close[i - 1] <= final_upper[i - 1] {
|
||||
final_upper[i] = upper_band[i].min(final_upper[i - 1]);
|
||||
} else {
|
||||
final_upper[i] = upper_band[i];
|
||||
}
|
||||
} else {
|
||||
final_upper[i] = upper_band[i];
|
||||
}
|
||||
|
||||
// Final lower band: use higher of current lower or previous final lower
|
||||
// if previous close was above previous final lower
|
||||
if !lower_band[i].is_nan() && !final_lower[i - 1].is_nan() {
|
||||
if close[i - 1] >= final_lower[i - 1] {
|
||||
final_lower[i] = lower_band[i].max(final_lower[i - 1]);
|
||||
} else {
|
||||
final_lower[i] = lower_band[i];
|
||||
}
|
||||
} else {
|
||||
final_lower[i] = lower_band[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate supertrend and direction
|
||||
for i in (period - 1)..n {
|
||||
if i == period - 1 {
|
||||
// Initial direction based on price vs bands
|
||||
if close[i] <= final_upper[i] {
|
||||
supertrend[i] = final_upper[i];
|
||||
direction[i] = -1; // bearish
|
||||
} else {
|
||||
supertrend[i] = final_lower[i];
|
||||
direction[i] = 1; // bullish
|
||||
}
|
||||
} else {
|
||||
let _prev_st = supertrend[i - 1];
|
||||
let prev_dir = direction[i - 1];
|
||||
|
||||
if prev_dir == 1 {
|
||||
// Was bullish
|
||||
if close[i] < final_lower[i] {
|
||||
// Switch to bearish
|
||||
supertrend[i] = final_upper[i];
|
||||
direction[i] = -1;
|
||||
} else {
|
||||
// Stay bullish
|
||||
supertrend[i] = final_lower[i];
|
||||
direction[i] = 1;
|
||||
}
|
||||
} else {
|
||||
// Was bearish
|
||||
if close[i] > final_upper[i] {
|
||||
// Switch to bullish
|
||||
supertrend[i] = final_lower[i];
|
||||
direction[i] = 1;
|
||||
} else {
|
||||
// Stay bearish
|
||||
supertrend[i] = final_upper[i];
|
||||
direction[i] = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SupertrendResult {
|
||||
supertrend,
|
||||
direction,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sma() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = sma(&data, 3).unwrap();
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 2.0).abs() < 1e-10);
|
||||
assert!((result[3] - 3.0).abs() < 1e-10);
|
||||
assert!((result[4] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = ema(&data, 3).unwrap();
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!(!result[2].is_nan());
|
||||
assert!(!result[3].is_nan());
|
||||
assert!(!result[4].is_nan());
|
||||
// EMA should be between min and max of data
|
||||
assert!(result[4] >= 1.0 && result[4] <= 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sma_invalid_period() {
|
||||
let data = vec![1.0, 2.0, 3.0];
|
||||
let result = sma(&data, 0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema_period_larger_than_data() {
|
||||
let data = vec![1.0, 2.0, 3.0];
|
||||
let result = ema(&data, 10).unwrap();
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Volatility indicators: ATR, Bollinger Bands.
|
||||
|
||||
use super::trend::sma;
|
||||
use crate::core::error::RaptorError;
|
||||
use crate::core::Result;
|
||||
|
||||
/// Average True Range (ATR).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `period` - Lookback period (default: 14)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of ATR values (NaN for warmup period)
|
||||
pub fn atr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("ATR period must be > 0"));
|
||||
}
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if period >= n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Calculate True Range
|
||||
let mut tr = vec![0.0; n];
|
||||
tr[0] = high[0] - low[0]; // First TR is just high - low
|
||||
|
||||
for i in 1..n {
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
tr[i] = hl.max(hc).max(lc);
|
||||
}
|
||||
|
||||
// Calculate initial ATR using SMA of first 'period' TR values
|
||||
let initial_atr: f64 = tr[..period].iter().sum::<f64>() / period as f64;
|
||||
result[period - 1] = initial_atr;
|
||||
|
||||
// Use Wilder's smoothing (exponential) for remaining values
|
||||
let alpha = 1.0 / period as f64;
|
||||
for i in period..n {
|
||||
result[i] = alpha * tr[i] + (1.0 - alpha) * result[i - 1];
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// True Range calculation (single bar).
|
||||
#[inline]
|
||||
pub fn true_range(high: f64, low: f64, prev_close: f64) -> f64 {
|
||||
let hl = high - low;
|
||||
let hc = (high - prev_close).abs();
|
||||
let lc = (low - prev_close).abs();
|
||||
hl.max(hc).max(lc)
|
||||
}
|
||||
|
||||
/// Bollinger Bands result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBandsResult {
|
||||
/// Middle band (SMA).
|
||||
pub middle: Vec<f64>,
|
||||
/// Upper band (SMA + std_dev * multiplier).
|
||||
pub upper: Vec<f64>,
|
||||
/// Lower band (SMA - std_dev * multiplier).
|
||||
pub lower: Vec<f64>,
|
||||
/// Bandwidth: (upper - lower) / middle.
|
||||
pub bandwidth: Vec<f64>,
|
||||
/// %B: (price - lower) / (upper - lower).
|
||||
pub percent_b: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Bollinger Bands.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Price data (typically close prices)
|
||||
/// * `period` - Lookback period (default: 20)
|
||||
/// * `std_dev` - Standard deviation multiplier (default: 2.0)
|
||||
///
|
||||
/// # Returns
|
||||
/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B
|
||||
pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<BollingerBandsResult> {
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"Bollinger Bands period must be > 0",
|
||||
));
|
||||
}
|
||||
if std_dev <= 0.0 {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"Bollinger Bands std_dev must be > 0",
|
||||
));
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
let mut middle = vec![f64::NAN; n];
|
||||
let mut upper = vec![f64::NAN; n];
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
let mut bandwidth = vec![f64::NAN; n];
|
||||
let mut percent_b = vec![f64::NAN; n];
|
||||
|
||||
if period > n {
|
||||
return Ok(BollingerBandsResult {
|
||||
middle,
|
||||
upper,
|
||||
lower,
|
||||
bandwidth,
|
||||
percent_b,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate SMA for middle band
|
||||
middle = sma(data, period)?;
|
||||
|
||||
// Calculate standard deviation and bands
|
||||
for i in (period - 1)..n {
|
||||
let mean = middle[i];
|
||||
|
||||
// Skip if mean is NaN (warmup period)
|
||||
if mean.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = i + 1 - period;
|
||||
|
||||
// Calculate standard deviation using population variance
|
||||
let variance: f64 = data[start..=i]
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ period as f64;
|
||||
let std = variance.sqrt();
|
||||
|
||||
// Calculate bands (std is always non-negative from sqrt)
|
||||
upper[i] = mean + std_dev * std;
|
||||
lower[i] = mean - std_dev * std;
|
||||
|
||||
// Calculate bandwidth (as percentage of middle)
|
||||
if mean.abs() > f64::EPSILON {
|
||||
bandwidth[i] = (upper[i] - lower[i]) / mean.abs();
|
||||
}
|
||||
|
||||
// Calculate %B (position within bands)
|
||||
let band_width = upper[i] - lower[i];
|
||||
if band_width > f64::EPSILON {
|
||||
percent_b[i] = (data[i] - lower[i]) / band_width;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BollingerBandsResult {
|
||||
middle,
|
||||
upper,
|
||||
lower,
|
||||
bandwidth,
|
||||
percent_b,
|
||||
})
|
||||
}
|
||||
|
||||
/// Keltner Channels (ATR-based bands).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `ema_period` - EMA period for middle band
|
||||
/// * `atr_period` - ATR period
|
||||
/// * `multiplier` - ATR multiplier
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (middle, upper, lower) bands
|
||||
pub fn keltner_channels(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
ema_period: usize,
|
||||
atr_period: usize,
|
||||
multiplier: f64,
|
||||
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>)> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
|
||||
// Calculate EMA for middle band
|
||||
let middle = super::trend::ema(close, ema_period)?;
|
||||
|
||||
// Calculate ATR
|
||||
let atr_values = atr(high, low, close, atr_period)?;
|
||||
|
||||
// Calculate bands
|
||||
let mut upper = vec![f64::NAN; n];
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
|
||||
for i in 0..n {
|
||||
if !middle[i].is_nan() && !atr_values[i].is_nan() {
|
||||
upper[i] = middle[i] + multiplier * atr_values[i];
|
||||
lower[i] = middle[i] - multiplier * atr_values[i];
|
||||
}
|
||||
}
|
||||
|
||||
Ok((middle, upper, lower))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_atr() {
|
||||
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
|
||||
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
|
||||
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
|
||||
|
||||
let result = atr(&high, &low, &close, 5).unwrap();
|
||||
|
||||
// ATR should be valid from index 4
|
||||
assert!(result[3].is_nan());
|
||||
assert!(!result[4].is_nan());
|
||||
assert!(result[4] > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bollinger_bands() {
|
||||
let data: Vec<f64> = (1..=30)
|
||||
.map(|x| x as f64 + (x as f64 * 0.1).sin())
|
||||
.collect();
|
||||
|
||||
let result = bollinger_bands(&data, 20, 2.0).unwrap();
|
||||
|
||||
// Bands should be valid from index 19
|
||||
assert!(result.middle[18].is_nan());
|
||||
assert!(!result.middle[19].is_nan());
|
||||
|
||||
// Upper > Middle > Lower
|
||||
assert!(result.upper[19] > result.middle[19]);
|
||||
assert!(result.middle[19] > result.lower[19]);
|
||||
|
||||
// %B should be between 0 and 1 for data within bands
|
||||
assert!(result.percent_b[19] >= -0.5 && result.percent_b[19] <= 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_true_range() {
|
||||
// Simple case
|
||||
assert!((true_range(52.0, 48.0, 50.0) - 4.0).abs() < 1e-10);
|
||||
|
||||
// Gap up case
|
||||
assert!((true_range(55.0, 53.0, 50.0) - 5.0).abs() < 1e-10);
|
||||
|
||||
// Gap down case
|
||||
assert!((true_range(48.0, 45.0, 50.0) - 5.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Volume indicators: VWAP, OBV.
|
||||
|
||||
use crate::core::error::RaptorError;
|
||||
use crate::core::Result;
|
||||
|
||||
/// Volume Weighted Average Price (VWAP).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume data
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of VWAP values
|
||||
pub fn vwap(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() || n != volume.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut cumulative_tp_vol = 0.0;
|
||||
let mut cumulative_vol = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
// Typical price
|
||||
let tp = (high[i] + low[i] + close[i]) / 3.0;
|
||||
|
||||
cumulative_tp_vol += tp * volume[i];
|
||||
cumulative_vol += volume[i];
|
||||
|
||||
if cumulative_vol > 0.0 {
|
||||
result[i] = cumulative_tp_vol / cumulative_vol;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// VWAP with session reset (e.g., daily reset).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume data
|
||||
/// * `session_starts` - Boolean array indicating session start (true = reset VWAP)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of VWAP values with session resets
|
||||
pub fn vwap_session(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
session_starts: &[bool],
|
||||
) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() || n != volume.len() || n != session_starts.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut cumulative_tp_vol = 0.0;
|
||||
let mut cumulative_vol = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
// Reset on session start
|
||||
if session_starts[i] {
|
||||
cumulative_tp_vol = 0.0;
|
||||
cumulative_vol = 0.0;
|
||||
}
|
||||
|
||||
// Typical price
|
||||
let tp = (high[i] + low[i] + close[i]) / 3.0;
|
||||
|
||||
cumulative_tp_vol += tp * volume[i];
|
||||
cumulative_vol += volume[i];
|
||||
|
||||
if cumulative_vol > 0.0 {
|
||||
result[i] = cumulative_tp_vol / cumulative_vol;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// On Balance Volume (OBV).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume data
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of OBV values
|
||||
pub fn obv(close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != volume.len() {
|
||||
return Err(RaptorError::length_mismatch(n, volume.len()));
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut result = vec![0.0; n];
|
||||
result[0] = volume[0];
|
||||
|
||||
for i in 1..n {
|
||||
if close[i] > close[i - 1] {
|
||||
result[i] = result[i - 1] + volume[i];
|
||||
} else if close[i] < close[i - 1] {
|
||||
result[i] = result[i - 1] - volume[i];
|
||||
} else {
|
||||
result[i] = result[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Volume Rate of Change.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `volume` - Volume data
|
||||
/// * `period` - Lookback period
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of volume rate of change values
|
||||
pub fn volume_roc(volume: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("Period must be > 0"));
|
||||
}
|
||||
|
||||
let n = volume.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if period >= n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
for i in period..n {
|
||||
if volume[i - period] != 0.0 {
|
||||
result[i] = (volume[i] - volume[i - period]) / volume[i - period] * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Money Flow Index (volume-weighted RSI).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume data
|
||||
/// * `period` - Lookback period (default: 14)
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of MFI values (0-100 scale)
|
||||
pub fn mfi(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
period: usize,
|
||||
) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() || n != volume.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter("MFI period must be > 0"));
|
||||
}
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if period >= n {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Calculate typical price and raw money flow
|
||||
let mut typical_price = vec![0.0; n];
|
||||
let mut raw_money_flow = vec![0.0; n];
|
||||
|
||||
for i in 0..n {
|
||||
typical_price[i] = (high[i] + low[i] + close[i]) / 3.0;
|
||||
raw_money_flow[i] = typical_price[i] * volume[i];
|
||||
}
|
||||
|
||||
// Calculate MFI for each period
|
||||
for i in period..n {
|
||||
let mut positive_flow = 0.0;
|
||||
let mut negative_flow = 0.0;
|
||||
|
||||
for j in (i - period + 1)..=i {
|
||||
if typical_price[j] > typical_price[j - 1] {
|
||||
positive_flow += raw_money_flow[j];
|
||||
} else if typical_price[j] < typical_price[j - 1] {
|
||||
negative_flow += raw_money_flow[j];
|
||||
}
|
||||
}
|
||||
|
||||
if negative_flow == 0.0 {
|
||||
result[i] = 100.0;
|
||||
} else {
|
||||
let money_ratio = positive_flow / negative_flow;
|
||||
result[i] = 100.0 - (100.0 / (1.0 + money_ratio));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Accumulation/Distribution Line.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume data
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of A/D line values
|
||||
pub fn ad_line(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
|
||||
let n = close.len();
|
||||
if n != high.len() || n != low.len() || n != volume.len() {
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut result = vec![0.0; n];
|
||||
|
||||
for i in 0..n {
|
||||
let hl_range = high[i] - low[i];
|
||||
|
||||
// Money Flow Multiplier
|
||||
let mfm = if hl_range > 0.0 {
|
||||
((close[i] - low[i]) - (high[i] - close[i])) / hl_range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Money Flow Volume
|
||||
let mfv = mfm * volume[i];
|
||||
|
||||
// Accumulate
|
||||
if i == 0 {
|
||||
result[i] = mfv;
|
||||
} else {
|
||||
result[i] = result[i - 1] + mfv;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vwap() {
|
||||
let high = vec![52.0, 53.0, 54.0, 53.0, 52.0];
|
||||
let low = vec![50.0, 51.0, 52.0, 51.0, 50.0];
|
||||
let close = vec![51.0, 52.0, 53.0, 52.0, 51.0];
|
||||
let volume = vec![1000.0, 1500.0, 2000.0, 1500.0, 1000.0];
|
||||
|
||||
let result = vwap(&high, &low, &close, &volume).unwrap();
|
||||
|
||||
// VWAP should be valid for all bars
|
||||
assert!(!result[0].is_nan());
|
||||
assert!(!result[4].is_nan());
|
||||
|
||||
// VWAP should be between low and high range
|
||||
assert!(result[4] >= 50.0 && result[4] <= 54.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_obv() {
|
||||
let close = vec![50.0, 51.0, 50.5, 52.0, 51.0];
|
||||
let volume = vec![1000.0, 1500.0, 1200.0, 1800.0, 1300.0];
|
||||
|
||||
let result = obv(&close, &volume).unwrap();
|
||||
|
||||
// OBV starts with first volume
|
||||
assert!((result[0] - 1000.0).abs() < 1e-10);
|
||||
|
||||
// Price up -> add volume
|
||||
assert!((result[1] - 2500.0).abs() < 1e-10);
|
||||
|
||||
// Price down -> subtract volume
|
||||
assert!((result[2] - 1300.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mfi() {
|
||||
let high = vec![
|
||||
52.0, 53.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0,
|
||||
56.0,
|
||||
];
|
||||
let low = vec![
|
||||
50.0, 51.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0,
|
||||
54.0,
|
||||
];
|
||||
let close = vec![
|
||||
51.0, 52.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0,
|
||||
55.0,
|
||||
];
|
||||
let volume = vec![1000.0; 15];
|
||||
|
||||
let result = mfi(&high, &low, &close, &volume, 14).unwrap();
|
||||
|
||||
// MFI should be valid from index 14
|
||||
assert!(result[13].is_nan());
|
||||
assert!(!result[14].is_nan());
|
||||
assert!(result[14] >= 0.0 && result[14] <= 100.0);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
|
||||
#![allow(non_local_definitions)]
|
||||
|
||||
//! RaptorBT - High-performance Rust backtesting engine for quant5.
|
||||
//!
|
||||
//! This crate provides a complete backtesting solution with:
|
||||
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
|
||||
//! - Portfolio simulation engine
|
||||
//! - Multiple strategy types (single, basket, options, pairs, multi)
|
||||
//! - Stop-loss and take-profit mechanisms
|
||||
//! - Streaming metrics calculation
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub mod core;
|
||||
pub mod execution;
|
||||
pub mod indicators;
|
||||
pub mod metrics;
|
||||
pub mod portfolio;
|
||||
pub mod python;
|
||||
pub mod signals;
|
||||
pub mod stops;
|
||||
pub mod strategies;
|
||||
|
||||
/// Python module entry point
|
||||
#[pymodule]
|
||||
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
||||
// Register config classes
|
||||
m.add_class::<python::bindings::PyBacktestConfig>()?;
|
||||
m.add_class::<python::bindings::PyStopConfig>()?;
|
||||
m.add_class::<python::bindings::PyTargetConfig>()?;
|
||||
|
||||
// Register result classes
|
||||
m.add_class::<python::bindings::PyBacktestResult>()?;
|
||||
m.add_class::<python::bindings::PyBacktestMetrics>()?;
|
||||
m.add_class::<python::bindings::PyTrade>()?;
|
||||
|
||||
// Register backtest functions
|
||||
m.add_function(wrap_pyfunction!(python::bindings::run_single_backtest, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::run_basket_backtest, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?;
|
||||
|
||||
// Register indicator functions
|
||||
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::rsi, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::macd, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::stochastic, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::atr, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::bollinger_bands, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Incremental drawdown tracking.
|
||||
|
||||
/// Drawdown tracker for incremental portfolio value updates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrawdownTracker {
|
||||
/// Current peak value.
|
||||
peak: f64,
|
||||
/// Current drawdown value.
|
||||
current_drawdown: f64,
|
||||
/// Maximum drawdown seen.
|
||||
max_drawdown: f64,
|
||||
/// Current drawdown duration (bars since peak).
|
||||
current_duration: usize,
|
||||
/// Maximum drawdown duration.
|
||||
max_duration: usize,
|
||||
/// Value at drawdown start.
|
||||
drawdown_start_value: f64,
|
||||
/// Index at drawdown start.
|
||||
drawdown_start_idx: usize,
|
||||
/// Index at max drawdown.
|
||||
max_drawdown_idx: usize,
|
||||
/// Total count of updates.
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Default for DrawdownTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DrawdownTracker {
|
||||
/// Create a new drawdown tracker.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
peak: 0.0,
|
||||
current_drawdown: 0.0,
|
||||
max_drawdown: 0.0,
|
||||
current_duration: 0,
|
||||
max_duration: 0,
|
||||
drawdown_start_value: 0.0,
|
||||
drawdown_start_idx: 0,
|
||||
max_drawdown_idx: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with initial value.
|
||||
pub fn with_initial(initial_value: f64) -> Self {
|
||||
Self {
|
||||
peak: initial_value,
|
||||
current_drawdown: 0.0,
|
||||
max_drawdown: 0.0,
|
||||
current_duration: 0,
|
||||
max_duration: 0,
|
||||
drawdown_start_value: initial_value,
|
||||
drawdown_start_idx: 0,
|
||||
max_drawdown_idx: 0,
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update with new portfolio value.
|
||||
pub fn update(&mut self, value: f64) {
|
||||
self.count += 1;
|
||||
|
||||
if value > self.peak {
|
||||
// New peak - reset drawdown
|
||||
self.peak = value;
|
||||
self.current_drawdown = 0.0;
|
||||
self.current_duration = 0;
|
||||
self.drawdown_start_value = value;
|
||||
self.drawdown_start_idx = self.count - 1;
|
||||
} else {
|
||||
// In drawdown
|
||||
self.current_drawdown = (self.peak - value) / self.peak;
|
||||
self.current_duration += 1;
|
||||
|
||||
if self.current_drawdown > self.max_drawdown {
|
||||
self.max_drawdown = self.current_drawdown;
|
||||
self.max_drawdown_idx = self.count - 1;
|
||||
}
|
||||
|
||||
if self.current_duration > self.max_duration {
|
||||
self.max_duration = self.current_duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current drawdown as percentage.
|
||||
#[inline]
|
||||
pub fn current_drawdown_pct(&self) -> f64 {
|
||||
self.current_drawdown * 100.0
|
||||
}
|
||||
|
||||
/// Get maximum drawdown as percentage.
|
||||
#[inline]
|
||||
pub fn max_drawdown_pct(&self) -> f64 {
|
||||
self.max_drawdown * 100.0
|
||||
}
|
||||
|
||||
/// Get maximum drawdown as fraction.
|
||||
#[inline]
|
||||
pub fn max_drawdown(&self) -> f64 {
|
||||
self.max_drawdown
|
||||
}
|
||||
|
||||
/// Get current peak value.
|
||||
#[inline]
|
||||
pub fn peak(&self) -> f64 {
|
||||
self.peak
|
||||
}
|
||||
|
||||
/// Get current drawdown duration.
|
||||
#[inline]
|
||||
pub fn current_duration(&self) -> usize {
|
||||
self.current_duration
|
||||
}
|
||||
|
||||
/// Get maximum drawdown duration.
|
||||
#[inline]
|
||||
pub fn max_duration(&self) -> usize {
|
||||
self.max_duration
|
||||
}
|
||||
|
||||
/// Check if currently in drawdown.
|
||||
#[inline]
|
||||
pub fn in_drawdown(&self) -> bool {
|
||||
self.current_drawdown > 0.0
|
||||
}
|
||||
|
||||
/// Get index where max drawdown occurred.
|
||||
#[inline]
|
||||
pub fn max_drawdown_idx(&self) -> usize {
|
||||
self.max_drawdown_idx
|
||||
}
|
||||
|
||||
/// Reset the tracker.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate drawdown curve from equity curve.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `equity_curve` - Portfolio values over time
|
||||
///
|
||||
/// # Returns
|
||||
/// Drawdown percentages at each point
|
||||
pub fn calculate_drawdown_curve(equity_curve: &[f64]) -> Vec<f64> {
|
||||
let n = equity_curve.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut peak = equity_curve[0];
|
||||
|
||||
for i in 0..n {
|
||||
if equity_curve[i] > peak {
|
||||
peak = equity_curve[i];
|
||||
}
|
||||
if peak > 0.0 {
|
||||
drawdown_curve[i] = (peak - equity_curve[i]) / peak * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
drawdown_curve
|
||||
}
|
||||
|
||||
/// Calculate maximum drawdown from equity curve.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `equity_curve` - Portfolio values over time
|
||||
///
|
||||
/// # Returns
|
||||
/// Maximum drawdown as percentage
|
||||
pub fn max_drawdown(equity_curve: &[f64]) -> f64 {
|
||||
let dd = calculate_drawdown_curve(equity_curve);
|
||||
dd.iter().fold(0.0f64, |a, &b| a.max(b))
|
||||
}
|
||||
|
||||
/// Calculate average drawdown from equity curve.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `equity_curve` - Portfolio values over time
|
||||
///
|
||||
/// # Returns
|
||||
/// Average drawdown as percentage
|
||||
pub fn avg_drawdown(equity_curve: &[f64]) -> f64 {
|
||||
let dd = calculate_drawdown_curve(equity_curve);
|
||||
if dd.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
dd.iter().sum::<f64>() / dd.len() as f64
|
||||
}
|
||||
|
||||
/// Find drawdown periods.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `equity_curve` - Portfolio values over time
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of (start_idx, end_idx, max_drawdown) tuples for each drawdown period
|
||||
pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> {
|
||||
let n = equity_curve.len();
|
||||
if n < 2 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut periods = Vec::new();
|
||||
let mut peak = equity_curve[0];
|
||||
let mut peak_idx = 0;
|
||||
let mut in_dd = false;
|
||||
let mut dd_start = 0;
|
||||
let mut max_dd = 0.0;
|
||||
|
||||
for i in 1..n {
|
||||
if equity_curve[i] > peak {
|
||||
if in_dd {
|
||||
// End of drawdown period
|
||||
periods.push((dd_start, i - 1, max_dd));
|
||||
in_dd = false;
|
||||
max_dd = 0.0;
|
||||
}
|
||||
peak = equity_curve[i];
|
||||
peak_idx = i;
|
||||
} else if peak > 0.0 {
|
||||
let dd = (peak - equity_curve[i]) / peak * 100.0;
|
||||
if !in_dd && dd > 0.0 {
|
||||
in_dd = true;
|
||||
dd_start = peak_idx;
|
||||
}
|
||||
if dd > max_dd {
|
||||
max_dd = dd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ongoing drawdown at end
|
||||
if in_dd {
|
||||
periods.push((dd_start, n - 1, max_dd));
|
||||
}
|
||||
|
||||
periods
|
||||
}
|
||||
|
||||
/// Calculate Calmar ratio.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `total_return` - Total return as percentage
|
||||
/// * `max_drawdown` - Maximum drawdown as percentage
|
||||
///
|
||||
/// # Returns
|
||||
/// Calmar ratio
|
||||
pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 {
|
||||
if max_drawdown <= 0.0 {
|
||||
return if total_return > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
total_return / max_drawdown
|
||||
}
|
||||
|
||||
/// Calculate Ulcer Index (root mean square of drawdowns).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `equity_curve` - Portfolio values over time
|
||||
///
|
||||
/// # Returns
|
||||
/// Ulcer Index
|
||||
pub fn ulcer_index(equity_curve: &[f64]) -> f64 {
|
||||
let dd = calculate_drawdown_curve(equity_curve);
|
||||
if dd.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum_sq: f64 = dd.iter().map(|d| d * d).sum();
|
||||
(sum_sq / dd.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_tracking() {
|
||||
let mut tracker = DrawdownTracker::new();
|
||||
|
||||
tracker.update(100.0);
|
||||
tracker.update(110.0);
|
||||
tracker.update(105.0); // 4.5% drawdown
|
||||
tracker.update(120.0);
|
||||
tracker.update(100.0); // 16.67% drawdown
|
||||
|
||||
assert!((tracker.max_drawdown_pct() - 16.67).abs() < 0.1);
|
||||
assert!((tracker.peak() - 120.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_curve() {
|
||||
let equity = vec![100.0, 110.0, 105.0, 120.0, 100.0];
|
||||
let dd = calculate_drawdown_curve(&equity);
|
||||
|
||||
assert_eq!(dd.len(), 5);
|
||||
assert!((dd[0] - 0.0).abs() < 1e-10);
|
||||
assert!((dd[1] - 0.0).abs() < 1e-10);
|
||||
assert!((dd[2] - 4.545).abs() < 0.1); // (110-105)/110 * 100
|
||||
assert!((dd[3] - 0.0).abs() < 1e-10);
|
||||
assert!((dd[4] - 16.67).abs() < 0.1); // (120-100)/120 * 100
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_drawdown() {
|
||||
let equity = vec![100.0, 120.0, 90.0, 110.0, 85.0];
|
||||
let max_dd = max_drawdown(&equity);
|
||||
|
||||
// Max DD should be (120-85)/120 = 29.17%
|
||||
assert!((max_dd - 29.17).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_periods() {
|
||||
let equity = vec![100.0, 110.0, 105.0, 115.0, 100.0, 120.0];
|
||||
let periods = drawdown_periods(&equity);
|
||||
|
||||
// Should have 2 drawdown periods
|
||||
assert_eq!(periods.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calmar_ratio() {
|
||||
// 50% return with 10% max drawdown
|
||||
let calmar = calmar_ratio(50.0, 10.0);
|
||||
assert!((calmar - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ulcer_index() {
|
||||
let equity = vec![100.0, 95.0, 90.0, 95.0, 100.0];
|
||||
let ui = ulcer_index(&equity);
|
||||
|
||||
// Should be positive (there were drawdowns)
|
||||
assert!(ui > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Performance metrics for RaptorBT.
|
||||
|
||||
pub mod drawdown;
|
||||
pub mod streaming;
|
||||
pub mod trade_stats;
|
||||
|
||||
pub use drawdown::DrawdownTracker;
|
||||
pub use streaming::StreamingMetrics;
|
||||
pub use trade_stats::TradeStatistics;
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Streaming metrics calculation using Welford's algorithm.
|
||||
//!
|
||||
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
|
||||
|
||||
/// Streaming metrics calculator using Welford's algorithm.
|
||||
///
|
||||
/// Allows incremental calculation of statistics without storing all values.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamingMetrics {
|
||||
/// Number of observations.
|
||||
count: usize,
|
||||
/// Running mean.
|
||||
mean: f64,
|
||||
/// Running M2 for variance calculation.
|
||||
m2: f64,
|
||||
/// Running M2 for downside variance (Sortino).
|
||||
m2_downside: f64,
|
||||
/// Target return for Sortino (default: 0).
|
||||
target_return: f64,
|
||||
/// Sum of returns (for total return calculation).
|
||||
sum: f64,
|
||||
/// Sum of positive returns.
|
||||
sum_positive: f64,
|
||||
/// Sum of negative returns.
|
||||
sum_negative: f64,
|
||||
/// Count of positive returns.
|
||||
count_positive: usize,
|
||||
/// Count of negative returns.
|
||||
count_negative: usize,
|
||||
}
|
||||
|
||||
impl Default for StreamingMetrics {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingMetrics {
|
||||
/// Create a new streaming metrics calculator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
count: 0,
|
||||
mean: 0.0,
|
||||
m2: 0.0,
|
||||
m2_downside: 0.0,
|
||||
target_return: 0.0,
|
||||
sum: 0.0,
|
||||
sum_positive: 0.0,
|
||||
sum_negative: 0.0,
|
||||
count_positive: 0,
|
||||
count_negative: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom target return for Sortino calculation.
|
||||
pub fn with_target_return(mut self, target: f64) -> Self {
|
||||
self.target_return = target;
|
||||
self
|
||||
}
|
||||
|
||||
/// Update metrics with a new return value.
|
||||
///
|
||||
/// Uses Welford's online algorithm for numerically stable variance calculation.
|
||||
pub fn update(&mut self, return_value: f64) {
|
||||
self.count += 1;
|
||||
self.sum += return_value;
|
||||
|
||||
// Track positive/negative
|
||||
if return_value > 0.0 {
|
||||
self.sum_positive += return_value;
|
||||
self.count_positive += 1;
|
||||
} else if return_value < 0.0 {
|
||||
self.sum_negative += return_value;
|
||||
self.count_negative += 1;
|
||||
}
|
||||
|
||||
// Welford's algorithm for mean and variance
|
||||
let delta = return_value - self.mean;
|
||||
self.mean += delta / self.count as f64;
|
||||
let delta2 = return_value - self.mean;
|
||||
self.m2 += delta * delta2;
|
||||
|
||||
// Downside variance (for Sortino)
|
||||
let downside = (return_value - self.target_return).min(0.0);
|
||||
let _delta_down = downside - (self.m2_downside / self.count.max(1) as f64).sqrt();
|
||||
self.m2_downside += downside * downside;
|
||||
}
|
||||
|
||||
/// Get the number of observations.
|
||||
#[inline]
|
||||
pub fn count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Get the running mean.
|
||||
#[inline]
|
||||
pub fn mean(&self) -> f64 {
|
||||
self.mean
|
||||
}
|
||||
|
||||
/// Get the sample variance.
|
||||
pub fn variance(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
self.m2 / (self.count - 1) as f64
|
||||
}
|
||||
|
||||
/// Get the population variance.
|
||||
pub fn variance_population(&self) -> f64 {
|
||||
if self.count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.m2 / self.count as f64
|
||||
}
|
||||
|
||||
/// Get the sample standard deviation.
|
||||
pub fn std_dev(&self) -> f64 {
|
||||
self.variance().sqrt()
|
||||
}
|
||||
|
||||
/// Get the downside standard deviation (for Sortino).
|
||||
pub fn downside_std_dev(&self) -> f64 {
|
||||
if self.count < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.m2_downside / (self.count - 1) as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Calculate Sharpe ratio.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
|
||||
/// * `risk_free_rate` - Annual risk-free rate (default: 0)
|
||||
///
|
||||
/// # Returns
|
||||
/// Annualized Sharpe ratio
|
||||
pub fn sharpe_ratio(&self, periods_per_year: f64) -> f64 {
|
||||
self.sharpe_ratio_with_rf(periods_per_year, 0.0)
|
||||
}
|
||||
|
||||
/// Calculate Sharpe ratio with custom risk-free rate.
|
||||
pub fn sharpe_ratio_with_rf(&self, periods_per_year: f64, risk_free_rate: f64) -> f64 {
|
||||
let std = self.std_dev();
|
||||
if std == 0.0 || self.count < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let rf_per_period = risk_free_rate / periods_per_year;
|
||||
let excess_return = self.mean - rf_per_period;
|
||||
let annualized_excess = excess_return * periods_per_year;
|
||||
let annualized_std = std * periods_per_year.sqrt();
|
||||
|
||||
annualized_excess / annualized_std
|
||||
}
|
||||
|
||||
/// Calculate Sortino ratio.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
|
||||
///
|
||||
/// # Returns
|
||||
/// Annualized Sortino ratio
|
||||
pub fn sortino_ratio(&self, periods_per_year: f64) -> f64 {
|
||||
let downside_std = self.downside_std_dev();
|
||||
if downside_std == 0.0 || self.count < 2 {
|
||||
return if self.mean > 0.0 { f64::INFINITY } else { 0.0 };
|
||||
}
|
||||
|
||||
let excess_return = self.mean - self.target_return;
|
||||
let annualized_excess = excess_return * periods_per_year;
|
||||
let annualized_downside_std = downside_std * periods_per_year.sqrt();
|
||||
|
||||
annualized_excess / annualized_downside_std
|
||||
}
|
||||
|
||||
/// Get total return.
|
||||
pub fn total_return(&self) -> f64 {
|
||||
self.sum
|
||||
}
|
||||
|
||||
/// Get average positive return.
|
||||
pub fn avg_positive_return(&self) -> f64 {
|
||||
if self.count_positive == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.sum_positive / self.count_positive as f64
|
||||
}
|
||||
|
||||
/// Get average negative return.
|
||||
pub fn avg_negative_return(&self) -> f64 {
|
||||
if self.count_negative == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.sum_negative / self.count_negative as f64
|
||||
}
|
||||
|
||||
/// Get win rate (fraction of positive returns).
|
||||
pub fn win_rate(&self) -> f64 {
|
||||
if self.count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.count_positive as f64 / self.count as f64
|
||||
}
|
||||
|
||||
/// Get profit factor (sum of profits / sum of losses).
|
||||
pub fn profit_factor(&self) -> f64 {
|
||||
if self.sum_negative == 0.0 {
|
||||
return if self.sum_positive > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
self.sum_positive / self.sum_negative.abs()
|
||||
}
|
||||
|
||||
/// Get omega ratio (same as profit factor for return-based calculation).
|
||||
/// Omega = (sum of returns above threshold) / |sum of returns below threshold|
|
||||
/// With threshold = 0, this equals profit_factor.
|
||||
pub fn omega_ratio(&self) -> f64 {
|
||||
self.profit_factor()
|
||||
}
|
||||
|
||||
/// Reset all metrics.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
/// Merge two streaming metrics (for parallel computation).
|
||||
pub fn merge(&mut self, other: &StreamingMetrics) {
|
||||
if other.count == 0 {
|
||||
return;
|
||||
}
|
||||
if self.count == 0 {
|
||||
*self = other.clone();
|
||||
return;
|
||||
}
|
||||
|
||||
let combined_count = self.count + other.count;
|
||||
let delta = other.mean - self.mean;
|
||||
|
||||
// Merge means
|
||||
let combined_mean = self.mean + delta * other.count as f64 / combined_count as f64;
|
||||
|
||||
// Merge M2 (parallel variance)
|
||||
let combined_m2 = self.m2
|
||||
+ other.m2
|
||||
+ delta * delta * self.count as f64 * other.count as f64 / combined_count as f64;
|
||||
|
||||
// Update state
|
||||
self.count = combined_count;
|
||||
self.mean = combined_mean;
|
||||
self.m2 = combined_m2;
|
||||
self.sum += other.sum;
|
||||
self.sum_positive += other.sum_positive;
|
||||
self.sum_negative += other.sum_negative;
|
||||
self.count_positive += other.count_positive;
|
||||
self.count_negative += other.count_negative;
|
||||
self.m2_downside += other.m2_downside; // Approximation
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate Sharpe ratio from a slice of returns.
|
||||
pub fn sharpe_ratio(returns: &[f64], periods_per_year: f64, risk_free_rate: f64) -> f64 {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
for &r in returns {
|
||||
if !r.is_nan() {
|
||||
metrics.update(r);
|
||||
}
|
||||
}
|
||||
metrics.sharpe_ratio_with_rf(periods_per_year, risk_free_rate)
|
||||
}
|
||||
|
||||
/// Calculate Sortino ratio from a slice of returns.
|
||||
pub fn sortino_ratio(returns: &[f64], periods_per_year: f64) -> f64 {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
for &r in returns {
|
||||
if !r.is_nan() {
|
||||
metrics.update(r);
|
||||
}
|
||||
}
|
||||
metrics.sortino_ratio(periods_per_year)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_statistics() {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
|
||||
for v in &values {
|
||||
metrics.update(*v);
|
||||
}
|
||||
|
||||
assert_eq!(metrics.count(), 5);
|
||||
assert!((metrics.mean() - 3.0).abs() < 1e-10);
|
||||
|
||||
// Sample variance of [1,2,3,4,5] = 2.5
|
||||
assert!((metrics.variance() - 2.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_welford_numerical_stability() {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
|
||||
// Large values that might cause numerical issues with naive algorithm
|
||||
let base = 1e10;
|
||||
let values = vec![base + 1.0, base + 2.0, base + 3.0];
|
||||
|
||||
for v in &values {
|
||||
metrics.update(*v);
|
||||
}
|
||||
|
||||
// Mean should be base + 2
|
||||
assert!((metrics.mean() - (base + 2.0)).abs() < 1e-5);
|
||||
|
||||
// Variance should be 1.0 (same as [1, 2, 3])
|
||||
assert!((metrics.variance() - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sharpe_ratio() {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
|
||||
// Daily returns: 1%, 2%, -1%, 1.5%, 0.5%
|
||||
let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005];
|
||||
|
||||
for r in &returns {
|
||||
metrics.update(*r);
|
||||
}
|
||||
|
||||
// Should produce a positive Sharpe ratio
|
||||
let sharpe = metrics.sharpe_ratio(252.0);
|
||||
assert!(sharpe > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sortino_ratio() {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
|
||||
// Mix of positive and negative returns
|
||||
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
|
||||
|
||||
for r in &returns {
|
||||
metrics.update(*r);
|
||||
}
|
||||
|
||||
// Sortino should be different from Sharpe
|
||||
let sharpe = metrics.sharpe_ratio(252.0);
|
||||
let sortino = metrics.sortino_ratio(252.0);
|
||||
|
||||
// With negative returns, Sortino penalizes only downside
|
||||
assert!(sortino != sharpe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_win_rate_and_profit_factor() {
|
||||
let mut metrics = StreamingMetrics::new();
|
||||
|
||||
// 3 wins, 2 losses
|
||||
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
|
||||
|
||||
for r in &returns {
|
||||
metrics.update(*r);
|
||||
}
|
||||
|
||||
// Win rate should be 60%
|
||||
assert!((metrics.win_rate() - 0.6).abs() < 1e-10);
|
||||
|
||||
// Profit factor = 0.06 / 0.03 = 2.0
|
||||
assert!((metrics.profit_factor() - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge() {
|
||||
let mut m1 = StreamingMetrics::new();
|
||||
let mut m2 = StreamingMetrics::new();
|
||||
|
||||
// Split data between two calculators
|
||||
for v in &[1.0, 2.0, 3.0] {
|
||||
m1.update(*v);
|
||||
}
|
||||
for v in &[4.0, 5.0] {
|
||||
m2.update(*v);
|
||||
}
|
||||
|
||||
// Merge
|
||||
m1.merge(&m2);
|
||||
|
||||
// Should match single calculator with all data
|
||||
let mut combined = StreamingMetrics::new();
|
||||
for v in &[1.0, 2.0, 3.0, 4.0, 5.0] {
|
||||
combined.update(*v);
|
||||
}
|
||||
|
||||
assert_eq!(m1.count(), combined.count());
|
||||
assert!((m1.mean() - combined.mean()).abs() < 1e-10);
|
||||
assert!((m1.variance() - combined.variance()).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Trade statistics calculation.
|
||||
|
||||
use crate::core::types::Trade;
|
||||
|
||||
/// Comprehensive trade statistics.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TradeStatistics {
|
||||
/// Total number of trades.
|
||||
pub total_trades: usize,
|
||||
/// Number of winning trades.
|
||||
pub winning_trades: usize,
|
||||
/// Number of losing trades.
|
||||
pub losing_trades: usize,
|
||||
/// Number of breakeven trades.
|
||||
pub breakeven_trades: usize,
|
||||
/// Win rate (as percentage).
|
||||
pub win_rate: f64,
|
||||
/// Average win amount.
|
||||
pub avg_win: f64,
|
||||
/// Average loss amount.
|
||||
pub avg_loss: f64,
|
||||
/// Largest win.
|
||||
pub largest_win: f64,
|
||||
/// Largest loss.
|
||||
pub largest_loss: f64,
|
||||
/// Total profit.
|
||||
pub total_profit: f64,
|
||||
/// Total loss.
|
||||
pub total_loss: f64,
|
||||
/// Net profit.
|
||||
pub net_profit: f64,
|
||||
/// Profit factor.
|
||||
pub profit_factor: f64,
|
||||
/// Expected value per trade.
|
||||
pub expectancy: f64,
|
||||
/// Average trade return percentage.
|
||||
pub avg_return_pct: f64,
|
||||
/// Average holding period (bars).
|
||||
pub avg_holding_period: f64,
|
||||
/// Max consecutive wins.
|
||||
pub max_consecutive_wins: usize,
|
||||
/// Max consecutive losses.
|
||||
pub max_consecutive_losses: usize,
|
||||
/// Average win/loss ratio.
|
||||
pub avg_win_loss_ratio: f64,
|
||||
/// Recovery factor (net profit / max loss).
|
||||
pub recovery_factor: f64,
|
||||
/// Payoff ratio (avg win / avg loss).
|
||||
pub payoff_ratio: f64,
|
||||
}
|
||||
|
||||
impl TradeStatistics {
|
||||
/// Calculate statistics from a list of trades.
|
||||
pub fn from_trades(trades: &[Trade]) -> Self {
|
||||
let mut stats = Self::default();
|
||||
|
||||
if trades.is_empty() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
stats.total_trades = trades.len();
|
||||
|
||||
// Categorize trades
|
||||
for trade in trades {
|
||||
if trade.pnl > 0.0 {
|
||||
stats.winning_trades += 1;
|
||||
stats.total_profit += trade.pnl;
|
||||
if trade.pnl > stats.largest_win {
|
||||
stats.largest_win = trade.pnl;
|
||||
}
|
||||
} else if trade.pnl < 0.0 {
|
||||
stats.losing_trades += 1;
|
||||
stats.total_loss += trade.pnl.abs();
|
||||
if trade.pnl.abs() > stats.largest_loss {
|
||||
stats.largest_loss = trade.pnl.abs();
|
||||
}
|
||||
} else {
|
||||
stats.breakeven_trades += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ratios
|
||||
stats.net_profit = stats.total_profit - stats.total_loss;
|
||||
|
||||
if stats.total_trades > 0 {
|
||||
stats.win_rate = stats.winning_trades as f64 / stats.total_trades as f64 * 100.0;
|
||||
}
|
||||
|
||||
if stats.winning_trades > 0 {
|
||||
stats.avg_win = stats.total_profit / stats.winning_trades as f64;
|
||||
}
|
||||
|
||||
if stats.losing_trades > 0 {
|
||||
stats.avg_loss = stats.total_loss / stats.losing_trades as f64;
|
||||
}
|
||||
|
||||
if stats.total_loss > 0.0 {
|
||||
stats.profit_factor = stats.total_profit / stats.total_loss;
|
||||
} else if stats.total_profit > 0.0 {
|
||||
stats.profit_factor = f64::INFINITY;
|
||||
}
|
||||
|
||||
if stats.avg_loss > 0.0 {
|
||||
stats.payoff_ratio = stats.avg_win / stats.avg_loss;
|
||||
}
|
||||
|
||||
// Expectancy
|
||||
if stats.total_trades > 0 {
|
||||
stats.expectancy = stats.net_profit / stats.total_trades as f64;
|
||||
}
|
||||
|
||||
// Average return percentage
|
||||
if stats.total_trades > 0 {
|
||||
stats.avg_return_pct =
|
||||
trades.iter().map(|t| t.return_pct).sum::<f64>() / stats.total_trades as f64;
|
||||
}
|
||||
|
||||
// Average holding period
|
||||
if stats.total_trades > 0 {
|
||||
stats.avg_holding_period = trades
|
||||
.iter()
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ stats.total_trades as f64;
|
||||
}
|
||||
|
||||
// Consecutive wins/losses
|
||||
let (max_wins, max_losses) = calculate_consecutive(trades);
|
||||
stats.max_consecutive_wins = max_wins;
|
||||
stats.max_consecutive_losses = max_losses;
|
||||
|
||||
// Recovery factor
|
||||
if stats.largest_loss > 0.0 {
|
||||
stats.recovery_factor = stats.net_profit / stats.largest_loss;
|
||||
}
|
||||
|
||||
// Win/loss ratio
|
||||
if stats.losing_trades > 0 {
|
||||
stats.avg_win_loss_ratio = stats.winning_trades as f64 / stats.losing_trades as f64;
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
/// Get summary as formatted string.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2} | Net: {:.2}",
|
||||
self.total_trades, self.win_rate, self.profit_factor, self.net_profit
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if strategy is profitable.
|
||||
pub fn is_profitable(&self) -> bool {
|
||||
self.net_profit > 0.0
|
||||
}
|
||||
|
||||
/// Get edge (expected value as percentage of average trade).
|
||||
pub fn edge(&self) -> f64 {
|
||||
if self.total_trades == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let avg_trade = self.net_profit / self.total_trades as f64;
|
||||
let avg_cost = (self.total_profit + self.total_loss) / self.total_trades as f64;
|
||||
if avg_cost > 0.0 {
|
||||
avg_trade / avg_cost * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate maximum consecutive wins and losses.
|
||||
fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
|
||||
let mut max_wins = 0;
|
||||
let mut max_losses = 0;
|
||||
let mut current_wins = 0;
|
||||
let mut current_losses = 0;
|
||||
|
||||
for trade in trades {
|
||||
if trade.pnl > 0.0 {
|
||||
current_wins += 1;
|
||||
current_losses = 0;
|
||||
max_wins = max_wins.max(current_wins);
|
||||
} else if trade.pnl < 0.0 {
|
||||
current_losses += 1;
|
||||
current_wins = 0;
|
||||
max_losses = max_losses.max(current_losses);
|
||||
}
|
||||
}
|
||||
|
||||
(max_wins, max_losses)
|
||||
}
|
||||
|
||||
/// Monthly returns breakdown.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MonthlyReturns {
|
||||
/// Year.
|
||||
pub year: i32,
|
||||
/// Month (1-12).
|
||||
pub month: u8,
|
||||
/// Return percentage.
|
||||
pub return_pct: f64,
|
||||
/// Number of trades.
|
||||
pub trade_count: usize,
|
||||
}
|
||||
|
||||
/// Calculate trade statistics by exit reason.
|
||||
pub fn stats_by_exit_reason(
|
||||
trades: &[Trade],
|
||||
) -> std::collections::HashMap<crate::core::types::ExitReason, TradeStatistics> {
|
||||
use crate::core::types::ExitReason;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut grouped: HashMap<ExitReason, Vec<&Trade>> = HashMap::new();
|
||||
|
||||
for trade in trades {
|
||||
grouped.entry(trade.exit_reason).or_default().push(trade);
|
||||
}
|
||||
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(reason, trade_refs)| {
|
||||
let owned_trades: Vec<Trade> = trade_refs.into_iter().cloned().collect();
|
||||
(reason, TradeStatistics::from_trades(&owned_trades))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate statistics for long vs short trades.
|
||||
pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) {
|
||||
use crate::core::types::Direction;
|
||||
|
||||
let long_trades: Vec<Trade> = trades
|
||||
.iter()
|
||||
.filter(|t| t.direction == Direction::Long)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let short_trades: Vec<Trade> = trades
|
||||
.iter()
|
||||
.filter(|t| t.direction == Direction::Short)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
(
|
||||
TradeStatistics::from_trades(&long_trades),
|
||||
TradeStatistics::from_trades(&short_trades),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::types::{Direction, ExitReason};
|
||||
|
||||
fn sample_trades() -> Vec<Trade> {
|
||||
vec![
|
||||
Trade {
|
||||
id: 1,
|
||||
symbol: "TEST".to_string(),
|
||||
entry_idx: 0,
|
||||
exit_idx: 5,
|
||||
entry_price: 100.0,
|
||||
exit_price: 110.0,
|
||||
size: 10.0,
|
||||
direction: Direction::Long,
|
||||
pnl: 100.0, // Win
|
||||
return_pct: 10.0,
|
||||
entry_time: 0,
|
||||
exit_time: 5,
|
||||
fees: 0.0,
|
||||
exit_reason: ExitReason::Signal,
|
||||
},
|
||||
Trade {
|
||||
id: 2,
|
||||
symbol: "TEST".to_string(),
|
||||
entry_idx: 10,
|
||||
exit_idx: 15,
|
||||
entry_price: 100.0,
|
||||
exit_price: 95.0,
|
||||
size: 10.0,
|
||||
direction: Direction::Long,
|
||||
pnl: -50.0, // Loss
|
||||
return_pct: -5.0,
|
||||
entry_time: 10,
|
||||
exit_time: 15,
|
||||
fees: 0.0,
|
||||
exit_reason: ExitReason::StopLoss,
|
||||
},
|
||||
Trade {
|
||||
id: 3,
|
||||
symbol: "TEST".to_string(),
|
||||
entry_idx: 20,
|
||||
exit_idx: 25,
|
||||
entry_price: 100.0,
|
||||
exit_price: 108.0,
|
||||
size: 10.0,
|
||||
direction: Direction::Long,
|
||||
pnl: 80.0, // Win
|
||||
return_pct: 8.0,
|
||||
entry_time: 20,
|
||||
exit_time: 25,
|
||||
fees: 0.0,
|
||||
exit_reason: ExitReason::TakeProfit,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_stats() {
|
||||
let trades = sample_trades();
|
||||
let stats = TradeStatistics::from_trades(&trades);
|
||||
|
||||
assert_eq!(stats.total_trades, 3);
|
||||
assert_eq!(stats.winning_trades, 2);
|
||||
assert_eq!(stats.losing_trades, 1);
|
||||
assert!((stats.win_rate - 66.67).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profit_calculations() {
|
||||
let trades = sample_trades();
|
||||
let stats = TradeStatistics::from_trades(&trades);
|
||||
|
||||
assert!((stats.total_profit - 180.0).abs() < 1e-10);
|
||||
assert!((stats.total_loss - 50.0).abs() < 1e-10);
|
||||
assert!((stats.net_profit - 130.0).abs() < 1e-10);
|
||||
assert!((stats.profit_factor - 3.6).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_consecutive() {
|
||||
let trades = sample_trades();
|
||||
let (max_wins, max_losses) = calculate_consecutive(&trades);
|
||||
|
||||
// W, L, W -> max consecutive wins = 1, max consecutive losses = 1
|
||||
assert_eq!(max_wins, 1);
|
||||
assert_eq!(max_losses, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stats_by_exit_reason() {
|
||||
let trades = sample_trades();
|
||||
let by_reason = stats_by_exit_reason(&trades);
|
||||
|
||||
// Should have 3 different exit reasons
|
||||
assert!(by_reason.contains_key(&ExitReason::Signal));
|
||||
assert!(by_reason.contains_key(&ExitReason::StopLoss));
|
||||
assert!(by_reason.contains_key(&ExitReason::TakeProfit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trades() {
|
||||
let stats = TradeStatistics::from_trades(&[]);
|
||||
|
||||
assert_eq!(stats.total_trades, 0);
|
||||
assert!((stats.win_rate - 0.0).abs() < 1e-10);
|
||||
assert!((stats.profit_factor - 0.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Capital allocation strategies for portfolio management.
|
||||
|
||||
/// Allocation strategy for distributing capital across instruments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AllocationStrategy {
|
||||
/// Equal weight across all instruments.
|
||||
EqualWeight,
|
||||
/// Fixed weight for each instrument.
|
||||
FixedWeight(Vec<f64>),
|
||||
/// Volatility-based weighting (inverse volatility).
|
||||
InverseVolatility,
|
||||
/// Risk parity (equal risk contribution).
|
||||
RiskParity,
|
||||
/// Maximum weight per instrument.
|
||||
MaxWeight(f64),
|
||||
/// Custom weights.
|
||||
Custom(Vec<(String, f64)>),
|
||||
}
|
||||
|
||||
impl Default for AllocationStrategy {
|
||||
fn default() -> Self {
|
||||
AllocationStrategy::EqualWeight
|
||||
}
|
||||
}
|
||||
|
||||
/// Capital allocator for managing position sizing and capital distribution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapitalAllocator {
|
||||
/// Total capital.
|
||||
pub total_capital: f64,
|
||||
/// Available capital (not in positions).
|
||||
pub available_capital: f64,
|
||||
/// Allocation strategy.
|
||||
pub strategy: AllocationStrategy,
|
||||
/// Maximum position size as fraction of capital.
|
||||
pub max_position_size: f64,
|
||||
/// Minimum position size (absolute).
|
||||
pub min_position_size: f64,
|
||||
/// Reserve capital fraction (never allocate).
|
||||
pub reserve_fraction: f64,
|
||||
}
|
||||
|
||||
impl CapitalAllocator {
|
||||
/// Create a new capital allocator.
|
||||
pub fn new(total_capital: f64) -> Self {
|
||||
Self {
|
||||
total_capital,
|
||||
available_capital: total_capital,
|
||||
strategy: AllocationStrategy::EqualWeight,
|
||||
max_position_size: 1.0,
|
||||
min_position_size: 0.0,
|
||||
reserve_fraction: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set allocation strategy.
|
||||
pub fn with_strategy(mut self, strategy: AllocationStrategy) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum position size.
|
||||
pub fn with_max_position(mut self, max_fraction: f64) -> Self {
|
||||
self.max_position_size = max_fraction.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set reserve fraction.
|
||||
pub fn with_reserve(mut self, reserve: f64) -> Self {
|
||||
self.reserve_fraction = reserve.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate position size for a single instrument.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Entry price
|
||||
/// * `num_instruments` - Total number of instruments in portfolio
|
||||
/// * `instrument_weight` - Optional custom weight for this instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Position size in shares/contracts
|
||||
pub fn calculate_position_size(
|
||||
&self,
|
||||
price: f64,
|
||||
num_instruments: usize,
|
||||
instrument_weight: Option<f64>,
|
||||
) -> f64 {
|
||||
if price <= 0.0 || num_instruments == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate allocatable capital
|
||||
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
|
||||
|
||||
// Calculate weight
|
||||
let weight = match &self.strategy {
|
||||
AllocationStrategy::EqualWeight => 1.0 / num_instruments as f64,
|
||||
AllocationStrategy::FixedWeight(weights) => {
|
||||
if weights.is_empty() {
|
||||
1.0 / num_instruments as f64
|
||||
} else {
|
||||
weights[0].min(self.max_position_size)
|
||||
}
|
||||
}
|
||||
AllocationStrategy::MaxWeight(max) => (*max).min(1.0 / num_instruments as f64),
|
||||
_ => instrument_weight.unwrap_or(1.0 / num_instruments as f64),
|
||||
};
|
||||
|
||||
// Calculate allocation
|
||||
let allocation = allocatable * weight.min(self.max_position_size);
|
||||
|
||||
// Convert to shares
|
||||
let shares = allocation / price;
|
||||
|
||||
// Apply minimum size constraint
|
||||
if shares * price < self.min_position_size {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
shares
|
||||
}
|
||||
|
||||
/// Calculate position sizes for multiple instruments.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `prices` - Entry prices for each instrument
|
||||
/// * `weights` - Optional weights for each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Position sizes for each instrument
|
||||
pub fn calculate_portfolio_sizes(&self, prices: &[f64], weights: Option<&[f64]>) -> Vec<f64> {
|
||||
let n = prices.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
|
||||
|
||||
// Get weights
|
||||
let instrument_weights: Vec<f64> = match &self.strategy {
|
||||
AllocationStrategy::EqualWeight => vec![1.0 / n as f64; n],
|
||||
AllocationStrategy::FixedWeight(w) => {
|
||||
if w.len() == n {
|
||||
w.clone()
|
||||
} else {
|
||||
vec![1.0 / n as f64; n]
|
||||
}
|
||||
}
|
||||
AllocationStrategy::MaxWeight(max) => {
|
||||
let equal = 1.0 / n as f64;
|
||||
vec![equal.min(*max); n]
|
||||
}
|
||||
_ => weights
|
||||
.map(|w| w.to_vec())
|
||||
.unwrap_or_else(|| vec![1.0 / n as f64; n]),
|
||||
};
|
||||
|
||||
// Normalize weights
|
||||
let total_weight: f64 = instrument_weights.iter().sum();
|
||||
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
|
||||
instrument_weights
|
||||
.iter()
|
||||
.map(|w| w / total_weight)
|
||||
.collect()
|
||||
} else {
|
||||
vec![1.0 / n as f64; n]
|
||||
};
|
||||
|
||||
// Calculate sizes
|
||||
prices
|
||||
.iter()
|
||||
.zip(normalized_weights.iter())
|
||||
.map(|(&price, &weight)| {
|
||||
if price <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let allocation = allocatable * weight.min(self.max_position_size);
|
||||
let shares = allocation / price;
|
||||
if shares * price < self.min_position_size {
|
||||
0.0
|
||||
} else {
|
||||
shares
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate volatility-adjusted position size.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Entry price
|
||||
/// * `volatility` - Instrument volatility (e.g., ATR)
|
||||
/// * `risk_per_trade` - Risk per trade as fraction of capital
|
||||
///
|
||||
/// # Returns
|
||||
/// Position size
|
||||
pub fn calculate_volatility_sized(
|
||||
&self,
|
||||
price: f64,
|
||||
volatility: f64,
|
||||
risk_per_trade: f64,
|
||||
) -> f64 {
|
||||
if price <= 0.0 || volatility <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let risk_amount = self.available_capital * risk_per_trade;
|
||||
let size = risk_amount / volatility;
|
||||
|
||||
// Apply maximum constraint
|
||||
let max_allocation = self.available_capital * self.max_position_size;
|
||||
let max_shares = max_allocation / price;
|
||||
|
||||
size.min(max_shares)
|
||||
}
|
||||
|
||||
/// Allocate capital to a position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amount` - Amount to allocate
|
||||
///
|
||||
/// # Returns
|
||||
/// True if allocation succeeded
|
||||
pub fn allocate(&mut self, amount: f64) -> bool {
|
||||
if amount > self.available_capital {
|
||||
return false;
|
||||
}
|
||||
self.available_capital -= amount;
|
||||
true
|
||||
}
|
||||
|
||||
/// Release capital from a closed position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amount` - Amount to release (including P&L)
|
||||
pub fn release(&mut self, amount: f64) {
|
||||
self.available_capital += amount;
|
||||
}
|
||||
|
||||
/// Update total capital (e.g., after deposit/withdrawal or daily mark-to-market).
|
||||
pub fn update_capital(&mut self, new_capital: f64) {
|
||||
let diff = new_capital - self.total_capital;
|
||||
self.total_capital = new_capital;
|
||||
self.available_capital += diff;
|
||||
}
|
||||
|
||||
/// Get current utilization rate.
|
||||
pub fn utilization(&self) -> f64 {
|
||||
if self.total_capital <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
1.0 - (self.available_capital / self.total_capital)
|
||||
}
|
||||
|
||||
/// Reset allocator to initial state.
|
||||
pub fn reset(&mut self) {
|
||||
self.available_capital = self.total_capital;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_equal_weight() {
|
||||
let allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
// 4 instruments, equal weight = 25% each
|
||||
let size = allocator.calculate_position_size(100.0, 4, None);
|
||||
|
||||
// Expected: 100000 * 0.25 / 100 = 250 shares
|
||||
assert!((size - 250.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_position() {
|
||||
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.1);
|
||||
|
||||
// Even with 1 instrument, max is 10%
|
||||
let size = allocator.calculate_position_size(100.0, 1, None);
|
||||
|
||||
// Expected: 100000 * 0.1 / 100 = 100 shares
|
||||
assert!((size - 100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_sizes() {
|
||||
let allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
let prices = vec![100.0, 50.0, 200.0];
|
||||
let sizes = allocator.calculate_portfolio_sizes(&prices, None);
|
||||
|
||||
assert_eq!(sizes.len(), 3);
|
||||
|
||||
// Equal weight, each gets 1/3 of capital
|
||||
// Instrument 1: 33333 / 100 = 333.33
|
||||
// Instrument 2: 33333 / 50 = 666.66
|
||||
// Instrument 3: 33333 / 200 = 166.66
|
||||
assert!((sizes[0] - 333.33).abs() < 1.0);
|
||||
assert!((sizes[1] - 666.66).abs() < 1.0);
|
||||
assert!((sizes[2] - 166.66).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_release() {
|
||||
let mut allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
// Allocate 30000
|
||||
assert!(allocator.allocate(30_000.0));
|
||||
assert!((allocator.available_capital - 70_000.0).abs() < 1e-10);
|
||||
|
||||
// Try to allocate more than available
|
||||
assert!(!allocator.allocate(80_000.0));
|
||||
|
||||
// Release with profit
|
||||
allocator.release(35_000.0);
|
||||
assert!((allocator.available_capital - 105_000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utilization() {
|
||||
let mut allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
assert!((allocator.utilization() - 0.0).abs() < 1e-10);
|
||||
|
||||
allocator.allocate(50_000.0);
|
||||
assert!((allocator.utilization() - 0.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volatility_sizing() {
|
||||
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.2);
|
||||
|
||||
// Risk 1% per trade with ATR of 2
|
||||
let size = allocator.calculate_volatility_sized(100.0, 2.0, 0.01);
|
||||
|
||||
// Risk amount: 100000 * 0.01 = 1000
|
||||
// Size: 1000 / 2 = 500 shares
|
||||
// Max: 100000 * 0.2 / 100 = 200 shares
|
||||
// Should be capped at max
|
||||
assert!((size - 200.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
//! Event-driven portfolio simulation engine.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
|
||||
OhlcvData, Price, StopConfig, TargetConfig, Trade,
|
||||
};
|
||||
use crate::execution::{FeeModel, FillPrice, SlippageModel};
|
||||
use crate::indicators::volatility::atr;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
use crate::portfolio::position::PositionManager;
|
||||
use crate::signals::processor::SignalProcessor;
|
||||
|
||||
/// Portfolio simulation engine.
|
||||
///
|
||||
/// Single-pass O(n) algorithm for simulating portfolio performance.
|
||||
#[derive(Debug)]
|
||||
pub struct PortfolioEngine {
|
||||
/// Configuration.
|
||||
pub config: BacktestConfig,
|
||||
/// Fee model.
|
||||
pub fee_model: FeeModel,
|
||||
/// Slippage model.
|
||||
pub slippage_model: SlippageModel,
|
||||
/// Fill price model.
|
||||
pub fill_price: FillPrice,
|
||||
/// Signal processor.
|
||||
pub signal_processor: SignalProcessor,
|
||||
}
|
||||
|
||||
impl Default for PortfolioEngine {
|
||||
fn default() -> Self {
|
||||
Self::new(BacktestConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl PortfolioEngine {
|
||||
/// Create a new portfolio engine with the given configuration.
|
||||
pub fn new(config: BacktestConfig) -> Self {
|
||||
let fee_model = FeeModel::percentage(config.fees);
|
||||
let fill_price = if config.upon_bar_close {
|
||||
FillPrice::Close
|
||||
} else {
|
||||
FillPrice::Open
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
fee_model,
|
||||
slippage_model: SlippageModel::None,
|
||||
fill_price,
|
||||
signal_processor: SignalProcessor::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fee model.
|
||||
pub fn with_fee_model(mut self, fee_model: FeeModel) -> Self {
|
||||
self.fee_model = fee_model;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set slippage model.
|
||||
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
|
||||
self.slippage_model = slippage_model;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run backtest on single instrument.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV data
|
||||
/// * `signals` - Compiled trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
|
||||
let n = ohlcv.len();
|
||||
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
|
||||
|
||||
// Clean signals
|
||||
let (entries, exits) = self
|
||||
.signal_processor
|
||||
.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut position = PositionManager::new(signals.symbol.clone());
|
||||
let mut cash = self.config.initial_capital;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
|
||||
// Pre-calculate ATR for ATR-based stops
|
||||
let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. })
|
||||
|| matches!(self.config.target, TargetConfig::Atr { .. })
|
||||
{
|
||||
let period = match self.config.stop {
|
||||
StopConfig::Atr { period, .. } => period,
|
||||
_ => match self.config.target {
|
||||
TargetConfig::Atr { period, .. } => period,
|
||||
_ => 14,
|
||||
},
|
||||
};
|
||||
atr(&ohlcv.high, &ohlcv.low, &ohlcv.close, period).unwrap_or_else(|_| vec![0.0; n])
|
||||
} else {
|
||||
vec![0.0; n]
|
||||
};
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let close = ohlcv.close[i];
|
||||
let high = ohlcv.high[i];
|
||||
let low = ohlcv.low[i];
|
||||
let timestamp = ohlcv.timestamps[i];
|
||||
|
||||
// Update position price tracking
|
||||
position.update_price(high, low);
|
||||
|
||||
// Check for exits first (stops and signals)
|
||||
if position.is_in_position() {
|
||||
let mut exit_reason: Option<ExitReason> = None;
|
||||
let mut exit_price = close;
|
||||
|
||||
// Check stop-loss
|
||||
if position.is_stop_hit(low, high) {
|
||||
exit_reason = Some(ExitReason::StopLoss);
|
||||
exit_price = position.position.stop_price.unwrap();
|
||||
|
||||
// Adjust for gap through stop
|
||||
match position.position.direction {
|
||||
Direction::Long => {
|
||||
if ohlcv.open[i] < exit_price {
|
||||
exit_price = ohlcv.open[i];
|
||||
}
|
||||
}
|
||||
Direction::Short => {
|
||||
if ohlcv.open[i] > exit_price {
|
||||
exit_price = ohlcv.open[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check take-profit
|
||||
if exit_reason.is_none() && position.is_target_hit(low, high) {
|
||||
exit_reason = Some(ExitReason::TakeProfit);
|
||||
exit_price = position.position.target_price.unwrap();
|
||||
}
|
||||
|
||||
// Check exit signal
|
||||
if exit_reason.is_none() && exits[i] {
|
||||
exit_reason = Some(ExitReason::Signal);
|
||||
exit_price = self.get_fill_price(ohlcv, i, signals.direction, false);
|
||||
}
|
||||
|
||||
// Execute exit
|
||||
if let Some(reason) = exit_reason {
|
||||
// Apply slippage
|
||||
exit_price = self.slippage_model.apply(
|
||||
exit_price,
|
||||
position.position.direction,
|
||||
false,
|
||||
Some(ohlcv.volume[i]),
|
||||
);
|
||||
|
||||
// Calculate fees
|
||||
let fees = self.fee_model.calculate(
|
||||
exit_price,
|
||||
position.position.size,
|
||||
position.position.direction,
|
||||
);
|
||||
|
||||
// Close position
|
||||
if let Some(trade) = position.close_position(
|
||||
i,
|
||||
timestamp,
|
||||
exit_price,
|
||||
ohlcv.timestamps[position.position.entry_idx],
|
||||
reason,
|
||||
fees,
|
||||
) {
|
||||
// Update cash
|
||||
let exit_value = exit_price * trade.size;
|
||||
cash += exit_value - fees;
|
||||
|
||||
// Track return for this trade
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
|
||||
trades.push(trade);
|
||||
}
|
||||
}
|
||||
|
||||
// Update trailing stop if position still open
|
||||
if position.is_in_position() {
|
||||
if let StopConfig::Trailing { percent } = self.config.stop {
|
||||
position.update_trailing_stop(percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entries
|
||||
if !position.is_in_position() && entries[i] {
|
||||
let entry_price = self.get_fill_price(ohlcv, i, signals.direction, true);
|
||||
|
||||
// Apply slippage
|
||||
let adjusted_price = self.slippage_model.apply(
|
||||
entry_price,
|
||||
signals.direction,
|
||||
true,
|
||||
Some(ohlcv.volume[i]),
|
||||
);
|
||||
|
||||
// Calculate position size
|
||||
// VectorBT formula: size = cash / (price * (1 + fees))
|
||||
// This ensures the position value plus entry fee equals available cash
|
||||
let fee_rate = self.config.fees;
|
||||
let size = if let Some(ref sizes) = signals.position_sizes {
|
||||
sizes[i] * cash / (adjusted_price * (1.0 + fee_rate))
|
||||
} else {
|
||||
cash / (adjusted_price * (1.0 + fee_rate))
|
||||
};
|
||||
|
||||
if size > 0.0 {
|
||||
// Calculate entry fees
|
||||
let entry_fees =
|
||||
self.fee_model
|
||||
.calculate(adjusted_price, size, signals.direction);
|
||||
|
||||
// Calculate stop and target prices
|
||||
let (stop_price, target_price) = self.calculate_stop_target(
|
||||
adjusted_price,
|
||||
signals.direction,
|
||||
&atr_values,
|
||||
i,
|
||||
);
|
||||
|
||||
// Open position (passing entry_fees for trade PnL tracking)
|
||||
position.open_position(
|
||||
i,
|
||||
timestamp,
|
||||
adjusted_price,
|
||||
size,
|
||||
signals.direction,
|
||||
stop_price,
|
||||
target_price,
|
||||
entry_fees,
|
||||
);
|
||||
|
||||
// Deduct cost
|
||||
cash -= adjusted_price * size + entry_fees;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate equity
|
||||
let position_value = if position.is_in_position() {
|
||||
close * position.position.size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Calculate drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any open position at end of data (no exit fees, matching VectorBT behavior)
|
||||
if position.is_in_position() {
|
||||
let last_idx = n - 1;
|
||||
let exit_price = ohlcv.close[last_idx];
|
||||
// No exit fees for EndOfData - position is marked-to-market but not actually closed
|
||||
// This matches VectorBT's behavior for "Open" trades
|
||||
let exit_fees = 0.0;
|
||||
|
||||
if let Some(trade) = position.close_position(
|
||||
last_idx,
|
||||
ohlcv.timestamps[last_idx],
|
||||
exit_price,
|
||||
ohlcv.timestamps[position.position.entry_idx],
|
||||
ExitReason::EndOfData,
|
||||
exit_fees,
|
||||
) {
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
trades.push(trade);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final metrics
|
||||
let metrics = self.calculate_metrics(
|
||||
&equity_curve,
|
||||
&drawdown_curve,
|
||||
&returns,
|
||||
&trades,
|
||||
&streaming,
|
||||
);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Get fill price based on model.
|
||||
fn get_fill_price(
|
||||
&self,
|
||||
ohlcv: &OhlcvData,
|
||||
idx: usize,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> Price {
|
||||
self.fill_price.get_price_from_arrays(
|
||||
ohlcv.open[idx],
|
||||
ohlcv.high[idx],
|
||||
ohlcv.low[idx],
|
||||
ohlcv.close[idx],
|
||||
direction,
|
||||
is_entry,
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate stop and target prices.
|
||||
fn calculate_stop_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
direction: Direction,
|
||||
atr_values: &[f64],
|
||||
idx: usize,
|
||||
) -> (Option<Price>, Option<Price>) {
|
||||
let multiplier = direction.multiplier();
|
||||
|
||||
// Calculate stop price
|
||||
let stop_price = match self.config.stop {
|
||||
StopConfig::None => None,
|
||||
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
||||
StopConfig::Atr { multiplier: m, .. } => {
|
||||
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
|
||||
if atr > 0.0 {
|
||||
Some(entry_price - multiplier * m * atr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StopConfig::Trailing { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
||||
};
|
||||
|
||||
// Calculate target price
|
||||
let target_price = match self.config.target {
|
||||
TargetConfig::None => None,
|
||||
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
|
||||
TargetConfig::Atr { multiplier: m, .. } => {
|
||||
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
|
||||
if atr > 0.0 {
|
||||
Some(entry_price + multiplier * m * atr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
TargetConfig::RiskReward { ratio } => {
|
||||
if let Some(stop) = stop_price {
|
||||
let risk = (entry_price - stop).abs();
|
||||
Some(entry_price + multiplier * risk * ratio)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(stop_price, target_price)
|
||||
}
|
||||
|
||||
/// Calculate backtest metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
returns: &[f64],
|
||||
trades: &[Trade],
|
||||
_streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
// Calculate max drawdown duration
|
||||
let max_drawdown_duration = self.calculate_max_drawdown_duration(drawdown_curve);
|
||||
|
||||
// Trade statistics
|
||||
let total_trades = trades.len();
|
||||
|
||||
// Separate closed vs open trades (EndOfData means still open)
|
||||
let total_open_trades = trades
|
||||
.iter()
|
||||
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.count();
|
||||
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
|
||||
|
||||
// Open trade PnL
|
||||
let open_trade_pnl: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.map(|t| t.pnl)
|
||||
.sum();
|
||||
|
||||
// Only count closed trades for win/loss statistics
|
||||
let closed_trades: Vec<_> = trades
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.collect();
|
||||
|
||||
let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_closed_trades > 0 {
|
||||
winning_trades as f64 / total_closed_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Total fees paid
|
||||
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
|
||||
|
||||
// Best and worst trade
|
||||
let best_trade_pct = trades
|
||||
.iter()
|
||||
.map(|t| t.return_pct)
|
||||
.fold(f64::NEG_INFINITY, |a, b| a.max(b));
|
||||
let best_trade_pct = if best_trade_pct.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
best_trade_pct
|
||||
};
|
||||
|
||||
let worst_trade_pct = trades
|
||||
.iter()
|
||||
.map(|t| t.return_pct)
|
||||
.fold(f64::INFINITY, |a, b| a.min(b));
|
||||
let worst_trade_pct = if worst_trade_pct.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
worst_trade_pct
|
||||
};
|
||||
|
||||
// Profit factor (based on closed trades)
|
||||
let gross_profit: f64 = closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.pnl)
|
||||
.sum();
|
||||
let gross_loss: f64 = closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Expectancy = average trade PnL
|
||||
let expectancy = if total_closed_trades > 0 {
|
||||
closed_trades.iter().map(|t| t.pnl).sum::<f64>() / total_closed_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// SQN = (Expectancy / StdDev of trade PnL) * sqrt(total trades)
|
||||
let sqn = if total_closed_trades > 1 {
|
||||
let trade_pnls: Vec<f64> = closed_trades.iter().map(|t| t.pnl).collect();
|
||||
let mean = expectancy;
|
||||
let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::<f64>()
|
||||
/ (total_closed_trades - 1) as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
if std_dev > 0.0 {
|
||||
(mean / std_dev) * (total_closed_trades as f64).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Average returns
|
||||
let avg_trade_return_pct = if total_trades > 0 {
|
||||
trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_win_pct = if winning_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.return_pct)
|
||||
.sum::<f64>()
|
||||
/ winning_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_loss_pct = if losing_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.return_pct)
|
||||
.sum::<f64>()
|
||||
/ losing_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Average winning/losing trade duration
|
||||
let avg_winning_duration = if winning_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ winning_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_losing_duration = if losing_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ losing_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Consecutive wins/losses
|
||||
let (max_consecutive_wins, max_consecutive_losses) = self.calculate_consecutive(trades);
|
||||
|
||||
// Holding period
|
||||
let avg_holding_period = if total_trades > 0 {
|
||||
trades
|
||||
.iter()
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ total_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Exposure (time in market)
|
||||
let bars_in_position: usize = trades.iter().map(|t| t.holding_period()).sum();
|
||||
let exposure_pct = if !equity_curve.is_empty() {
|
||||
bars_in_position as f64 / equity_curve.len() as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
|
||||
// This matches VectorBT's calculation methodology
|
||||
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
|
||||
|
||||
// Calmar ratio: CAGR / max drawdown
|
||||
// VectorBT uses Compound Annual Growth Rate (CAGR)
|
||||
let num_periods = equity_curve.len().max(1) as f64;
|
||||
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
||||
let total_return_frac = total_return_pct / 100.0;
|
||||
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
|
||||
let cagr = if years > 0.0 {
|
||||
(1.0 + total_return_frac).powf(1.0 / years) - 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
||||
cagr / (max_drawdown_pct / 100.0) // Both as fractions
|
||||
} else if total_return_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
sortino_ratio,
|
||||
calmar_ratio,
|
||||
omega_ratio,
|
||||
max_drawdown_pct,
|
||||
max_drawdown_duration,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
expectancy,
|
||||
sqn,
|
||||
total_trades,
|
||||
total_closed_trades,
|
||||
total_open_trades,
|
||||
open_trade_pnl,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
total_fees_paid,
|
||||
best_trade_pct,
|
||||
worst_trade_pct,
|
||||
avg_trade_return_pct,
|
||||
avg_win_pct,
|
||||
avg_loss_pct,
|
||||
avg_winning_duration,
|
||||
avg_losing_duration,
|
||||
max_consecutive_wins,
|
||||
max_consecutive_losses,
|
||||
avg_holding_period,
|
||||
exposure_pct,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate max drawdown duration from drawdown curve.
|
||||
fn calculate_max_drawdown_duration(&self, drawdown_curve: &[f64]) -> usize {
|
||||
let mut max_duration = 0;
|
||||
let mut current_duration = 0;
|
||||
|
||||
for &dd in drawdown_curve {
|
||||
if dd > 0.0 {
|
||||
current_duration += 1;
|
||||
max_duration = max_duration.max(current_duration);
|
||||
} else {
|
||||
current_duration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
max_duration
|
||||
}
|
||||
|
||||
/// Calculate max consecutive wins and losses.
|
||||
fn calculate_consecutive(&self, trades: &[Trade]) -> (usize, usize) {
|
||||
let mut max_wins = 0;
|
||||
let mut max_losses = 0;
|
||||
let mut current_wins = 0;
|
||||
let mut current_losses = 0;
|
||||
|
||||
for trade in trades {
|
||||
if trade.pnl > 0.0 {
|
||||
current_wins += 1;
|
||||
current_losses = 0;
|
||||
max_wins = max_wins.max(current_wins);
|
||||
} else if trade.pnl < 0.0 {
|
||||
current_losses += 1;
|
||||
current_wins = 0;
|
||||
max_losses = max_losses.max(current_losses);
|
||||
}
|
||||
}
|
||||
|
||||
(max_wins, max_losses)
|
||||
}
|
||||
|
||||
/// Calculate risk-adjusted metrics from daily portfolio returns.
|
||||
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
|
||||
/// Uses 365 days for annualization to match VectorBT.
|
||||
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
|
||||
if returns.len() < 2 {
|
||||
return (0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// VectorBT uses 365 days (calendar days) for annualization
|
||||
let periods_per_year: f64 = 365.0;
|
||||
let _n = returns.len() as f64;
|
||||
|
||||
// Filter out NaN values
|
||||
let valid_returns: Vec<f64> = returns.iter().filter(|r| !r.is_nan()).copied().collect();
|
||||
|
||||
if valid_returns.len() < 2 {
|
||||
return (0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
let n_valid = valid_returns.len() as f64;
|
||||
|
||||
// Calculate mean return
|
||||
let mean = valid_returns.iter().sum::<f64>() / n_valid;
|
||||
|
||||
// Calculate standard deviation
|
||||
let variance = valid_returns
|
||||
.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (n_valid - 1.0);
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
|
||||
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
|
||||
let sharpe_ratio = if std_dev > 0.0 {
|
||||
(mean / std_dev) * periods_per_year.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Sortino Ratio - uses downside deviation (only negative returns)
|
||||
let downside_returns: Vec<f64> = valid_returns
|
||||
.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let downside_variance = if !downside_returns.is_empty() {
|
||||
downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / n_valid // Divide by total count, not downside count
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let downside_std = downside_variance.sqrt();
|
||||
|
||||
let sortino_ratio = if downside_std > 0.0 {
|
||||
(mean / downside_std) * periods_per_year.sqrt()
|
||||
} else if mean > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
|
||||
// With threshold = 0
|
||||
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
|
||||
let sum_negative: f64 = valid_returns
|
||||
.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.map(|r| r.abs())
|
||||
.sum();
|
||||
|
||||
let omega_ratio = if sum_negative > 0.0 {
|
||||
sum_positive / sum_negative
|
||||
} else if sum_positive > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
(sharpe_ratio, sortino_ratio, omega_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_ohlcv() -> OhlcvData {
|
||||
OhlcvData {
|
||||
timestamps: (0..20).map(|i| i as i64).collect(),
|
||||
open: vec![
|
||||
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
|
||||
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
|
||||
],
|
||||
high: vec![
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
|
||||
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
|
||||
],
|
||||
low: vec![
|
||||
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
|
||||
],
|
||||
close: vec![
|
||||
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
|
||||
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
|
||||
],
|
||||
volume: vec![1000.0; 20],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_signals() -> CompiledSignals {
|
||||
CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![
|
||||
false, true, false, false, false, false, false, false, false, false, false, true,
|
||||
false, false, false, false, false, false, false, false,
|
||||
],
|
||||
exits: vec![
|
||||
false, false, false, false, false, true, false, false, false, false, false, false,
|
||||
false, false, false, true, false, false, false, false,
|
||||
],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_backtest() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = sample_signals();
|
||||
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 2 trades
|
||||
assert_eq!(result.trades.len(), 2);
|
||||
|
||||
// First trade: entry at 101.5, exit at 105.0
|
||||
let trade1 = &result.trades[0];
|
||||
assert!((trade1.entry_price - 101.5).abs() < 1e-10);
|
||||
assert!((trade1.exit_price - 105.0).abs() < 1e-10);
|
||||
assert!(trade1.pnl > 0.0); // Profitable
|
||||
|
||||
// Equity curve should have correct length
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_fees() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.001, // 0.1%
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = sample_signals();
|
||||
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Trades should have fees deducted
|
||||
for trade in &result.trades {
|
||||
assert!(trade.fees > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_stop_loss() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
|
||||
// Create data where stop would be hit
|
||||
let mut ohlcv = sample_ohlcv();
|
||||
// Add a big drop after entry
|
||||
ohlcv.low[3] = 95.0; // Big drop
|
||||
ohlcv.close[3] = 96.0;
|
||||
|
||||
let signals = sample_signals();
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// First trade should exit on stop loss
|
||||
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Portfolio simulation engine for RaptorBT.
|
||||
|
||||
pub mod allocation;
|
||||
pub mod engine;
|
||||
pub mod position;
|
||||
|
||||
pub use allocation::{AllocationStrategy, CapitalAllocator};
|
||||
pub use engine::PortfolioEngine;
|
||||
pub use position::PositionManager;
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Position tracking for portfolio management.
|
||||
|
||||
use crate::core::types::{Direction, ExitReason, Position, Price, Timestamp, Trade};
|
||||
|
||||
/// Position manager for tracking open positions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionManager {
|
||||
/// Current position state.
|
||||
pub position: Position,
|
||||
/// Trade counter for generating unique IDs.
|
||||
trade_counter: u64,
|
||||
/// Symbol being traded.
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
impl PositionManager {
|
||||
/// Create a new position manager.
|
||||
pub fn new(symbol: String) -> Self {
|
||||
Self {
|
||||
position: Position::new(),
|
||||
trade_counter: 0,
|
||||
symbol,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently in a position.
|
||||
#[inline]
|
||||
pub fn is_in_position(&self) -> bool {
|
||||
self.position.is_open
|
||||
}
|
||||
|
||||
/// Get current position direction.
|
||||
pub fn current_direction(&self) -> Option<Direction> {
|
||||
if self.position.is_open {
|
||||
Some(self.position.direction)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a new position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `idx` - Bar index
|
||||
/// * `timestamp` - Entry timestamp
|
||||
/// * `price` - Entry price
|
||||
/// * `size` - Position size
|
||||
/// * `direction` - Trade direction
|
||||
/// * `stop_price` - Optional stop-loss price
|
||||
/// * `target_price` - Optional take-profit price
|
||||
/// * `entry_fees` - Entry fees (to track for PnL calculation)
|
||||
///
|
||||
/// # Returns
|
||||
/// True if position was opened, false if already in position
|
||||
pub fn open_position(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
_timestamp: Timestamp,
|
||||
price: Price,
|
||||
size: f64,
|
||||
direction: Direction,
|
||||
stop_price: Option<Price>,
|
||||
target_price: Option<Price>,
|
||||
entry_fees: f64,
|
||||
) -> bool {
|
||||
if self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.position.open(
|
||||
idx,
|
||||
price,
|
||||
size,
|
||||
direction,
|
||||
stop_price,
|
||||
target_price,
|
||||
entry_fees,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Close current position and generate a trade record.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `idx` - Bar index
|
||||
/// * `timestamp` - Exit timestamp
|
||||
/// * `price` - Exit price
|
||||
/// * `entry_timestamp` - Entry timestamp (for trade record)
|
||||
/// * `exit_reason` - Reason for exit
|
||||
/// * `fees` - Transaction fees
|
||||
///
|
||||
/// # Returns
|
||||
/// Trade record if position was closed, None if no position
|
||||
pub fn close_position(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
timestamp: Timestamp,
|
||||
price: Price,
|
||||
entry_timestamp: Timestamp,
|
||||
exit_reason: ExitReason,
|
||||
fees: f64,
|
||||
) -> Option<Trade> {
|
||||
if !self.position.is_open {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trade = self.create_trade(idx, timestamp, price, entry_timestamp, exit_reason, fees);
|
||||
self.position.close();
|
||||
self.trade_counter += 1;
|
||||
|
||||
Some(trade)
|
||||
}
|
||||
|
||||
/// Create a trade record from current position.
|
||||
fn create_trade(
|
||||
&self,
|
||||
exit_idx: usize,
|
||||
exit_timestamp: Timestamp,
|
||||
exit_price: Price,
|
||||
entry_timestamp: Timestamp,
|
||||
exit_reason: ExitReason,
|
||||
exit_fees: f64,
|
||||
) -> Trade {
|
||||
let pos = &self.position;
|
||||
let multiplier = pos.direction.multiplier();
|
||||
|
||||
// Calculate P&L (matching VectorBT: gross - entry_fees - exit_fees)
|
||||
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
|
||||
let total_fees = pos.entry_fees + exit_fees;
|
||||
let pnl = gross_pnl - total_fees;
|
||||
|
||||
// Calculate return percentage
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Trade {
|
||||
id: self.trade_counter,
|
||||
symbol: self.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: pos.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: entry_timestamp,
|
||||
exit_time: exit_timestamp,
|
||||
fees: total_fees,
|
||||
exit_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update position with new price data (for trailing stops).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - Current bar high
|
||||
/// * `low` - Current bar low
|
||||
pub fn update_price(&mut self, high: Price, low: Price) {
|
||||
if self.position.is_open {
|
||||
self.position.update_extremes(high, low);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate unrealized P&L at current price.
|
||||
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
|
||||
self.position.unrealized_pnl(current_price)
|
||||
}
|
||||
|
||||
/// Get current position value (market value of position).
|
||||
pub fn position_value(&self, current_price: Price) -> f64 {
|
||||
if !self.position.is_open {
|
||||
return 0.0;
|
||||
}
|
||||
current_price * self.position.size
|
||||
}
|
||||
|
||||
/// Calculate position exposure (notional value as fraction of given capital).
|
||||
pub fn exposure(&self, current_price: Price, capital: f64) -> f64 {
|
||||
if capital <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.position_value(current_price) / capital
|
||||
}
|
||||
|
||||
/// Check if stop-loss is hit.
|
||||
pub fn is_stop_hit(&self, low: Price, high: Price) -> bool {
|
||||
if !self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(stop) = self.position.stop_price {
|
||||
match self.position.direction {
|
||||
Direction::Long => low <= stop,
|
||||
Direction::Short => high >= stop,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if take-profit is hit.
|
||||
pub fn is_target_hit(&self, low: Price, high: Price) -> bool {
|
||||
if !self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(target) = self.position.target_price {
|
||||
match self.position.direction {
|
||||
Direction::Long => high >= target,
|
||||
Direction::Short => low <= target,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Update trailing stop.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `trail_percent` - Trailing stop percentage
|
||||
pub fn update_trailing_stop(&mut self, trail_percent: f64) {
|
||||
if !self.position.is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.position.direction {
|
||||
Direction::Long => {
|
||||
// Trail below highest price since entry
|
||||
let new_stop = self.position.highest_since_entry * (1.0 - trail_percent);
|
||||
if let Some(current_stop) = self.position.stop_price {
|
||||
if new_stop > current_stop {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
} else {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
}
|
||||
Direction::Short => {
|
||||
// Trail above lowest price since entry
|
||||
let new_stop = self.position.lowest_since_entry * (1.0 + trail_percent);
|
||||
if let Some(current_stop) = self.position.stop_price {
|
||||
if new_stop < current_stop {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
} else {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset position manager for new backtest.
|
||||
pub fn reset(&mut self) {
|
||||
self.position = Position::new();
|
||||
self.trade_counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_open_close_position() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
// Open position
|
||||
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None));
|
||||
assert!(pm.is_in_position());
|
||||
|
||||
// Try to open another - should fail
|
||||
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None));
|
||||
|
||||
// Close position with profit
|
||||
let trade = pm
|
||||
.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0)
|
||||
.unwrap();
|
||||
|
||||
assert!(!pm.is_in_position());
|
||||
assert_eq!(trade.entry_idx, 0);
|
||||
assert_eq!(trade.exit_idx, 5);
|
||||
assert!((trade.entry_price - 100.0).abs() < 1e-10);
|
||||
assert!((trade.exit_price - 110.0).abs() < 1e-10);
|
||||
|
||||
// P&L: (110 - 100) * 10 - 2 = 98
|
||||
assert!((trade.pnl - 98.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_position() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None);
|
||||
|
||||
// Close with profit (price went down)
|
||||
let trade = pm
|
||||
.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0)
|
||||
.unwrap();
|
||||
|
||||
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
|
||||
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
|
||||
assert!((trade.pnl - 98.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_loss() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(
|
||||
0,
|
||||
1000,
|
||||
100.0,
|
||||
10.0,
|
||||
Direction::Long,
|
||||
Some(95.0), // Stop at 95
|
||||
None,
|
||||
);
|
||||
|
||||
// Check stop not hit
|
||||
assert!(!pm.is_stop_hit(96.0, 102.0));
|
||||
|
||||
// Check stop hit
|
||||
assert!(pm.is_stop_hit(94.0, 102.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_stop() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
||||
|
||||
// Update with higher price
|
||||
pm.update_price(110.0, 98.0);
|
||||
pm.update_trailing_stop(0.05); // 5% trail
|
||||
|
||||
// Stop should be at 110 * 0.95 = 104.5
|
||||
assert!((pm.position.stop_price.unwrap() - 104.5).abs() < 1e-10);
|
||||
|
||||
// Update with even higher price
|
||||
pm.update_price(120.0, 108.0);
|
||||
pm.update_trailing_stop(0.05);
|
||||
|
||||
// Stop should move up to 120 * 0.95 = 114
|
||||
assert!((pm.position.stop_price.unwrap() - 114.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unrealized_pnl() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
||||
|
||||
// Price up
|
||||
let pnl = pm.unrealized_pnl(110.0);
|
||||
assert!((pnl - 100.0).abs() < 1e-10); // (110 - 100) * 10 = 100
|
||||
|
||||
// Price down
|
||||
let pnl = pm.unrealized_pnl(95.0);
|
||||
assert!((pnl - (-50.0)).abs() < 1e-10); // (95 - 100) * 10 = -50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
//! PyO3 function bindings for RaptorBT.
|
||||
|
||||
use numpy::{PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
|
||||
};
|
||||
use crate::indicators;
|
||||
use crate::signals::synchronizer::SyncMode;
|
||||
use crate::strategies::basket::{BasketBacktest, BasketConfig};
|
||||
use crate::strategies::multi::{CombineMode, MultiStrategyBacktest, MultiStrategyConfig};
|
||||
use crate::strategies::options::{
|
||||
OptionType, OptionsBacktest, OptionsConfig, SizeType, StrikeSelection,
|
||||
};
|
||||
use crate::strategies::pairs::{PairsBacktest, PairsConfig};
|
||||
use crate::strategies::single::SingleBacktest;
|
||||
|
||||
use super::numpy_bridge::*;
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Classes
|
||||
// ============================================================================
|
||||
|
||||
/// Python-exposed backtest configuration.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBacktestConfig {
|
||||
#[pyo3(get, set)]
|
||||
pub initial_capital: f64,
|
||||
#[pyo3(get, set)]
|
||||
pub fees: f64,
|
||||
#[pyo3(get, set)]
|
||||
pub slippage: f64,
|
||||
#[pyo3(get, set)]
|
||||
pub upon_bar_close: bool,
|
||||
stop_config: StopConfig,
|
||||
target_config: TargetConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBacktestConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (initial_capital=100000.0, fees=0.001, slippage=0.0, upon_bar_close=true))]
|
||||
fn new(initial_capital: f64, fees: f64, slippage: f64, upon_bar_close: bool) -> Self {
|
||||
Self {
|
||||
initial_capital,
|
||||
fees,
|
||||
slippage,
|
||||
upon_bar_close,
|
||||
stop_config: StopConfig::None,
|
||||
target_config: TargetConfig::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fixed percentage stop-loss.
|
||||
fn set_fixed_stop(&mut self, percent: f64) {
|
||||
self.stop_config = StopConfig::Fixed { percent };
|
||||
}
|
||||
|
||||
/// Set ATR-based stop-loss.
|
||||
fn set_atr_stop(&mut self, multiplier: f64, period: usize) {
|
||||
self.stop_config = StopConfig::Atr { multiplier, period };
|
||||
}
|
||||
|
||||
/// Set trailing stop-loss.
|
||||
fn set_trailing_stop(&mut self, percent: f64) {
|
||||
self.stop_config = StopConfig::Trailing { percent };
|
||||
}
|
||||
|
||||
/// Set fixed percentage take-profit.
|
||||
fn set_fixed_target(&mut self, percent: f64) {
|
||||
self.target_config = TargetConfig::Fixed { percent };
|
||||
}
|
||||
|
||||
/// Set ATR-based take-profit.
|
||||
fn set_atr_target(&mut self, multiplier: f64, period: usize) {
|
||||
self.target_config = TargetConfig::Atr { multiplier, period };
|
||||
}
|
||||
|
||||
/// Set risk-reward based take-profit.
|
||||
fn set_risk_reward_target(&mut self, ratio: f64) {
|
||||
self.target_config = TargetConfig::RiskReward { ratio };
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PyBacktestConfig> for BacktestConfig {
|
||||
fn from(py_config: &PyBacktestConfig) -> Self {
|
||||
BacktestConfig {
|
||||
initial_capital: py_config.initial_capital,
|
||||
fees: py_config.fees,
|
||||
slippage: py_config.slippage,
|
||||
stop: py_config.stop_config,
|
||||
target: py_config.target_config,
|
||||
upon_bar_close: py_config.upon_bar_close,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Python-exposed stop configuration.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyStopConfig {
|
||||
#[pyo3(get, set)]
|
||||
pub stop_type: String,
|
||||
#[pyo3(get, set)]
|
||||
pub percent: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub multiplier: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub period: Option<usize>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyStopConfig {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stop_type: "none".to_string(),
|
||||
percent: None,
|
||||
multiplier: None,
|
||||
period: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn fixed(percent: f64) -> Self {
|
||||
Self {
|
||||
stop_type: "fixed".to_string(),
|
||||
percent: Some(percent),
|
||||
multiplier: None,
|
||||
period: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn atr(multiplier: f64, period: usize) -> Self {
|
||||
Self {
|
||||
stop_type: "atr".to_string(),
|
||||
percent: None,
|
||||
multiplier: Some(multiplier),
|
||||
period: Some(period),
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn trailing(percent: f64) -> Self {
|
||||
Self {
|
||||
stop_type: "trailing".to_string(),
|
||||
percent: Some(percent),
|
||||
multiplier: None,
|
||||
period: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Python-exposed target configuration.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyTargetConfig {
|
||||
#[pyo3(get, set)]
|
||||
pub target_type: String,
|
||||
#[pyo3(get, set)]
|
||||
pub percent: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub multiplier: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub period: Option<usize>,
|
||||
#[pyo3(get, set)]
|
||||
pub ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTargetConfig {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
target_type: "none".to_string(),
|
||||
percent: None,
|
||||
multiplier: None,
|
||||
period: None,
|
||||
ratio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn fixed(percent: f64) -> Self {
|
||||
Self {
|
||||
target_type: "fixed".to_string(),
|
||||
percent: Some(percent),
|
||||
multiplier: None,
|
||||
period: None,
|
||||
ratio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn atr(multiplier: f64, period: usize) -> Self {
|
||||
Self {
|
||||
target_type: "atr".to_string(),
|
||||
percent: None,
|
||||
multiplier: Some(multiplier),
|
||||
period: Some(period),
|
||||
ratio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn risk_reward(ratio: f64) -> Self {
|
||||
Self {
|
||||
target_type: "risk_reward".to_string(),
|
||||
percent: None,
|
||||
multiplier: None,
|
||||
period: None,
|
||||
ratio: Some(ratio),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Result Classes
|
||||
// ============================================================================
|
||||
|
||||
/// Python-exposed trade.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyTrade {
|
||||
#[pyo3(get)]
|
||||
pub id: u64,
|
||||
#[pyo3(get)]
|
||||
pub symbol: String,
|
||||
#[pyo3(get)]
|
||||
pub entry_idx: usize,
|
||||
#[pyo3(get)]
|
||||
pub exit_idx: usize,
|
||||
#[pyo3(get)]
|
||||
pub entry_price: f64,
|
||||
#[pyo3(get)]
|
||||
pub exit_price: f64,
|
||||
#[pyo3(get)]
|
||||
pub size: f64,
|
||||
#[pyo3(get)]
|
||||
pub direction: i32,
|
||||
#[pyo3(get)]
|
||||
pub pnl: f64,
|
||||
#[pyo3(get)]
|
||||
pub return_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub entry_time: i64,
|
||||
#[pyo3(get)]
|
||||
pub exit_time: i64,
|
||||
#[pyo3(get)]
|
||||
pub fees: f64,
|
||||
#[pyo3(get)]
|
||||
pub exit_reason: String,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTrade {
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Trade(symbol={}, entry={:.2}, exit={:.2}, pnl={:.2}, return={:.2}%)",
|
||||
self.symbol, self.entry_price, self.exit_price, self.pnl, self.return_pct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Python-exposed backtest metrics.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBacktestMetrics {
|
||||
#[pyo3(get)]
|
||||
pub total_return_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub sharpe_ratio: f64,
|
||||
#[pyo3(get)]
|
||||
pub sortino_ratio: f64,
|
||||
#[pyo3(get)]
|
||||
pub calmar_ratio: f64,
|
||||
#[pyo3(get)]
|
||||
pub omega_ratio: f64,
|
||||
#[pyo3(get)]
|
||||
pub max_drawdown_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub max_drawdown_duration: usize,
|
||||
#[pyo3(get)]
|
||||
pub win_rate_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub profit_factor: f64,
|
||||
#[pyo3(get)]
|
||||
pub expectancy: f64,
|
||||
#[pyo3(get)]
|
||||
pub sqn: f64,
|
||||
#[pyo3(get)]
|
||||
pub total_trades: usize,
|
||||
#[pyo3(get)]
|
||||
pub total_closed_trades: usize,
|
||||
#[pyo3(get)]
|
||||
pub total_open_trades: usize,
|
||||
#[pyo3(get)]
|
||||
pub open_trade_pnl: f64,
|
||||
#[pyo3(get)]
|
||||
pub winning_trades: usize,
|
||||
#[pyo3(get)]
|
||||
pub losing_trades: usize,
|
||||
#[pyo3(get)]
|
||||
pub start_value: f64,
|
||||
#[pyo3(get)]
|
||||
pub end_value: f64,
|
||||
#[pyo3(get)]
|
||||
pub total_fees_paid: f64,
|
||||
#[pyo3(get)]
|
||||
pub best_trade_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub worst_trade_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub avg_trade_return_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub avg_win_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub avg_loss_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub avg_winning_duration: f64,
|
||||
#[pyo3(get)]
|
||||
pub avg_losing_duration: f64,
|
||||
#[pyo3(get)]
|
||||
pub max_consecutive_wins: usize,
|
||||
#[pyo3(get)]
|
||||
pub max_consecutive_losses: usize,
|
||||
#[pyo3(get)]
|
||||
pub avg_holding_period: f64,
|
||||
#[pyo3(get)]
|
||||
pub exposure_pct: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBacktestMetrics {
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"BacktestMetrics(return={:.2}%, sharpe={:.2}, max_dd={:.2}%, trades={})",
|
||||
self.total_return_pct, self.sharpe_ratio, self.max_drawdown_pct, self.total_trades
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert to dictionary matching VectorBT stats() format.
|
||||
fn to_dict(&self, py: Python) -> PyResult<PyObject> {
|
||||
let dict = pyo3::types::PyDict::new(py);
|
||||
dict.set_item("Start Value", self.start_value)?;
|
||||
dict.set_item("End Value", self.end_value)?;
|
||||
dict.set_item("Total Return [%]", self.total_return_pct)?;
|
||||
dict.set_item("Total Fees Paid", self.total_fees_paid)?;
|
||||
dict.set_item("Max Drawdown [%]", self.max_drawdown_pct)?;
|
||||
dict.set_item("Max Drawdown Duration", self.max_drawdown_duration)?;
|
||||
dict.set_item("Total Trades", self.total_trades)?;
|
||||
dict.set_item("Total Closed Trades", self.total_closed_trades)?;
|
||||
dict.set_item("Total Open Trades", self.total_open_trades)?;
|
||||
dict.set_item("Open Trade PnL", self.open_trade_pnl)?;
|
||||
dict.set_item("Win Rate [%]", self.win_rate_pct)?;
|
||||
dict.set_item("Best Trade [%]", self.best_trade_pct)?;
|
||||
dict.set_item("Worst Trade [%]", self.worst_trade_pct)?;
|
||||
dict.set_item("Avg Winning Trade [%]", self.avg_win_pct)?;
|
||||
dict.set_item("Avg Losing Trade [%]", self.avg_loss_pct)?;
|
||||
dict.set_item("Avg Winning Trade Duration", self.avg_winning_duration)?;
|
||||
dict.set_item("Avg Losing Trade Duration", self.avg_losing_duration)?;
|
||||
dict.set_item("Profit Factor", self.profit_factor)?;
|
||||
dict.set_item("Expectancy", self.expectancy)?;
|
||||
dict.set_item("SQN", self.sqn)?;
|
||||
dict.set_item("Sharpe Ratio", self.sharpe_ratio)?;
|
||||
dict.set_item("Sortino Ratio", self.sortino_ratio)?;
|
||||
dict.set_item("Calmar Ratio", self.calmar_ratio)?;
|
||||
dict.set_item("Omega Ratio", self.omega_ratio)?;
|
||||
Ok(dict.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Python-exposed backtest result.
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBacktestResult {
|
||||
#[pyo3(get)]
|
||||
pub metrics: PyBacktestMetrics,
|
||||
equity_curve: Vec<f64>,
|
||||
drawdown_curve: Vec<f64>,
|
||||
trades: Vec<PyTrade>,
|
||||
returns: Vec<f64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBacktestResult {
|
||||
/// Get equity curve as numpy array.
|
||||
fn equity_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1<f64> {
|
||||
vec_to_numpy_f64(py, self.equity_curve.clone())
|
||||
}
|
||||
|
||||
/// Get drawdown curve as numpy array.
|
||||
fn drawdown_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1<f64> {
|
||||
vec_to_numpy_f64(py, self.drawdown_curve.clone())
|
||||
}
|
||||
|
||||
/// Get returns as numpy array.
|
||||
fn returns<'py>(&self, py: Python<'py>) -> &'py PyArray1<f64> {
|
||||
vec_to_numpy_f64(py, self.returns.clone())
|
||||
}
|
||||
|
||||
/// Get list of trades.
|
||||
fn trades(&self) -> Vec<PyTrade> {
|
||||
self.trades.clone()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"BacktestResult(return={:.2}%, trades={}, max_dd={:.2}%)",
|
||||
self.metrics.total_return_pct, self.metrics.total_trades, self.metrics.max_drawdown_pct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backtest Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Run single instrument backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None))]
|
||||
pub fn run_single_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
open: PyReadonlyArray1<f64>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
volume: PyReadonlyArray1<f64>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
direction: i32,
|
||||
weight: f64,
|
||||
symbol: &str,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
position_sizes: Option<PyReadonlyArray1<f64>>,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(timestamps),
|
||||
open: numpy_to_vec_f64(open),
|
||||
high: numpy_to_vec_f64(high),
|
||||
low: numpy_to_vec_f64(low),
|
||||
close: numpy_to_vec_f64(close),
|
||||
volume: numpy_to_vec_f64(volume),
|
||||
};
|
||||
|
||||
let dir = Direction::from_int(direction).unwrap_or(Direction::Long);
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: symbol.to_string(),
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
position_sizes: position_sizes.map(numpy_to_vec_f64),
|
||||
direction: dir,
|
||||
weight,
|
||||
};
|
||||
|
||||
let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
|
||||
|
||||
let backtest = SingleBacktest::new(rust_config);
|
||||
let result = backtest.run(&ohlcv, &signals);
|
||||
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// Run basket/collective backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (instruments, config=None, sync_mode="all"))]
|
||||
pub fn run_basket_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
instruments: Vec<(
|
||||
PyReadonlyArray1<i64>,
|
||||
PyReadonlyArray1<f64>,
|
||||
PyReadonlyArray1<f64>,
|
||||
PyReadonlyArray1<f64>,
|
||||
PyReadonlyArray1<f64>,
|
||||
PyReadonlyArray1<f64>,
|
||||
PyReadonlyArray1<bool>,
|
||||
PyReadonlyArray1<bool>,
|
||||
i32,
|
||||
f64,
|
||||
String,
|
||||
)>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
sync_mode: &str,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments
|
||||
.into_iter()
|
||||
.map(|(ts, o, h, l, c, v, entries, exits, dir, weight, sym)| {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(ts),
|
||||
open: numpy_to_vec_f64(o),
|
||||
high: numpy_to_vec_f64(h),
|
||||
low: numpy_to_vec_f64(l),
|
||||
close: numpy_to_vec_f64(c),
|
||||
volume: numpy_to_vec_f64(v),
|
||||
};
|
||||
let signals = CompiledSignals {
|
||||
symbol: sym,
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
position_sizes: None,
|
||||
direction: Direction::from_int(dir).unwrap_or(Direction::Long),
|
||||
weight,
|
||||
};
|
||||
(ohlcv, signals)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mode = match sync_mode {
|
||||
"any" => SyncMode::Any,
|
||||
"majority" => SyncMode::Majority,
|
||||
"master" => SyncMode::Master,
|
||||
_ => SyncMode::All,
|
||||
};
|
||||
|
||||
let basket_config = BasketConfig {
|
||||
base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
|
||||
sync_mode: mode,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let backtest = BasketBacktest::new(basket_config);
|
||||
let result = backtest.run(&rust_instruments);
|
||||
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// Run options backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, option_prices, entries, exits, direction=1, symbol="OPTION", config=None, option_type="call", strike_selection="atm", size_type="percent", size_value=1.0, lot_size=1, strike_interval=50.0))]
|
||||
pub fn run_options_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
open: PyReadonlyArray1<f64>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
volume: PyReadonlyArray1<f64>,
|
||||
option_prices: PyReadonlyArray1<f64>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
direction: i32,
|
||||
symbol: &str,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
option_type: &str,
|
||||
strike_selection: &str,
|
||||
size_type: &str,
|
||||
size_value: f64,
|
||||
lot_size: usize,
|
||||
strike_interval: f64,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(timestamps),
|
||||
open: numpy_to_vec_f64(open),
|
||||
high: numpy_to_vec_f64(high),
|
||||
low: numpy_to_vec_f64(low),
|
||||
close: numpy_to_vec_f64(close),
|
||||
volume: numpy_to_vec_f64(volume),
|
||||
};
|
||||
|
||||
let opt_prices = numpy_to_vec_f64(option_prices);
|
||||
|
||||
let dir = Direction::from_int(direction).unwrap_or(Direction::Long);
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: symbol.to_string(),
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
position_sizes: None,
|
||||
direction: dir,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let opt_type = match option_type {
|
||||
"put" => OptionType::Put,
|
||||
_ => OptionType::Call,
|
||||
};
|
||||
|
||||
let strike_sel = match strike_selection {
|
||||
"otm1" => StrikeSelection::Otm(1),
|
||||
"otm2" => StrikeSelection::Otm(2),
|
||||
"itm1" => StrikeSelection::Itm(1),
|
||||
"itm2" => StrikeSelection::Itm(2),
|
||||
_ => StrikeSelection::Atm,
|
||||
};
|
||||
|
||||
let size = match size_type {
|
||||
"contracts" => SizeType::Contracts(size_value as usize),
|
||||
"notional" => SizeType::Notional(size_value),
|
||||
"risk" => SizeType::RiskPercent(size_value),
|
||||
_ => SizeType::Percent(size_value),
|
||||
};
|
||||
|
||||
let options_config = OptionsConfig {
|
||||
base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
|
||||
option_type: opt_type,
|
||||
strike_selection: strike_sel,
|
||||
size_type: size,
|
||||
lot_size,
|
||||
strike_interval,
|
||||
target_dte: None,
|
||||
};
|
||||
|
||||
let backtest = OptionsBacktest::new(options_config);
|
||||
let result = backtest.run(&ohlcv, &opt_prices, &signals);
|
||||
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// Run pairs trading backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (leg1_timestamps, leg1_open, leg1_high, leg1_low, leg1_close, leg1_volume, leg2_timestamps, leg2_open, leg2_high, leg2_low, leg2_close, leg2_volume, entries, exits, direction=1, symbol="PAIR", config=None, hedge_ratio=1.0, dynamic_hedge=false))]
|
||||
pub fn run_pairs_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
leg1_timestamps: PyReadonlyArray1<i64>,
|
||||
leg1_open: PyReadonlyArray1<f64>,
|
||||
leg1_high: PyReadonlyArray1<f64>,
|
||||
leg1_low: PyReadonlyArray1<f64>,
|
||||
leg1_close: PyReadonlyArray1<f64>,
|
||||
leg1_volume: PyReadonlyArray1<f64>,
|
||||
leg2_timestamps: PyReadonlyArray1<i64>,
|
||||
leg2_open: PyReadonlyArray1<f64>,
|
||||
leg2_high: PyReadonlyArray1<f64>,
|
||||
leg2_low: PyReadonlyArray1<f64>,
|
||||
leg2_close: PyReadonlyArray1<f64>,
|
||||
leg2_volume: PyReadonlyArray1<f64>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
direction: i32,
|
||||
symbol: &str,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
hedge_ratio: f64,
|
||||
dynamic_hedge: bool,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let leg1_ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(leg1_timestamps),
|
||||
open: numpy_to_vec_f64(leg1_open),
|
||||
high: numpy_to_vec_f64(leg1_high),
|
||||
low: numpy_to_vec_f64(leg1_low),
|
||||
close: numpy_to_vec_f64(leg1_close),
|
||||
volume: numpy_to_vec_f64(leg1_volume),
|
||||
};
|
||||
|
||||
let leg2_ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(leg2_timestamps),
|
||||
open: numpy_to_vec_f64(leg2_open),
|
||||
high: numpy_to_vec_f64(leg2_high),
|
||||
low: numpy_to_vec_f64(leg2_low),
|
||||
close: numpy_to_vec_f64(leg2_close),
|
||||
volume: numpy_to_vec_f64(leg2_volume),
|
||||
};
|
||||
|
||||
let dir = Direction::from_int(direction).unwrap_or(Direction::Long);
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: symbol.to_string(),
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
position_sizes: None,
|
||||
direction: dir,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let pairs_config = PairsConfig {
|
||||
base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
|
||||
hedge_ratio,
|
||||
dynamic_hedge,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let backtest = PairsBacktest::new(pairs_config);
|
||||
let result = backtest.run(&leg1_ohlcv, &leg2_ohlcv, &signals);
|
||||
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))]
|
||||
pub fn run_multi_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
open: PyReadonlyArray1<f64>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
volume: PyReadonlyArray1<f64>,
|
||||
strategies: Vec<(
|
||||
PyReadonlyArray1<bool>,
|
||||
PyReadonlyArray1<bool>,
|
||||
i32,
|
||||
f64,
|
||||
String,
|
||||
)>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
combine_mode: &str,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: numpy_to_vec_i64(timestamps),
|
||||
open: numpy_to_vec_f64(open),
|
||||
high: numpy_to_vec_f64(high),
|
||||
low: numpy_to_vec_f64(low),
|
||||
close: numpy_to_vec_f64(close),
|
||||
volume: numpy_to_vec_f64(volume),
|
||||
};
|
||||
|
||||
let rust_strategies: Vec<CompiledSignals> = strategies
|
||||
.into_iter()
|
||||
.map(|(entries, exits, dir, weight, symbol)| CompiledSignals {
|
||||
symbol,
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
position_sizes: None,
|
||||
direction: Direction::from_int(dir).unwrap_or(Direction::Long),
|
||||
weight,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mode = match combine_mode {
|
||||
"all" => CombineMode::All,
|
||||
"majority" => CombineMode::Majority,
|
||||
"independent" => CombineMode::Independent,
|
||||
"weighted" => CombineMode::Weighted,
|
||||
_ => CombineMode::Any,
|
||||
};
|
||||
|
||||
let multi_config = MultiStrategyConfig {
|
||||
base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
|
||||
combine_mode: mode,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let backtest = MultiStrategyBacktest::new(multi_config);
|
||||
let result = backtest.run(&ohlcv, &rust_strategies);
|
||||
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Indicator Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Simple Moving Average.
|
||||
#[pyfunction]
|
||||
pub fn sma<'py>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let vec = numpy_to_vec_f64(data);
|
||||
let result = indicators::trend::sma(&vec, period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// Exponential Moving Average.
|
||||
#[pyfunction]
|
||||
pub fn ema<'py>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let vec = numpy_to_vec_f64(data);
|
||||
let result = indicators::trend::ema(&vec, period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// Relative Strength Index.
|
||||
#[pyfunction]
|
||||
pub fn rsi<'py>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let vec = numpy_to_vec_f64(data);
|
||||
let result = indicators::momentum::rsi(&vec, period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// MACD indicator.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (data, fast_period=12, slow_period=26, signal_period=9))]
|
||||
pub fn macd<'py>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray1<f64>,
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
signal_period: usize,
|
||||
) -> PyResult<(&'py PyArray1<f64>, &'py PyArray1<f64>, &'py PyArray1<f64>)> {
|
||||
let vec = numpy_to_vec_f64(data);
|
||||
let result = indicators::momentum::macd(&vec, fast_period, slow_period, signal_period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok((
|
||||
vec_to_numpy_f64(py, result.macd_line),
|
||||
vec_to_numpy_f64(py, result.signal_line),
|
||||
vec_to_numpy_f64(py, result.histogram),
|
||||
))
|
||||
}
|
||||
|
||||
/// Stochastic oscillator.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (high, low, close, k_period=14, d_period=3))]
|
||||
pub fn stochastic<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
k_period: usize,
|
||||
d_period: usize,
|
||||
) -> PyResult<(&'py PyArray1<f64>, &'py PyArray1<f64>)> {
|
||||
let h = numpy_to_vec_f64(high);
|
||||
let l = numpy_to_vec_f64(low);
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok((
|
||||
vec_to_numpy_f64(py, result.k),
|
||||
vec_to_numpy_f64(py, result.d),
|
||||
))
|
||||
}
|
||||
|
||||
/// Average True Range.
|
||||
#[pyfunction]
|
||||
pub fn atr<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let h = numpy_to_vec_f64(high);
|
||||
let l = numpy_to_vec_f64(low);
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let result = indicators::volatility::atr(&h, &l, &c, period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// Bollinger Bands.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (data, period=20, std_dev=2.0))]
|
||||
pub fn bollinger_bands<'py>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
std_dev: f64,
|
||||
) -> PyResult<(&'py PyArray1<f64>, &'py PyArray1<f64>, &'py PyArray1<f64>)> {
|
||||
let vec = numpy_to_vec_f64(data);
|
||||
let result = indicators::volatility::bollinger_bands(&vec, period, std_dev)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok((
|
||||
vec_to_numpy_f64(py, result.upper),
|
||||
vec_to_numpy_f64(py, result.middle),
|
||||
vec_to_numpy_f64(py, result.lower),
|
||||
))
|
||||
}
|
||||
|
||||
/// Average Directional Index.
|
||||
#[pyfunction]
|
||||
pub fn adx<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let h = numpy_to_vec_f64(high);
|
||||
let l = numpy_to_vec_f64(low);
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let result = indicators::strength::adx(&h, &l, &c, period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// Volume Weighted Average Price.
|
||||
#[pyfunction]
|
||||
pub fn vwap<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
volume: PyReadonlyArray1<f64>,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
let h = numpy_to_vec_f64(high);
|
||||
let l = numpy_to_vec_f64(low);
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let v = numpy_to_vec_f64(volume);
|
||||
let result = indicators::volume::vwap(&h, &l, &c, &v)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok(vec_to_numpy_f64(py, result))
|
||||
}
|
||||
|
||||
/// Supertrend indicator.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (high, low, close, period=10, multiplier=3.0))]
|
||||
pub fn supertrend<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<f64>,
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
) -> PyResult<(&'py PyArray1<f64>, &'py PyArray1<i8>)> {
|
||||
let h = numpy_to_vec_f64(high);
|
||||
let l = numpy_to_vec_f64(low);
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let result = indicators::trend::supertrend(&h, &l, &c, period, multiplier)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
|
||||
let direction_array = PyArray1::from_vec(py, result.direction);
|
||||
Ok((vec_to_numpy_f64(py, result.supertrend), direction_array))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Convert Rust BacktestResult to Python PyBacktestResult.
|
||||
fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult {
|
||||
let metrics = PyBacktestMetrics {
|
||||
total_return_pct: result.metrics.total_return_pct,
|
||||
sharpe_ratio: result.metrics.sharpe_ratio,
|
||||
sortino_ratio: result.metrics.sortino_ratio,
|
||||
calmar_ratio: result.metrics.calmar_ratio,
|
||||
omega_ratio: result.metrics.omega_ratio,
|
||||
max_drawdown_pct: result.metrics.max_drawdown_pct,
|
||||
max_drawdown_duration: result.metrics.max_drawdown_duration,
|
||||
win_rate_pct: result.metrics.win_rate_pct,
|
||||
profit_factor: result.metrics.profit_factor,
|
||||
expectancy: result.metrics.expectancy,
|
||||
sqn: result.metrics.sqn,
|
||||
total_trades: result.metrics.total_trades,
|
||||
total_closed_trades: result.metrics.total_closed_trades,
|
||||
total_open_trades: result.metrics.total_open_trades,
|
||||
open_trade_pnl: result.metrics.open_trade_pnl,
|
||||
winning_trades: result.metrics.winning_trades,
|
||||
losing_trades: result.metrics.losing_trades,
|
||||
start_value: result.metrics.start_value,
|
||||
end_value: result.metrics.end_value,
|
||||
total_fees_paid: result.metrics.total_fees_paid,
|
||||
best_trade_pct: result.metrics.best_trade_pct,
|
||||
worst_trade_pct: result.metrics.worst_trade_pct,
|
||||
avg_trade_return_pct: result.metrics.avg_trade_return_pct,
|
||||
avg_win_pct: result.metrics.avg_win_pct,
|
||||
avg_loss_pct: result.metrics.avg_loss_pct,
|
||||
avg_winning_duration: result.metrics.avg_winning_duration,
|
||||
avg_losing_duration: result.metrics.avg_losing_duration,
|
||||
max_consecutive_wins: result.metrics.max_consecutive_wins,
|
||||
max_consecutive_losses: result.metrics.max_consecutive_losses,
|
||||
avg_holding_period: result.metrics.avg_holding_period,
|
||||
exposure_pct: result.metrics.exposure_pct,
|
||||
};
|
||||
|
||||
let trades: Vec<PyTrade> = result
|
||||
.trades
|
||||
.into_iter()
|
||||
.map(|t| PyTrade {
|
||||
id: t.id,
|
||||
symbol: t.symbol,
|
||||
entry_idx: t.entry_idx,
|
||||
exit_idx: t.exit_idx,
|
||||
entry_price: t.entry_price,
|
||||
exit_price: t.exit_price,
|
||||
size: t.size,
|
||||
direction: t.direction as i32,
|
||||
pnl: t.pnl,
|
||||
return_pct: t.return_pct,
|
||||
entry_time: t.entry_time,
|
||||
exit_time: t.exit_time,
|
||||
fees: t.fees,
|
||||
exit_reason: format!("{:?}", t.exit_reason),
|
||||
})
|
||||
.collect();
|
||||
|
||||
PyBacktestResult {
|
||||
metrics,
|
||||
equity_curve: result.equity_curve,
|
||||
drawdown_curve: result.drawdown_curve,
|
||||
trades,
|
||||
returns: result.returns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Python bindings for RaptorBT.
|
||||
|
||||
pub mod bindings;
|
||||
pub mod numpy_bridge;
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Zero-copy numpy array interface.
|
||||
|
||||
use numpy::{PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
/// Convert numpy array to Vec<f64>.
|
||||
pub fn numpy_to_vec_f64(arr: PyReadonlyArray1<f64>) -> Vec<f64> {
|
||||
arr.as_slice().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// Convert numpy array to Vec<i64>.
|
||||
pub fn numpy_to_vec_i64(arr: PyReadonlyArray1<i64>) -> Vec<i64> {
|
||||
arr.as_slice().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// Convert numpy bool array to Vec<bool>.
|
||||
pub fn numpy_to_vec_bool(arr: PyReadonlyArray1<bool>) -> Vec<bool> {
|
||||
arr.as_slice().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// Convert Vec<f64> to numpy array.
|
||||
pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec<f64>) -> &'py PyArray1<f64> {
|
||||
PyArray1::from_vec(py, vec)
|
||||
}
|
||||
|
||||
/// Convert Vec<i64> to numpy array.
|
||||
pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec<i64>) -> &'py PyArray1<i64> {
|
||||
PyArray1::from_vec(py, vec)
|
||||
}
|
||||
|
||||
/// Convert Vec<bool> to numpy array.
|
||||
pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec<bool>) -> &'py PyArray1<bool> {
|
||||
PyArray1::from_vec(py, vec)
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//! Expression evaluation for signal generation.
|
||||
//!
|
||||
//! Provides a Rust-native expression evaluator for generating trading signals
|
||||
//! from indicator values.
|
||||
|
||||
/// Comparison operators for signal generation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CompareOp {
|
||||
/// Greater than.
|
||||
Gt,
|
||||
/// Greater than or equal.
|
||||
Gte,
|
||||
/// Less than.
|
||||
Lt,
|
||||
/// Less than or equal.
|
||||
Lte,
|
||||
/// Equal (within tolerance).
|
||||
Eq,
|
||||
/// Not equal.
|
||||
Ne,
|
||||
}
|
||||
|
||||
/// Crossover/crossunder detection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CrossType {
|
||||
/// Line A crosses above line B.
|
||||
CrossOver,
|
||||
/// Line A crosses below line B.
|
||||
CrossUnder,
|
||||
}
|
||||
|
||||
/// Compare two series element-wise.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First series
|
||||
/// * `b` - Second series
|
||||
/// * `op` - Comparison operator
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where comparison is true
|
||||
pub fn compare(a: &[f64], b: &[f64], op: CompareOp) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
assert_eq!(n, b.len());
|
||||
|
||||
let tolerance = 1e-10;
|
||||
|
||||
let mut result = vec![false; n];
|
||||
for i in 0..n {
|
||||
if a[i].is_nan() || b[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
result[i] = match op {
|
||||
CompareOp::Gt => a[i] > b[i],
|
||||
CompareOp::Gte => a[i] >= b[i],
|
||||
CompareOp::Lt => a[i] < b[i],
|
||||
CompareOp::Lte => a[i] <= b[i],
|
||||
CompareOp::Eq => (a[i] - b[i]).abs() < tolerance,
|
||||
CompareOp::Ne => (a[i] - b[i]).abs() >= tolerance,
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare series with a scalar value.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `value` - Scalar value to compare against
|
||||
/// * `op` - Comparison operator
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where comparison is true
|
||||
pub fn compare_scalar(a: &[f64], value: f64, op: CompareOp) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let tolerance = 1e-10;
|
||||
|
||||
let mut result = vec![false; n];
|
||||
for i in 0..n {
|
||||
if a[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
result[i] = match op {
|
||||
CompareOp::Gt => a[i] > value,
|
||||
CompareOp::Gte => a[i] >= value,
|
||||
CompareOp::Lt => a[i] < value,
|
||||
CompareOp::Lte => a[i] <= value,
|
||||
CompareOp::Eq => (a[i] - value).abs() < tolerance,
|
||||
CompareOp::Ne => (a[i] - value).abs() >= tolerance,
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Detect crossover/crossunder between two series.
|
||||
///
|
||||
/// Crossover: a crosses above b (a[i-1] < b[i-1] and a[i] > b[i])
|
||||
/// Crossunder: a crosses below b (a[i-1] > b[i-1] and a[i] < b[i])
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First series
|
||||
/// * `b` - Second series
|
||||
/// * `cross_type` - Type of cross to detect
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where cross occurs
|
||||
pub fn cross(a: &[f64], b: &[f64], cross_type: CrossType) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
assert_eq!(n, b.len());
|
||||
|
||||
let mut result = vec![false; n];
|
||||
if n < 2 {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in 1..n {
|
||||
if a[i].is_nan() || b[i].is_nan() || a[i - 1].is_nan() || b[i - 1].is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[i] = match cross_type {
|
||||
CrossType::CrossOver => a[i - 1] <= b[i - 1] && a[i] > b[i],
|
||||
CrossType::CrossUnder => a[i - 1] >= b[i - 1] && a[i] < b[i],
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Detect crossover with a scalar value.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `value` - Scalar value to cross
|
||||
/// * `cross_type` - Type of cross to detect
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where cross occurs
|
||||
pub fn cross_scalar(a: &[f64], value: f64, cross_type: CrossType) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if n < 2 {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in 1..n {
|
||||
if a[i].is_nan() || a[i - 1].is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[i] = match cross_type {
|
||||
CrossType::CrossOver => a[i - 1] <= value && a[i] > value,
|
||||
CrossType::CrossUnder => a[i - 1] >= value && a[i] < value,
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if value is in a range.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `low` - Lower bound
|
||||
/// * `high` - Upper bound
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where value is in range [low, high]
|
||||
pub fn in_range(a: &[f64], low: f64, high: f64) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
if a[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
result[i] = a[i] >= low && a[i] <= high;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if series is rising (current > previous).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `periods` - Number of periods to look back (default: 1)
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where value is rising
|
||||
pub fn is_rising(a: &[f64], periods: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if periods >= n {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in periods..n {
|
||||
if a[i].is_nan() || a[i - periods].is_nan() {
|
||||
continue;
|
||||
}
|
||||
result[i] = a[i] > a[i - periods];
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if series is falling (current < previous).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `periods` - Number of periods to look back (default: 1)
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where value is falling
|
||||
pub fn is_falling(a: &[f64], periods: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if periods >= n {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in periods..n {
|
||||
if a[i].is_nan() || a[i - periods].is_nan() {
|
||||
continue;
|
||||
}
|
||||
result[i] = a[i] < a[i - periods];
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if value has been above a threshold for n consecutive bars.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `threshold` - Threshold value
|
||||
/// * `consecutive` - Number of consecutive bars required
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where condition is met
|
||||
pub fn above_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if consecutive > n {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in (consecutive - 1)..n {
|
||||
let mut all_above = true;
|
||||
for j in 0..consecutive {
|
||||
let idx = i - j;
|
||||
if a[idx].is_nan() || a[idx] <= threshold {
|
||||
all_above = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result[i] = all_above;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if value has been below a threshold for n consecutive bars.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `threshold` - Threshold value
|
||||
/// * `consecutive` - Number of consecutive bars required
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where condition is met
|
||||
pub fn below_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if consecutive > n {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in (consecutive - 1)..n {
|
||||
let mut all_below = true;
|
||||
for j in 0..consecutive {
|
||||
let idx = i - j;
|
||||
if a[idx].is_nan() || a[idx] >= threshold {
|
||||
all_below = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result[i] = all_below;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Detect highest value in rolling window.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `window` - Window size
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where current value is highest in window
|
||||
pub fn is_highest(a: &[f64], window: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if window > n || window == 0 {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in (window - 1)..n {
|
||||
let start = i + 1 - window;
|
||||
let current = a[i];
|
||||
if current.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let max_in_window = a[start..=i]
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
|
||||
|
||||
result[i] = (current - max_in_window).abs() < 1e-10;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Detect lowest value in rolling window.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - Series
|
||||
/// * `window` - Window size
|
||||
///
|
||||
/// # Returns
|
||||
/// Boolean series indicating where current value is lowest in window
|
||||
pub fn is_lowest(a: &[f64], window: usize) -> Vec<bool> {
|
||||
let n = a.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
if window > n || window == 0 {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in (window - 1)..n {
|
||||
let start = i + 1 - window;
|
||||
let current = a[i];
|
||||
if current.is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let min_in_window = a[start..=i]
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.fold(f64::INFINITY, |a, &b| a.min(b));
|
||||
|
||||
result[i] = (current - min_in_window).abs() < 1e-10;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compare() {
|
||||
let a = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let b = vec![2.0, 2.0, 2.0, 2.0];
|
||||
|
||||
let result = compare(&a, &b, CompareOp::Gt);
|
||||
assert!(!result[0]); // 1 > 2 = false
|
||||
assert!(!result[1]); // 2 > 2 = false
|
||||
assert!(result[2]); // 3 > 2 = true
|
||||
assert!(result[3]); // 4 > 2 = true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossover() {
|
||||
let a = vec![1.0, 1.5, 2.5, 3.0, 2.5];
|
||||
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
|
||||
|
||||
let result = cross(&a, &b, CrossType::CrossOver);
|
||||
assert!(!result[0]); // No previous
|
||||
assert!(!result[1]); // 1.0 < 2.0, 1.5 < 2.0 - still below
|
||||
assert!(result[2]); // 1.5 < 2.0, 2.5 > 2.0 - crossed over!
|
||||
assert!(!result[3]); // 2.5 > 2.0, 3.0 > 2.0 - already above
|
||||
assert!(!result[4]); // 3.0 > 2.0, 2.5 > 2.0 - still above
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossunder() {
|
||||
let a = vec![3.0, 2.5, 1.5, 1.0, 1.5];
|
||||
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
|
||||
|
||||
let result = cross(&a, &b, CrossType::CrossUnder);
|
||||
assert!(!result[0]); // No previous
|
||||
assert!(!result[1]); // 3.0 > 2.0, 2.5 > 2.0 - still above
|
||||
assert!(result[2]); // 2.5 > 2.0, 1.5 < 2.0 - crossed under!
|
||||
assert!(!result[3]); // 1.5 < 2.0, 1.0 < 2.0 - already below
|
||||
assert!(!result[4]); // 1.0 < 2.0, 1.5 < 2.0 - still below
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_range() {
|
||||
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
|
||||
let result = in_range(&a, 2.0, 4.0);
|
||||
assert!(!result[0]); // 1 not in [2, 4]
|
||||
assert!(result[1]); // 2 in [2, 4]
|
||||
assert!(result[2]); // 3 in [2, 4]
|
||||
assert!(result[3]); // 4 in [2, 4]
|
||||
assert!(!result[4]); // 5 not in [2, 4]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_rising() {
|
||||
let a = vec![1.0, 2.0, 3.0, 2.5, 3.5];
|
||||
|
||||
let result = is_rising(&a, 1);
|
||||
assert!(!result[0]); // No previous
|
||||
assert!(result[1]); // 2 > 1
|
||||
assert!(result[2]); // 3 > 2
|
||||
assert!(!result[3]); // 2.5 < 3
|
||||
assert!(result[4]); // 3.5 > 2.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_above_for() {
|
||||
let a = vec![1.0, 3.0, 3.5, 4.0, 2.0, 3.0];
|
||||
let threshold = 2.5;
|
||||
|
||||
let result = above_for(&a, threshold, 3);
|
||||
assert!(!result[0]);
|
||||
assert!(!result[1]);
|
||||
assert!(!result[2]); // 1.0 < 2.5
|
||||
assert!(result[3]); // 3.0, 3.5, 4.0 all > 2.5
|
||||
assert!(!result[4]); // 2.0 < 2.5
|
||||
assert!(!result[5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_highest() {
|
||||
let a = vec![1.0, 3.0, 2.0, 4.0, 3.5];
|
||||
|
||||
let result = is_highest(&a, 3);
|
||||
assert!(!result[0]);
|
||||
assert!(!result[1]);
|
||||
assert!(result[2] == false); // 2.0 is not highest in [1.0, 3.0, 2.0]
|
||||
assert!(result[3]); // 4.0 is highest in [3.0, 2.0, 4.0]
|
||||
assert!(!result[4]); // 3.5 is not highest in [2.0, 4.0, 3.5]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Signal processing for RaptorBT.
|
||||
//!
|
||||
//! This module handles signal cleaning, synchronization, and expression evaluation.
|
||||
|
||||
pub mod expression;
|
||||
pub mod processor;
|
||||
pub mod synchronizer;
|
||||
|
||||
pub use processor::SignalProcessor;
|
||||
pub use synchronizer::{SignalSynchronizer, SyncMode};
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Signal processor for cleaning entry/exit signals.
|
||||
//!
|
||||
//! Ensures proper alternation between entries and exits to prevent
|
||||
//! overlapping positions or orphaned signals.
|
||||
|
||||
use crate::core::types::Direction;
|
||||
|
||||
/// Signal processor for cleaning raw entry/exit signals.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalProcessor {
|
||||
/// Whether to allow multiple entries before an exit (pyramiding).
|
||||
pub allow_pyramiding: bool,
|
||||
/// Maximum number of pyramid entries.
|
||||
pub max_pyramid_entries: usize,
|
||||
}
|
||||
|
||||
impl Default for SignalProcessor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_pyramiding: false,
|
||||
max_pyramid_entries: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalProcessor {
|
||||
/// Create a new signal processor.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Enable pyramiding with a maximum number of entries.
|
||||
pub fn with_pyramiding(mut self, max_entries: usize) -> Self {
|
||||
self.allow_pyramiding = max_entries > 1;
|
||||
self.max_pyramid_entries = max_entries;
|
||||
self
|
||||
}
|
||||
|
||||
/// Clean entry/exit signals to ensure proper alternation.
|
||||
///
|
||||
/// Rules (matching VectorBT behavior):
|
||||
/// 1. First signal must be an entry
|
||||
/// 2. After an entry, ignore further entries (unless pyramiding)
|
||||
/// 3. After an exit, ignore further exits
|
||||
/// 4. Entries and exits must alternate properly
|
||||
/// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar
|
||||
/// when in position, VectorBT stays in position (ignores the exit).
|
||||
/// This matches VectorBT's "entry takes priority" behavior.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `entries` - Raw entry signals
|
||||
/// * `exits` - Raw exit signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (cleaned_entries, cleaned_exits)
|
||||
pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec<bool>, Vec<bool>) {
|
||||
let n = entries.len();
|
||||
assert_eq!(
|
||||
n,
|
||||
exits.len(),
|
||||
"Entry and exit arrays must have same length"
|
||||
);
|
||||
|
||||
let mut clean_entries = vec![false; n];
|
||||
let mut clean_exits = vec![false; n];
|
||||
|
||||
if n == 0 {
|
||||
return (clean_entries, clean_exits);
|
||||
}
|
||||
|
||||
let mut in_position = false;
|
||||
let mut position_count = 0;
|
||||
|
||||
for i in 0..n {
|
||||
if !in_position {
|
||||
// Not in position - looking for entry
|
||||
if entries[i] {
|
||||
clean_entries[i] = true;
|
||||
in_position = true;
|
||||
position_count = 1;
|
||||
}
|
||||
// Ignore exits when not in position
|
||||
} else {
|
||||
// In position - looking for exit (or pyramid entry)
|
||||
// VectorBT behavior: If both entry and exit are True, stay in position
|
||||
// (entry signal "cancels" the exit signal)
|
||||
if exits[i] && !entries[i] {
|
||||
// Only exit if there's no conflicting entry signal
|
||||
clean_exits[i] = true;
|
||||
if self.allow_pyramiding {
|
||||
position_count -= 1;
|
||||
if position_count == 0 {
|
||||
in_position = false;
|
||||
}
|
||||
} else {
|
||||
in_position = false;
|
||||
position_count = 0;
|
||||
}
|
||||
} else if entries[i]
|
||||
&& self.allow_pyramiding
|
||||
&& position_count < self.max_pyramid_entries
|
||||
{
|
||||
// Pyramid entry
|
||||
clean_entries[i] = true;
|
||||
position_count += 1;
|
||||
}
|
||||
// If both entry and exit are True, we stay in position (ignore both)
|
||||
// If only entry is True and not pyramiding, ignore entry (already in position)
|
||||
}
|
||||
}
|
||||
|
||||
(clean_entries, clean_exits)
|
||||
}
|
||||
|
||||
/// Clean signals with direction awareness (for strategies that can go long/short).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `long_entries` - Long entry signals
|
||||
/// * `long_exits` - Long exit signals
|
||||
/// * `short_entries` - Short entry signals
|
||||
/// * `short_exits` - Short exit signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
|
||||
pub fn clean_signals_bidirectional(
|
||||
&self,
|
||||
long_entries: &[bool],
|
||||
long_exits: &[bool],
|
||||
short_entries: &[bool],
|
||||
short_exits: &[bool],
|
||||
) -> (Vec<bool>, Vec<bool>, Vec<bool>, Vec<bool>) {
|
||||
let n = long_entries.len();
|
||||
assert_eq!(n, long_exits.len());
|
||||
assert_eq!(n, short_entries.len());
|
||||
assert_eq!(n, short_exits.len());
|
||||
|
||||
let mut clean_long_entries = vec![false; n];
|
||||
let mut clean_long_exits = vec![false; n];
|
||||
let mut clean_short_entries = vec![false; n];
|
||||
let mut clean_short_exits = vec![false; n];
|
||||
|
||||
if n == 0 {
|
||||
return (
|
||||
clean_long_entries,
|
||||
clean_long_exits,
|
||||
clean_short_entries,
|
||||
clean_short_exits,
|
||||
);
|
||||
}
|
||||
|
||||
let mut current_direction: Option<Direction> = None;
|
||||
|
||||
for i in 0..n {
|
||||
match current_direction {
|
||||
None => {
|
||||
// Not in any position - look for entry
|
||||
if long_entries[i] {
|
||||
clean_long_entries[i] = true;
|
||||
current_direction = Some(Direction::Long);
|
||||
} else if short_entries[i] {
|
||||
clean_short_entries[i] = true;
|
||||
current_direction = Some(Direction::Short);
|
||||
}
|
||||
}
|
||||
Some(Direction::Long) => {
|
||||
// In long position - look for exit or reversal
|
||||
if long_exits[i] {
|
||||
clean_long_exits[i] = true;
|
||||
current_direction = None;
|
||||
} else if short_entries[i] {
|
||||
// Reversal: exit long and enter short
|
||||
clean_long_exits[i] = true;
|
||||
clean_short_entries[i] = true;
|
||||
current_direction = Some(Direction::Short);
|
||||
}
|
||||
}
|
||||
Some(Direction::Short) => {
|
||||
// In short position - look for exit or reversal
|
||||
if short_exits[i] {
|
||||
clean_short_exits[i] = true;
|
||||
current_direction = None;
|
||||
} else if long_entries[i] {
|
||||
// Reversal: exit short and enter long
|
||||
clean_short_exits[i] = true;
|
||||
clean_long_entries[i] = true;
|
||||
current_direction = Some(Direction::Long);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
clean_long_entries,
|
||||
clean_long_exits,
|
||||
clean_short_entries,
|
||||
clean_short_exits,
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate exit-on-opposite-entry signals.
|
||||
///
|
||||
/// Useful for strategies where an entry in opposite direction
|
||||
/// should automatically close the current position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `entries` - Entry signals
|
||||
/// * `direction` - Current position direction
|
||||
///
|
||||
/// # Returns
|
||||
/// Modified exit signals that include opposite-direction entries as exits
|
||||
pub fn exits_from_opposite_entries(
|
||||
&self,
|
||||
long_entries: &[bool],
|
||||
short_entries: &[bool],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
let n = long_entries.len();
|
||||
assert_eq!(n, short_entries.len());
|
||||
|
||||
// Long exits when short entry
|
||||
// Short exits when long entry
|
||||
(short_entries.to_vec(), long_entries.to_vec())
|
||||
}
|
||||
|
||||
/// Count the number of trades that would be generated from signals.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `entries` - Entry signals (already cleaned)
|
||||
/// * `exits` - Exit signals (already cleaned)
|
||||
///
|
||||
/// # Returns
|
||||
/// Number of complete trades (entry + exit pairs)
|
||||
pub fn count_trades(_entries: &[bool], exits: &[bool]) -> usize {
|
||||
exits.iter().filter(|&&e| e).count()
|
||||
}
|
||||
|
||||
/// Get indices of entries and exits.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `entries` - Entry signals
|
||||
/// * `exits` - Exit signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (entry_indices, exit_indices)
|
||||
pub fn get_trade_indices(entries: &[bool], exits: &[bool]) -> (Vec<usize>, Vec<usize>) {
|
||||
let entry_indices: Vec<usize> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &e)| if e { Some(i) } else { None })
|
||||
.collect();
|
||||
|
||||
let exit_indices: Vec<usize> = exits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &e)| if e { Some(i) } else { None })
|
||||
.collect();
|
||||
|
||||
(entry_indices, exit_indices)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift signals forward by n bars (delays execution).
|
||||
pub fn shift_signals(signals: &[bool], n: usize) -> Vec<bool> {
|
||||
let len = signals.len();
|
||||
let mut result = vec![false; len];
|
||||
|
||||
if n >= len {
|
||||
return result;
|
||||
}
|
||||
|
||||
for i in n..len {
|
||||
result[i] = signals[i - n];
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Combine multiple signal arrays with AND logic.
|
||||
pub fn combine_signals_and(signals: &[&[bool]]) -> Vec<bool> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = signals[0].len();
|
||||
for sig in signals.iter() {
|
||||
assert_eq!(sig.len(), n, "All signal arrays must have same length");
|
||||
}
|
||||
|
||||
let mut result = vec![true; n];
|
||||
for sig in signals.iter() {
|
||||
for i in 0..n {
|
||||
result[i] = result[i] && sig[i];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Combine multiple signal arrays with OR logic.
|
||||
pub fn combine_signals_or(signals: &[&[bool]]) -> Vec<bool> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = signals[0].len();
|
||||
for sig in signals.iter() {
|
||||
assert_eq!(sig.len(), n, "All signal arrays must have same length");
|
||||
}
|
||||
|
||||
let mut result = vec![false; n];
|
||||
for sig in signals.iter() {
|
||||
for i in 0..n {
|
||||
result[i] = result[i] || sig[i];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_signals_basic() {
|
||||
let processor = SignalProcessor::new();
|
||||
|
||||
let entries = vec![true, false, true, false, true, false];
|
||||
let exits = vec![false, true, false, true, false, true];
|
||||
|
||||
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
|
||||
|
||||
// First entry should be kept
|
||||
assert!(clean_e[0]);
|
||||
// First exit should be kept
|
||||
assert!(clean_x[1]);
|
||||
// Second entry should be kept
|
||||
assert!(clean_e[2]);
|
||||
// Second exit should be kept
|
||||
assert!(clean_x[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_signals_consecutive_entries() {
|
||||
let processor = SignalProcessor::new();
|
||||
|
||||
let entries = vec![true, true, true, false, false];
|
||||
let exits = vec![false, false, false, true, false];
|
||||
|
||||
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
|
||||
|
||||
// Only first entry should be kept
|
||||
assert!(clean_e[0]);
|
||||
assert!(!clean_e[1]);
|
||||
assert!(!clean_e[2]);
|
||||
// Exit should be kept
|
||||
assert!(clean_x[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_signals_consecutive_exits() {
|
||||
let processor = SignalProcessor::new();
|
||||
|
||||
let entries = vec![true, false, false, false, false];
|
||||
let exits = vec![false, true, true, true, false];
|
||||
|
||||
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
|
||||
|
||||
// Entry should be kept
|
||||
assert!(clean_e[0]);
|
||||
// Only first exit should be kept
|
||||
assert!(clean_x[1]);
|
||||
assert!(!clean_x[2]);
|
||||
assert!(!clean_x[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_signals_exit_before_entry() {
|
||||
let processor = SignalProcessor::new();
|
||||
|
||||
let entries = vec![false, false, true, false, false];
|
||||
let exits = vec![true, true, false, true, false];
|
||||
|
||||
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
|
||||
|
||||
// Exits before first entry should be ignored
|
||||
assert!(!clean_x[0]);
|
||||
assert!(!clean_x[1]);
|
||||
// Entry should be kept
|
||||
assert!(clean_e[2]);
|
||||
// Exit after entry should be kept
|
||||
assert!(clean_x[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pyramiding() {
|
||||
let processor = SignalProcessor::new().with_pyramiding(3);
|
||||
|
||||
let entries = vec![true, true, true, false, false];
|
||||
let exits = vec![false, false, false, true, true];
|
||||
|
||||
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
|
||||
|
||||
// All three entries should be kept (pyramiding)
|
||||
assert!(clean_e[0]);
|
||||
assert!(clean_e[1]);
|
||||
assert!(clean_e[2]);
|
||||
// Both exits should be kept
|
||||
assert!(clean_x[3]);
|
||||
assert!(clean_x[4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift_signals() {
|
||||
let signals = vec![true, false, true, false, true];
|
||||
let shifted = shift_signals(&signals, 2);
|
||||
|
||||
assert!(!shifted[0]);
|
||||
assert!(!shifted[1]);
|
||||
assert!(shifted[2]); // Original [0]
|
||||
assert!(!shifted[3]); // Original [1]
|
||||
assert!(shifted[4]); // Original [2]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combine_signals_and() {
|
||||
let sig1 = vec![true, true, false, false];
|
||||
let sig2 = vec![true, false, true, false];
|
||||
|
||||
let combined = combine_signals_and(&[&sig1, &sig2]);
|
||||
|
||||
assert!(combined[0]); // true && true
|
||||
assert!(!combined[1]); // true && false
|
||||
assert!(!combined[2]); // false && true
|
||||
assert!(!combined[3]); // false && false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combine_signals_or() {
|
||||
let sig1 = vec![true, true, false, false];
|
||||
let sig2 = vec![true, false, true, false];
|
||||
|
||||
let combined = combine_signals_or(&[&sig1, &sig2]);
|
||||
|
||||
assert!(combined[0]); // true || true
|
||||
assert!(combined[1]); // true || false
|
||||
assert!(combined[2]); // false || true
|
||||
assert!(!combined[3]); // false || false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Signal synchronization for multi-instrument strategies.
|
||||
//!
|
||||
//! Handles combining signals from multiple instruments with different sync modes.
|
||||
|
||||
use crate::core::types::CompiledSignals;
|
||||
|
||||
/// Synchronization mode for combining signals from multiple instruments.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncMode {
|
||||
/// All instruments must signal (AND logic).
|
||||
All,
|
||||
/// Any instrument can signal (OR logic).
|
||||
Any,
|
||||
/// Majority of instruments must signal.
|
||||
Majority,
|
||||
/// Use first instrument's signals as master.
|
||||
Master,
|
||||
}
|
||||
|
||||
impl Default for SyncMode {
|
||||
fn default() -> Self {
|
||||
SyncMode::All
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal synchronizer for multi-instrument backtests.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalSynchronizer {
|
||||
/// Synchronization mode.
|
||||
pub mode: SyncMode,
|
||||
/// Minimum number of instruments that must signal (for custom thresholds).
|
||||
pub min_signals: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for SignalSynchronizer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SyncMode::All,
|
||||
min_signals: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalSynchronizer {
|
||||
/// Create a new signal synchronizer with the given mode.
|
||||
pub fn new(mode: SyncMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
min_signals: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a synchronizer with a custom minimum signal threshold.
|
||||
pub fn with_min_signals(min: usize) -> Self {
|
||||
Self {
|
||||
mode: SyncMode::Majority,
|
||||
min_signals: Some(min),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize entry signals from multiple instruments.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signals` - Slice of signal arrays from each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined entry signals based on sync mode
|
||||
pub fn sync_entries(&self, signals: &[&[bool]]) -> Vec<bool> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = signals[0].len();
|
||||
for sig in signals.iter() {
|
||||
assert_eq!(sig.len(), n, "All signal arrays must have same length");
|
||||
}
|
||||
|
||||
let num_instruments = signals.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
let count = signals.iter().filter(|s| s[i]).count();
|
||||
|
||||
result[i] = match self.mode {
|
||||
SyncMode::All => count == num_instruments,
|
||||
SyncMode::Any => count > 0,
|
||||
SyncMode::Majority => {
|
||||
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
|
||||
count >= threshold
|
||||
}
|
||||
SyncMode::Master => signals[0][i],
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Synchronize exit signals from multiple instruments.
|
||||
///
|
||||
/// Exit logic is typically inverse of entry:
|
||||
/// - All mode -> exit on Any
|
||||
/// - Any mode -> exit on All
|
||||
/// - Majority mode -> exit when majority want to exit
|
||||
/// - Master mode -> use master's exit signals
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signals` - Slice of signal arrays from each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined exit signals based on sync mode
|
||||
pub fn sync_exits(&self, signals: &[&[bool]]) -> Vec<bool> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = signals[0].len();
|
||||
for sig in signals.iter() {
|
||||
assert_eq!(sig.len(), n, "All signal arrays must have same length");
|
||||
}
|
||||
|
||||
let num_instruments = signals.len();
|
||||
let mut result = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
let count = signals.iter().filter(|s| s[i]).count();
|
||||
|
||||
result[i] = match self.mode {
|
||||
// For All entry mode, exit when ANY wants to exit
|
||||
SyncMode::All => count > 0,
|
||||
// For Any entry mode, exit when ALL want to exit
|
||||
SyncMode::Any => count == num_instruments,
|
||||
SyncMode::Majority => {
|
||||
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
|
||||
count >= threshold
|
||||
}
|
||||
SyncMode::Master => signals[0][i],
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Synchronize signals from CompiledSignals objects.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `compiled_signals` - Slice of CompiledSignals from each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (synchronized_entries, synchronized_exits)
|
||||
pub fn sync_compiled_signals(
|
||||
&self,
|
||||
compiled_signals: &[&CompiledSignals],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
if compiled_signals.is_empty() {
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
|
||||
let entries: Vec<&[bool]> = compiled_signals
|
||||
.iter()
|
||||
.map(|cs| cs.entries.as_slice())
|
||||
.collect();
|
||||
|
||||
let exits: Vec<&[bool]> = compiled_signals
|
||||
.iter()
|
||||
.map(|cs| cs.exits.as_slice())
|
||||
.collect();
|
||||
|
||||
let synced_entries = self.sync_entries(&entries);
|
||||
let synced_exits = self.sync_exits(&exits);
|
||||
|
||||
(synced_entries, synced_exits)
|
||||
}
|
||||
|
||||
/// Calculate signal agreement score (0.0 to 1.0).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signals` - Slice of signal arrays from each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of agreement scores for each bar
|
||||
pub fn signal_agreement(&self, signals: &[&[bool]]) -> Vec<f64> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = signals[0].len();
|
||||
let num_instruments = signals.len() as f64;
|
||||
|
||||
let mut result = vec![0.0; n];
|
||||
|
||||
for i in 0..n {
|
||||
let count = signals.iter().filter(|s| s[i]).count() as f64;
|
||||
result[i] = count / num_instruments;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Align signals to a common time axis.
|
||||
///
|
||||
/// Useful when instruments have different trading hours or missing data.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signals` - Signal array to align
|
||||
/// * `source_timestamps` - Timestamps of the signal array
|
||||
/// * `target_timestamps` - Target timestamp grid
|
||||
/// * `fill_value` - Value to use for missing timestamps
|
||||
///
|
||||
/// # Returns
|
||||
/// Aligned signal array
|
||||
pub fn align_signals(
|
||||
signals: &[bool],
|
||||
source_timestamps: &[i64],
|
||||
target_timestamps: &[i64],
|
||||
fill_value: bool,
|
||||
) -> Vec<bool> {
|
||||
let n = target_timestamps.len();
|
||||
let mut result = vec![fill_value; n];
|
||||
|
||||
// Create a map of source timestamps to indices
|
||||
let mut source_map = std::collections::HashMap::new();
|
||||
for (i, &ts) in source_timestamps.iter().enumerate() {
|
||||
source_map.insert(ts, i);
|
||||
}
|
||||
|
||||
// Fill in values where timestamps match
|
||||
for (i, &ts) in target_timestamps.iter().enumerate() {
|
||||
if let Some(&source_idx) = source_map.get(&ts) {
|
||||
result[i] = signals[source_idx];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Forward-fill signals (carry forward last signal).
|
||||
pub fn forward_fill_signals(signals: &[bool]) -> Vec<bool> {
|
||||
let mut result = signals.to_vec();
|
||||
let mut last_value = false;
|
||||
|
||||
for i in 0..result.len() {
|
||||
if result[i] {
|
||||
last_value = true;
|
||||
}
|
||||
result[i] = last_value;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Create synchronized position signals.
|
||||
///
|
||||
/// Returns a position signal where:
|
||||
/// - 1 = in position
|
||||
/// - 0 = out of position
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `entries` - Entry signals (cleaned)
|
||||
/// * `exits` - Exit signals (cleaned)
|
||||
///
|
||||
/// # Returns
|
||||
/// Position state array
|
||||
pub fn position_signals(entries: &[bool], exits: &[bool]) -> Vec<i8> {
|
||||
let n = entries.len();
|
||||
assert_eq!(n, exits.len());
|
||||
|
||||
let mut result = vec![0i8; n];
|
||||
let mut in_position = false;
|
||||
|
||||
for i in 0..n {
|
||||
if entries[i] {
|
||||
in_position = true;
|
||||
}
|
||||
if exits[i] {
|
||||
in_position = false;
|
||||
}
|
||||
result[i] = if in_position { 1 } else { 0 };
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sync_all() {
|
||||
let sync = SignalSynchronizer::new(SyncMode::All);
|
||||
|
||||
let sig1 = vec![true, true, false, true];
|
||||
let sig2 = vec![true, false, false, true];
|
||||
let sig3 = vec![true, true, false, true];
|
||||
|
||||
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
|
||||
|
||||
assert!(result[0]); // All true
|
||||
assert!(!result[1]); // Not all true
|
||||
assert!(!result[2]); // All false
|
||||
assert!(result[3]); // All true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_any() {
|
||||
let sync = SignalSynchronizer::new(SyncMode::Any);
|
||||
|
||||
let sig1 = vec![true, false, false, false];
|
||||
let sig2 = vec![false, true, false, false];
|
||||
let sig3 = vec![false, false, false, false];
|
||||
|
||||
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
|
||||
|
||||
assert!(result[0]); // At least one true
|
||||
assert!(result[1]); // At least one true
|
||||
assert!(!result[2]); // All false
|
||||
assert!(!result[3]); // All false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_majority() {
|
||||
let sync = SignalSynchronizer::new(SyncMode::Majority);
|
||||
|
||||
let sig1 = vec![true, true, false, true];
|
||||
let sig2 = vec![true, false, false, true];
|
||||
let sig3 = vec![false, true, false, false];
|
||||
|
||||
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
|
||||
|
||||
assert!(result[0]); // 2 out of 3
|
||||
assert!(result[1]); // 2 out of 3
|
||||
assert!(!result[2]); // 0 out of 3
|
||||
assert!(result[3]); // 2 out of 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_master() {
|
||||
let sync = SignalSynchronizer::new(SyncMode::Master);
|
||||
|
||||
let sig1 = vec![true, false, true, false]; // Master
|
||||
let sig2 = vec![false, true, false, true];
|
||||
let sig3 = vec![true, true, true, true];
|
||||
|
||||
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
|
||||
|
||||
// Should follow master (sig1)
|
||||
assert!(result[0]);
|
||||
assert!(!result[1]);
|
||||
assert!(result[2]);
|
||||
assert!(!result[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_inverse_logic() {
|
||||
// For All entry mode, exit should be Any
|
||||
let sync = SignalSynchronizer::new(SyncMode::All);
|
||||
|
||||
let exit1 = vec![true, false, false];
|
||||
let exit2 = vec![false, false, false];
|
||||
let exit3 = vec![false, false, false];
|
||||
|
||||
let result = sync.sync_exits(&[&exit1, &exit2, &exit3]);
|
||||
|
||||
assert!(result[0]); // Any true -> exit
|
||||
assert!(!result[1]);
|
||||
assert!(!result[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_agreement() {
|
||||
let sync = SignalSynchronizer::new(SyncMode::All);
|
||||
|
||||
let sig1 = vec![true, true, false, true];
|
||||
let sig2 = vec![true, false, false, true];
|
||||
let sig3 = vec![false, true, false, true];
|
||||
|
||||
let result = sync.signal_agreement(&[&sig1, &sig2, &sig3]);
|
||||
|
||||
assert!((result[0] - 2.0 / 3.0).abs() < 1e-10);
|
||||
assert!((result[1] - 2.0 / 3.0).abs() < 1e-10);
|
||||
assert!((result[2] - 0.0).abs() < 1e-10);
|
||||
assert!((result[3] - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_signals() {
|
||||
let entries = vec![false, true, false, false, true, false];
|
||||
let exits = vec![false, false, false, true, false, true];
|
||||
|
||||
let result = position_signals(&entries, &exits);
|
||||
|
||||
assert_eq!(result[0], 0);
|
||||
assert_eq!(result[1], 1);
|
||||
assert_eq!(result[2], 1);
|
||||
assert_eq!(result[3], 0);
|
||||
assert_eq!(result[4], 1);
|
||||
assert_eq!(result[5], 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! ATR-based stop-loss and take-profit.
|
||||
|
||||
use super::{StopCalculator, TargetCalculator};
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// ATR-based stop-loss.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrStop {
|
||||
/// ATR multiplier.
|
||||
pub multiplier: f64,
|
||||
/// Current ATR value.
|
||||
pub atr: f64,
|
||||
}
|
||||
|
||||
impl AtrStop {
|
||||
/// Create a new ATR stop.
|
||||
pub fn new(multiplier: f64, atr: f64) -> Self {
|
||||
Self { multiplier, atr }
|
||||
}
|
||||
|
||||
/// Update ATR value.
|
||||
pub fn update_atr(&mut self, atr: f64) {
|
||||
self.atr = atr;
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for AtrStop {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
if self.atr <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let distance = self.atr * self.multiplier;
|
||||
let stop = match direction {
|
||||
Direction::Long => entry_price - distance,
|
||||
Direction::Short => entry_price + distance,
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
_high: Price,
|
||||
_low: Price,
|
||||
_direction: Direction,
|
||||
) -> Option<Price> {
|
||||
// ATR stop doesn't trail by default
|
||||
current_stop
|
||||
}
|
||||
}
|
||||
|
||||
/// ATR-based take-profit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrTarget {
|
||||
/// ATR multiplier.
|
||||
pub multiplier: f64,
|
||||
/// Current ATR value.
|
||||
pub atr: f64,
|
||||
}
|
||||
|
||||
impl AtrTarget {
|
||||
/// Create a new ATR target.
|
||||
pub fn new(multiplier: f64, atr: f64) -> Self {
|
||||
Self { multiplier, atr }
|
||||
}
|
||||
|
||||
/// Update ATR value.
|
||||
pub fn update_atr(&mut self, atr: f64) {
|
||||
self.atr = atr;
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetCalculator for AtrTarget {
|
||||
fn calculate_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
_stop_price: Option<Price>,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
if self.atr <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let distance = self.atr * self.multiplier;
|
||||
let target = match direction {
|
||||
Direction::Long => entry_price + distance,
|
||||
Direction::Short => entry_price - distance,
|
||||
};
|
||||
Some(target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Chandelier exit (ATR-based trailing stop from high/low).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChandelierExit {
|
||||
/// ATR multiplier.
|
||||
pub multiplier: f64,
|
||||
/// Current ATR value.
|
||||
pub atr: f64,
|
||||
/// Highest high since entry (for long).
|
||||
pub highest_high: f64,
|
||||
/// Lowest low since entry (for short).
|
||||
pub lowest_low: f64,
|
||||
}
|
||||
|
||||
impl ChandelierExit {
|
||||
/// Create a new Chandelier exit.
|
||||
pub fn new(multiplier: f64, atr: f64) -> Self {
|
||||
Self {
|
||||
multiplier,
|
||||
atr,
|
||||
highest_high: 0.0,
|
||||
lowest_low: f64::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset for new position.
|
||||
pub fn reset(&mut self, entry_price: Price) {
|
||||
self.highest_high = entry_price;
|
||||
self.lowest_low = entry_price;
|
||||
}
|
||||
|
||||
/// Update with new bar data.
|
||||
pub fn update(&mut self, high: Price, low: Price, atr: f64) {
|
||||
if high > self.highest_high {
|
||||
self.highest_high = high;
|
||||
}
|
||||
if low < self.lowest_low {
|
||||
self.lowest_low = low;
|
||||
}
|
||||
self.atr = atr;
|
||||
}
|
||||
|
||||
/// Get current stop level.
|
||||
pub fn stop_level(&self, direction: Direction) -> Option<Price> {
|
||||
if self.atr <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let distance = self.atr * self.multiplier;
|
||||
let stop = match direction {
|
||||
Direction::Long => self.highest_high - distance,
|
||||
Direction::Short => self.lowest_low + distance,
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for ChandelierExit {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
if self.atr <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let distance = self.atr * self.multiplier;
|
||||
let stop = match direction {
|
||||
Direction::Long => entry_price - distance,
|
||||
Direction::Short => entry_price + distance,
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
if self.atr <= 0.0 {
|
||||
return current_stop;
|
||||
}
|
||||
|
||||
let distance = self.atr * self.multiplier;
|
||||
let new_stop = match direction {
|
||||
Direction::Long => {
|
||||
let proposed = high - distance;
|
||||
current_stop.map(|cs| cs.max(proposed)).or(Some(proposed))
|
||||
}
|
||||
Direction::Short => {
|
||||
let proposed = low + distance;
|
||||
current_stop.map(|cs| cs.min(proposed)).or(Some(proposed))
|
||||
}
|
||||
};
|
||||
|
||||
new_stop
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_atr_stop_long() {
|
||||
let stop = AtrStop::new(2.0, 5.0);
|
||||
let result = stop.calculate_stop(100.0, Direction::Long);
|
||||
// 100 - (2 * 5) = 90
|
||||
assert!((result.unwrap() - 90.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_stop_short() {
|
||||
let stop = AtrStop::new(2.0, 5.0);
|
||||
let result = stop.calculate_stop(100.0, Direction::Short);
|
||||
// 100 + (2 * 5) = 110
|
||||
assert!((result.unwrap() - 110.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_target() {
|
||||
let target = AtrTarget::new(3.0, 5.0);
|
||||
let result = target.calculate_target(100.0, None, Direction::Long);
|
||||
// 100 + (3 * 5) = 115
|
||||
assert!((result.unwrap() - 115.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chandelier_exit() {
|
||||
let mut chandelier = ChandelierExit::new(3.0, 2.0);
|
||||
chandelier.reset(100.0);
|
||||
|
||||
// Simulate price movement up
|
||||
chandelier.update(105.0, 99.0, 2.0);
|
||||
chandelier.update(110.0, 103.0, 2.0);
|
||||
|
||||
// Long stop should trail from highest high
|
||||
// 110 - (3 * 2) = 104
|
||||
let stop = chandelier.stop_level(Direction::Long);
|
||||
assert!((stop.unwrap() - 104.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_zero() {
|
||||
let stop = AtrStop::new(2.0, 0.0);
|
||||
let result = stop.calculate_stop(100.0, Direction::Long);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Fixed percentage stop-loss and take-profit.
|
||||
|
||||
use super::{StopCalculator, TargetCalculator};
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// Fixed percentage stop-loss.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FixedStop {
|
||||
/// Stop percentage (e.g., 0.02 for 2%).
|
||||
pub percent: f64,
|
||||
}
|
||||
|
||||
impl FixedStop {
|
||||
/// Create a new fixed stop with given percentage.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a 1% stop.
|
||||
pub fn one_percent() -> Self {
|
||||
Self::new(0.01)
|
||||
}
|
||||
|
||||
/// Create a 2% stop.
|
||||
pub fn two_percent() -> Self {
|
||||
Self::new(0.02)
|
||||
}
|
||||
|
||||
/// Create a 5% stop.
|
||||
pub fn five_percent() -> Self {
|
||||
Self::new(0.05)
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for FixedStop {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
let stop = match direction {
|
||||
Direction::Long => entry_price * (1.0 - self.percent),
|
||||
Direction::Short => entry_price * (1.0 + self.percent),
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
_high: Price,
|
||||
_low: Price,
|
||||
_direction: Direction,
|
||||
) -> Option<Price> {
|
||||
// Fixed stop doesn't update
|
||||
current_stop
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed percentage take-profit.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FixedTarget {
|
||||
/// Target percentage (e.g., 0.04 for 4%).
|
||||
pub percent: f64,
|
||||
}
|
||||
|
||||
impl FixedTarget {
|
||||
/// Create a new fixed target with given percentage.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetCalculator for FixedTarget {
|
||||
fn calculate_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
_stop_price: Option<Price>,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
let target = match direction {
|
||||
Direction::Long => entry_price * (1.0 + self.percent),
|
||||
Direction::Short => entry_price * (1.0 - self.percent),
|
||||
};
|
||||
Some(target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Risk-reward based take-profit.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RiskRewardTarget {
|
||||
/// Risk-reward ratio (e.g., 2.0 for 2:1 reward:risk).
|
||||
pub ratio: f64,
|
||||
}
|
||||
|
||||
impl RiskRewardTarget {
|
||||
/// Create a new risk-reward target.
|
||||
pub fn new(ratio: f64) -> Self {
|
||||
Self { ratio }
|
||||
}
|
||||
|
||||
/// Create a 2:1 target.
|
||||
pub fn two_to_one() -> Self {
|
||||
Self::new(2.0)
|
||||
}
|
||||
|
||||
/// Create a 3:1 target.
|
||||
pub fn three_to_one() -> Self {
|
||||
Self::new(3.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetCalculator for RiskRewardTarget {
|
||||
fn calculate_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
stop_price: Option<Price>,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
let stop = stop_price?;
|
||||
let risk = (entry_price - stop).abs();
|
||||
let reward = risk * self.ratio;
|
||||
|
||||
let target = match direction {
|
||||
Direction::Long => entry_price + reward,
|
||||
Direction::Short => entry_price - reward,
|
||||
};
|
||||
Some(target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fixed_stop_long() {
|
||||
let stop = FixedStop::new(0.02);
|
||||
let result = stop.calculate_stop(100.0, Direction::Long);
|
||||
assert!((result.unwrap() - 98.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_stop_short() {
|
||||
let stop = FixedStop::new(0.02);
|
||||
let result = stop.calculate_stop(100.0, Direction::Short);
|
||||
assert!((result.unwrap() - 102.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_target_long() {
|
||||
let target = FixedTarget::new(0.04);
|
||||
let result = target.calculate_target(100.0, None, Direction::Long);
|
||||
assert!((result.unwrap() - 104.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_risk_reward_target() {
|
||||
let target = RiskRewardTarget::new(2.0);
|
||||
// Entry at 100, stop at 98 (2% risk), target should be at 104 (4% reward)
|
||||
let result = target.calculate_target(100.0, Some(98.0), Direction::Long);
|
||||
assert!((result.unwrap() - 104.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_risk_reward_no_stop() {
|
||||
let target = RiskRewardTarget::new(2.0);
|
||||
let result = target.calculate_target(100.0, None, Direction::Long);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Stop-loss and take-profit mechanisms for RaptorBT.
|
||||
|
||||
pub mod atr;
|
||||
pub mod fixed;
|
||||
pub mod trailing;
|
||||
|
||||
pub use atr::AtrStop;
|
||||
pub use fixed::FixedStop;
|
||||
pub use trailing::TrailingStop;
|
||||
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// Stop-loss calculator trait.
|
||||
pub trait StopCalculator {
|
||||
/// Calculate stop price for a new position.
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price>;
|
||||
|
||||
/// Update stop price for trailing stops.
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
current_price: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
direction: Direction,
|
||||
) -> Option<Price>;
|
||||
}
|
||||
|
||||
/// Take-profit calculator trait.
|
||||
pub trait TargetCalculator {
|
||||
/// Calculate target price for a new position.
|
||||
fn calculate_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
stop_price: Option<Price>,
|
||||
direction: Direction,
|
||||
) -> Option<Price>;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Trailing stop implementations.
|
||||
|
||||
use super::StopCalculator;
|
||||
use crate::core::types::{Direction, Price};
|
||||
|
||||
/// Percentage-based trailing stop.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TrailingStop {
|
||||
/// Trail percentage (e.g., 0.05 for 5%).
|
||||
pub percent: f64,
|
||||
/// Activation threshold (optional - start trailing after this profit %).
|
||||
pub activation_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
impl TrailingStop {
|
||||
/// Create a new trailing stop.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
activation_threshold: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with activation threshold.
|
||||
pub fn with_activation(mut self, threshold: f64) -> Self {
|
||||
self.activation_threshold = Some(threshold.abs());
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if trailing should be activated.
|
||||
#[allow(dead_code)]
|
||||
fn should_activate(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
current_price: Price,
|
||||
direction: Direction,
|
||||
) -> bool {
|
||||
if let Some(threshold) = self.activation_threshold {
|
||||
let profit_pct = match direction {
|
||||
Direction::Long => (current_price - entry_price) / entry_price,
|
||||
Direction::Short => (entry_price - current_price) / entry_price,
|
||||
};
|
||||
profit_pct >= threshold
|
||||
} else {
|
||||
true // Always active if no threshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for TrailingStop {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
let stop = match direction {
|
||||
Direction::Long => entry_price * (1.0 - self.percent),
|
||||
Direction::Short => entry_price * (1.0 + self.percent),
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
match direction {
|
||||
Direction::Long => {
|
||||
// Trail below the high
|
||||
let new_stop = high * (1.0 - self.percent);
|
||||
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
Direction::Short => {
|
||||
// Trail above the low
|
||||
let new_stop = low * (1.0 + self.percent);
|
||||
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point-based trailing stop (fixed point distance).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PointTrailingStop {
|
||||
/// Trail distance in points.
|
||||
pub points: f64,
|
||||
}
|
||||
|
||||
impl PointTrailingStop {
|
||||
/// Create a new point-based trailing stop.
|
||||
pub fn new(points: f64) -> Self {
|
||||
Self {
|
||||
points: points.abs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for PointTrailingStop {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
let stop = match direction {
|
||||
Direction::Long => entry_price - self.points,
|
||||
Direction::Short => entry_price + self.points,
|
||||
};
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
match direction {
|
||||
Direction::Long => {
|
||||
let new_stop = high - self.points;
|
||||
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
Direction::Short => {
|
||||
let new_stop = low + self.points;
|
||||
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Step trailing stop (moves in discrete steps).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StepTrailingStop {
|
||||
/// Step size percentage.
|
||||
pub step_percent: f64,
|
||||
/// Trail percentage from each step.
|
||||
pub trail_percent: f64,
|
||||
}
|
||||
|
||||
impl StepTrailingStop {
|
||||
/// Create a new step trailing stop.
|
||||
pub fn new(step_percent: f64, trail_percent: f64) -> Self {
|
||||
Self {
|
||||
step_percent: step_percent.abs(),
|
||||
trail_percent: trail_percent.abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate stop for a given step level.
|
||||
fn stop_for_step(&self, entry_price: Price, step: usize, direction: Direction) -> Price {
|
||||
let step_gain = self.step_percent * step as f64;
|
||||
match direction {
|
||||
Direction::Long => {
|
||||
let step_price = entry_price * (1.0 + step_gain);
|
||||
step_price * (1.0 - self.trail_percent)
|
||||
}
|
||||
Direction::Short => {
|
||||
let step_price = entry_price * (1.0 - step_gain);
|
||||
step_price * (1.0 + self.trail_percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine current step level.
|
||||
#[allow(dead_code)]
|
||||
fn current_step(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
extreme_price: Price,
|
||||
direction: Direction,
|
||||
) -> usize {
|
||||
let gain = match direction {
|
||||
Direction::Long => (extreme_price - entry_price) / entry_price,
|
||||
Direction::Short => (entry_price - extreme_price) / entry_price,
|
||||
};
|
||||
|
||||
if gain <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
(gain / self.step_percent).floor() as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for StepTrailingStop {
|
||||
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
|
||||
Some(self.stop_for_step(entry_price, 0, direction))
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
high: Price,
|
||||
low: Price,
|
||||
direction: Direction,
|
||||
) -> Option<Price> {
|
||||
// This is a simplified version - full implementation would need entry price
|
||||
// For now, just use regular trailing behavior
|
||||
match direction {
|
||||
Direction::Long => {
|
||||
let new_stop = high * (1.0 - self.trail_percent);
|
||||
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
Direction::Short => {
|
||||
let new_stop = low * (1.0 + self.trail_percent);
|
||||
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parabolic SAR style trailing stop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParabolicStop {
|
||||
/// Initial acceleration factor.
|
||||
pub af_start: f64,
|
||||
/// Acceleration factor increment.
|
||||
pub af_step: f64,
|
||||
/// Maximum acceleration factor.
|
||||
pub af_max: f64,
|
||||
/// Current acceleration factor.
|
||||
current_af: f64,
|
||||
/// Current extreme point.
|
||||
extreme_point: f64,
|
||||
/// Current SAR value.
|
||||
current_sar: f64,
|
||||
}
|
||||
|
||||
impl ParabolicStop {
|
||||
/// Create a new Parabolic SAR stop with default parameters.
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(0.02, 0.02, 0.2)
|
||||
}
|
||||
|
||||
/// Create with custom parameters.
|
||||
pub fn with_params(af_start: f64, af_step: f64, af_max: f64) -> Self {
|
||||
Self {
|
||||
af_start,
|
||||
af_step,
|
||||
af_max,
|
||||
current_af: af_start,
|
||||
extreme_point: 0.0,
|
||||
current_sar: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize for new position.
|
||||
pub fn init(&mut self, entry_price: Price, direction: Direction) {
|
||||
self.current_af = self.af_start;
|
||||
self.extreme_point = entry_price;
|
||||
self.current_sar = match direction {
|
||||
Direction::Long => entry_price * 0.99, // Slightly below entry
|
||||
Direction::Short => entry_price * 1.01, // Slightly above entry
|
||||
};
|
||||
}
|
||||
|
||||
/// Update SAR with new bar data.
|
||||
pub fn update_sar(&mut self, high: Price, low: Price, direction: Direction) -> Price {
|
||||
// Update extreme point
|
||||
let new_ep = match direction {
|
||||
Direction::Long => {
|
||||
if high > self.extreme_point {
|
||||
self.current_af = (self.current_af + self.af_step).min(self.af_max);
|
||||
high
|
||||
} else {
|
||||
self.extreme_point
|
||||
}
|
||||
}
|
||||
Direction::Short => {
|
||||
if low < self.extreme_point {
|
||||
self.current_af = (self.current_af + self.af_step).min(self.af_max);
|
||||
low
|
||||
} else {
|
||||
self.extreme_point
|
||||
}
|
||||
}
|
||||
};
|
||||
self.extreme_point = new_ep;
|
||||
|
||||
// Calculate new SAR
|
||||
let new_sar = self.current_sar + self.current_af * (self.extreme_point - self.current_sar);
|
||||
|
||||
// Ensure SAR doesn't cross price
|
||||
self.current_sar = match direction {
|
||||
Direction::Long => new_sar.min(low),
|
||||
Direction::Short => new_sar.max(high),
|
||||
};
|
||||
|
||||
self.current_sar
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ParabolicStop {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl StopCalculator for ParabolicStop {
|
||||
fn calculate_stop(&self, _entry_price: Price, _direction: Direction) -> Option<Price> {
|
||||
if self.current_sar > 0.0 {
|
||||
Some(self.current_sar)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn update_stop(
|
||||
&self,
|
||||
_current_stop: Option<Price>,
|
||||
_current_price: Price,
|
||||
_high: Price,
|
||||
_low: Price,
|
||||
_direction: Direction,
|
||||
) -> Option<Price> {
|
||||
// Parabolic stop is updated via update_sar method
|
||||
if self.current_sar > 0.0 {
|
||||
Some(self.current_sar)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trailing_stop_long() {
|
||||
let stop = TrailingStop::new(0.05);
|
||||
|
||||
// Initial stop
|
||||
let initial = stop.calculate_stop(100.0, Direction::Long);
|
||||
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
|
||||
|
||||
// Update with higher high
|
||||
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
|
||||
// 110 * 0.95 = 104.5
|
||||
assert!((updated.unwrap() - 104.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_stop_short() {
|
||||
let stop = TrailingStop::new(0.05);
|
||||
|
||||
// Initial stop
|
||||
let initial = stop.calculate_stop(100.0, Direction::Short);
|
||||
assert!((initial.unwrap() - 105.0).abs() < 1e-10);
|
||||
|
||||
// Update with lower low
|
||||
let updated = stop.update_stop(initial, 92.0, 95.0, 90.0, Direction::Short);
|
||||
// 90 * 1.05 = 94.5
|
||||
assert!((updated.unwrap() - 94.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_stop_only_tightens() {
|
||||
let stop = TrailingStop::new(0.05);
|
||||
|
||||
let initial = stop.calculate_stop(100.0, Direction::Long);
|
||||
|
||||
// Move up
|
||||
let moved_up = stop.update_stop(initial, 110.0, 110.0, 108.0, Direction::Long);
|
||||
// 110 * 0.95 = 104.5
|
||||
assert!((moved_up.unwrap() - 104.5).abs() < 1e-10);
|
||||
|
||||
// Move down - stop should NOT move down
|
||||
let moved_down = stop.update_stop(moved_up, 105.0, 106.0, 103.0, Direction::Long);
|
||||
// Should still be 104.5 (not 106 * 0.95 = 100.7)
|
||||
assert!((moved_down.unwrap() - 104.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_trailing_stop() {
|
||||
let stop = PointTrailingStop::new(5.0);
|
||||
|
||||
// Initial stop
|
||||
let initial = stop.calculate_stop(100.0, Direction::Long);
|
||||
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
|
||||
|
||||
// Update with higher high
|
||||
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
|
||||
// 110 - 5 = 105
|
||||
assert!((updated.unwrap() - 105.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parabolic_stop() {
|
||||
let mut stop = ParabolicStop::new();
|
||||
stop.init(100.0, Direction::Long);
|
||||
|
||||
// Simulate uptrend
|
||||
let sar1 = stop.update_sar(102.0, 99.0, Direction::Long);
|
||||
let sar2 = stop.update_sar(105.0, 101.0, Direction::Long);
|
||||
let sar3 = stop.update_sar(108.0, 103.0, Direction::Long);
|
||||
|
||||
// SAR should be increasing
|
||||
assert!(sar2 > sar1);
|
||||
assert!(sar3 > sar2);
|
||||
|
||||
// SAR should be below current low
|
||||
assert!(sar3 < 103.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Basket/collective strategy backtest implementation.
|
||||
//!
|
||||
//! Supports multiple instruments with synchronized signals.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
use crate::portfolio::allocation::{AllocationStrategy, CapitalAllocator};
|
||||
use crate::signals::processor::SignalProcessor;
|
||||
use crate::signals::synchronizer::{SignalSynchronizer, SyncMode};
|
||||
|
||||
/// Basket backtest configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BasketConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Signal synchronization mode.
|
||||
pub sync_mode: SyncMode,
|
||||
/// Capital allocation strategy.
|
||||
pub allocation: AllocationStrategy,
|
||||
/// Whether to rebalance on each signal.
|
||||
pub rebalance_on_signal: bool,
|
||||
}
|
||||
|
||||
impl Default for BasketConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
sync_mode: SyncMode::All,
|
||||
allocation: AllocationStrategy::EqualWeight,
|
||||
rebalance_on_signal: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Basket/collective strategy backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct BasketBacktest {
|
||||
/// Configuration.
|
||||
config: BasketConfig,
|
||||
/// Signal synchronizer.
|
||||
synchronizer: SignalSynchronizer,
|
||||
/// Capital allocator.
|
||||
#[allow(dead_code)]
|
||||
allocator: CapitalAllocator,
|
||||
/// Signal processor.
|
||||
signal_processor: SignalProcessor,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl BasketBacktest {
|
||||
/// Create a new basket backtest.
|
||||
pub fn new(config: BasketConfig) -> Self {
|
||||
let allocator = CapitalAllocator::new(config.base.initial_capital)
|
||||
.with_strategy(config.allocation.clone());
|
||||
|
||||
Self {
|
||||
synchronizer: SignalSynchronizer::new(config.sync_mode),
|
||||
allocator,
|
||||
signal_processor: SignalProcessor::new(),
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run basket backtest with multiple instruments.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined backtest result
|
||||
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
|
||||
if instruments.is_empty() {
|
||||
return self.empty_result();
|
||||
}
|
||||
|
||||
let n_instruments = instruments.len();
|
||||
let n_bars = instruments[0].0.len();
|
||||
|
||||
// Verify all instruments have same length
|
||||
for (ohlcv, signals) in instruments {
|
||||
assert_eq!(
|
||||
ohlcv.len(),
|
||||
n_bars,
|
||||
"All instruments must have same number of bars"
|
||||
);
|
||||
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
|
||||
}
|
||||
|
||||
// Synchronize signals
|
||||
let entry_signals: Vec<&[bool]> = instruments
|
||||
.iter()
|
||||
.map(|(_, s)| s.entries.as_slice())
|
||||
.collect();
|
||||
let exit_signals: Vec<&[bool]> = instruments
|
||||
.iter()
|
||||
.map(|(_, s)| s.exits.as_slice())
|
||||
.collect();
|
||||
|
||||
let synced_entries = self.synchronizer.sync_entries(&entry_signals);
|
||||
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
|
||||
|
||||
// Clean signals
|
||||
let (clean_entries, clean_exits) = self
|
||||
.signal_processor
|
||||
.clean_signals(&synced_entries, &synced_exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut positions: Vec<Option<PositionState>> = vec![None; n_instruments];
|
||||
let mut equity_curve = vec![cash; n_bars];
|
||||
let mut drawdown_curve = vec![0.0; n_bars];
|
||||
let mut returns = vec![0.0; n_bars];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n_bars {
|
||||
// Calculate current position values
|
||||
let mut _total_position_value = 0.0;
|
||||
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
|
||||
if let Some(ref pos) = positions[inst_idx] {
|
||||
_total_position_value += pos.size * ohlcv.close[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exit
|
||||
if clean_exits[i] {
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
if let Some(pos) = positions[inst_idx].take() {
|
||||
let exit_price = ohlcv.close[i];
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(exit_price, pos.size, signals.direction);
|
||||
|
||||
let pnl = (exit_price - pos.entry_price)
|
||||
* pos.size
|
||||
* signals.direction.multiplier()
|
||||
- fees;
|
||||
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
cash += exit_price * pos.size - fees;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: ohlcv.timestamps[i],
|
||||
fees,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if clean_entries[i] && positions.iter().all(|p| p.is_none()) {
|
||||
// Calculate position sizes
|
||||
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
|
||||
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect();
|
||||
let sizes = self.calculate_sizes(&prices, &weights, cash);
|
||||
|
||||
// Enter positions
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
let size = sizes[inst_idx];
|
||||
if size > 0.0 {
|
||||
let entry_price = ohlcv.close[i];
|
||||
let fees = self
|
||||
.fee_model
|
||||
.calculate(entry_price, size, signals.direction);
|
||||
cash -= entry_price * size + fees;
|
||||
|
||||
positions[inst_idx] = Some(PositionState {
|
||||
entry_idx: i,
|
||||
entry_price,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let mut position_value = 0.0;
|
||||
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
|
||||
if let Some(ref pos) = positions[inst_idx] {
|
||||
position_value += pos.size * ohlcv.close[i];
|
||||
}
|
||||
}
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining positions
|
||||
let last_idx = n_bars - 1;
|
||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||
if let Some(pos) = positions[inst_idx].take() {
|
||||
let exit_price = ohlcv.close[last_idx];
|
||||
let fees = self
|
||||
.fee_model
|
||||
.calculate(exit_price, pos.size, signals.direction);
|
||||
|
||||
let pnl =
|
||||
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
|
||||
- fees;
|
||||
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Calculate position sizes for each instrument.
|
||||
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
|
||||
let n = prices.len();
|
||||
let total_weight: f64 = weights.iter().sum();
|
||||
|
||||
if total_weight == 0.0 {
|
||||
return vec![0.0; n];
|
||||
}
|
||||
|
||||
prices
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(&price, &weight)| {
|
||||
if price <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let allocation = available_capital * (weight / total_weight);
|
||||
allocation / price
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate metrics for the backtest.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let sharpe_ratio = streaming.sharpe_ratio(252.0);
|
||||
let sortino_ratio = streaming.sortino_ratio(252.0);
|
||||
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else if total_return_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
sortino_ratio,
|
||||
calmar_ratio,
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create empty result.
|
||||
fn empty_result(&self) -> BacktestResult {
|
||||
BacktestResult::new(
|
||||
BacktestMetrics {
|
||||
start_value: self.config.base.initial_capital,
|
||||
end_value: self.config.base.initial_capital,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PositionState {
|
||||
entry_idx: usize,
|
||||
entry_price: f64,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
|
||||
let n = 20;
|
||||
|
||||
let ohlcv1 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
let ohlcv2 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (50..50 + n).map(|x| x as f64).collect(),
|
||||
high: (51..51 + n).map(|x| x as f64).collect(),
|
||||
low: (49..49 + n).map(|x| x as f64).collect(),
|
||||
close: (50..50 + n).map(|x| x as f64 + 0.25).collect(),
|
||||
volume: vec![2000.0; n],
|
||||
};
|
||||
|
||||
let mut entries1 = vec![false; n];
|
||||
let mut exits1 = vec![false; n];
|
||||
entries1[2] = true;
|
||||
exits1[8] = true;
|
||||
|
||||
let mut entries2 = vec![false; n];
|
||||
let mut exits2 = vec![false; n];
|
||||
entries2[2] = true;
|
||||
exits2[8] = true;
|
||||
|
||||
let signals1 = CompiledSignals {
|
||||
symbol: "INST1".to_string(),
|
||||
entries: entries1,
|
||||
exits: exits1,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let signals2 = CompiledSignals {
|
||||
symbol: "INST2".to_string(),
|
||||
entries: entries2,
|
||||
exits: exits2,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
vec![(ohlcv1, signals1), (ohlcv2, signals2)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basket_backtest() {
|
||||
let config = BasketConfig::default();
|
||||
let backtest = BasketBacktest::new(config);
|
||||
let instruments = sample_instruments();
|
||||
|
||||
let result = backtest.run(&instruments);
|
||||
|
||||
// Should have trades for both instruments
|
||||
assert!(result.trades.len() >= 2);
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_mode_all() {
|
||||
let config = BasketConfig {
|
||||
sync_mode: SyncMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = BasketBacktest::new(config);
|
||||
let instruments = sample_instruments();
|
||||
|
||||
let result = backtest.run(&instruments);
|
||||
|
||||
// With All mode, both instruments should enter at same time
|
||||
assert!(result.trades.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_instruments() {
|
||||
let config = BasketConfig::default();
|
||||
let backtest = BasketBacktest::new(config);
|
||||
|
||||
let result = backtest.run(&[]);
|
||||
|
||||
assert_eq!(result.trades.len(), 0);
|
||||
assert!(result.equity_curve.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Strategy implementations for different backtest types.
|
||||
|
||||
pub mod basket;
|
||||
pub mod multi;
|
||||
pub mod options;
|
||||
pub mod pairs;
|
||||
pub mod single;
|
||||
|
||||
pub use basket::BasketBacktest;
|
||||
pub use multi::MultiStrategyBacktest;
|
||||
pub use options::OptionsBacktest;
|
||||
pub use pairs::PairsBacktest;
|
||||
pub use single::SingleBacktest;
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Multi-strategy backtest implementation.
|
||||
//!
|
||||
//! Supports running multiple strategies on the same instrument.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Strategy combination mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CombineMode {
|
||||
/// Enter when any strategy signals.
|
||||
Any,
|
||||
/// Enter when all strategies signal.
|
||||
All,
|
||||
/// Enter when majority of strategies signal.
|
||||
Majority,
|
||||
/// Run strategies independently with separate capital.
|
||||
Independent,
|
||||
/// Vote-weighted combination.
|
||||
Weighted,
|
||||
}
|
||||
|
||||
impl Default for CombineMode {
|
||||
fn default() -> Self {
|
||||
CombineMode::Any
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-strategy configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultiStrategyConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Strategy combination mode.
|
||||
pub combine_mode: CombineMode,
|
||||
/// Capital allocation per strategy (for independent mode).
|
||||
pub capital_per_strategy: Option<f64>,
|
||||
/// Strategy weights (for weighted mode).
|
||||
pub strategy_weights: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for MultiStrategyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
combine_mode: CombineMode::Any,
|
||||
capital_per_strategy: None,
|
||||
strategy_weights: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-strategy backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct MultiStrategyBacktest {
|
||||
/// Configuration.
|
||||
config: MultiStrategyConfig,
|
||||
/// Fee model.
|
||||
#[allow(dead_code)]
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl MultiStrategyBacktest {
|
||||
/// Create a new multi-strategy backtest.
|
||||
pub fn new(config: MultiStrategyConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV data for the instrument
|
||||
/// * `strategies` - Vector of compiled signals from each strategy
|
||||
///
|
||||
/// # Returns
|
||||
/// Combined backtest result
|
||||
pub fn run(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
if strategies.is_empty() {
|
||||
return self.empty_result();
|
||||
}
|
||||
|
||||
let n = ohlcv.len();
|
||||
for signals in strategies {
|
||||
assert_eq!(
|
||||
signals.len(),
|
||||
n,
|
||||
"All strategies must have same length as OHLCV"
|
||||
);
|
||||
}
|
||||
|
||||
match self.config.combine_mode {
|
||||
CombineMode::Independent => self.run_independent(ohlcv, strategies),
|
||||
_ => self.run_combined(ohlcv, strategies),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run strategies independently with separate capital.
|
||||
fn run_independent(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
let n_strategies = strategies.len();
|
||||
let capital_per = self
|
||||
.config
|
||||
.capital_per_strategy
|
||||
.unwrap_or(self.config.base.initial_capital / n_strategies as f64);
|
||||
|
||||
// Run each strategy independently
|
||||
let mut all_trades: Vec<Trade> = Vec::new();
|
||||
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
|
||||
|
||||
for (strat_idx, signals) in strategies.iter().enumerate() {
|
||||
let single_config = BacktestConfig {
|
||||
initial_capital: capital_per,
|
||||
..self.config.base.clone()
|
||||
};
|
||||
let single = crate::strategies::single::SingleBacktest::new(single_config);
|
||||
let result = single.run(ohlcv, signals);
|
||||
|
||||
// Tag trades with strategy index
|
||||
for mut trade in result.trades {
|
||||
trade.symbol = format!("{}_{}", trade.symbol, strat_idx);
|
||||
all_trades.push(trade);
|
||||
}
|
||||
|
||||
strategy_equities.push(result.equity_curve);
|
||||
}
|
||||
|
||||
// Combine equity curves
|
||||
let n = ohlcv.len();
|
||||
let mut combined_equity = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
for equity in &strategy_equities {
|
||||
combined_equity[i] += equity[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drawdown
|
||||
let mut peak = combined_equity[0];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
if combined_equity[i] > peak {
|
||||
peak = combined_equity[i];
|
||||
}
|
||||
drawdown_curve[i] = (peak - combined_equity[i]) / peak * 100.0;
|
||||
}
|
||||
|
||||
// Calculate returns
|
||||
let mut returns = vec![0.0; n];
|
||||
for i in 1..n {
|
||||
returns[i] = (combined_equity[i] - combined_equity[i - 1]) / combined_equity[i - 1];
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
for trade in &all_trades {
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
}
|
||||
|
||||
let metrics = self.calculate_metrics(
|
||||
&combined_equity,
|
||||
&drawdown_curve,
|
||||
&all_trades,
|
||||
&streaming,
|
||||
self.config.base.initial_capital,
|
||||
);
|
||||
|
||||
BacktestResult::new(
|
||||
metrics,
|
||||
combined_equity,
|
||||
drawdown_curve,
|
||||
all_trades,
|
||||
returns,
|
||||
)
|
||||
}
|
||||
|
||||
/// Run strategies with combined signals.
|
||||
fn run_combined(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
|
||||
let n = ohlcv.len();
|
||||
let n_strategies = strategies.len();
|
||||
|
||||
// Combine entry signals
|
||||
let mut combined_entries = vec![false; n];
|
||||
let mut combined_exits = vec![false; n];
|
||||
|
||||
for i in 0..n {
|
||||
let entry_count = strategies.iter().filter(|s| s.entries[i]).count();
|
||||
let exit_count = strategies.iter().filter(|s| s.exits[i]).count();
|
||||
|
||||
combined_entries[i] = match self.config.combine_mode {
|
||||
CombineMode::Any => entry_count > 0,
|
||||
CombineMode::All => entry_count == n_strategies,
|
||||
CombineMode::Majority => entry_count > n_strategies / 2,
|
||||
CombineMode::Weighted => {
|
||||
let weighted_sum: f64 = strategies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.entries[i])
|
||||
.map(|(idx, _)| {
|
||||
self.config
|
||||
.strategy_weights
|
||||
.get(idx)
|
||||
.copied()
|
||||
.unwrap_or(1.0)
|
||||
})
|
||||
.sum();
|
||||
let total_weight: f64 = self
|
||||
.config
|
||||
.strategy_weights
|
||||
.iter()
|
||||
.sum::<f64>()
|
||||
.max(n_strategies as f64);
|
||||
weighted_sum / total_weight > 0.5
|
||||
}
|
||||
CombineMode::Independent => unreachable!(),
|
||||
};
|
||||
|
||||
// Exit when any strategy wants to exit (conservative)
|
||||
combined_exits[i] = exit_count > 0;
|
||||
}
|
||||
|
||||
// Use first strategy's direction and symbol
|
||||
let direction = strategies[0].direction;
|
||||
let symbol = strategies[0].symbol.clone();
|
||||
|
||||
let combined_signals = CompiledSignals {
|
||||
symbol,
|
||||
entries: combined_entries,
|
||||
exits: combined_exits,
|
||||
position_sizes: None,
|
||||
direction,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
// Run single backtest with combined signals
|
||||
let single = crate::strategies::single::SingleBacktest::new(self.config.base.clone());
|
||||
single.run(ohlcv, &combined_signals)
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
initial_capital: f64,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create empty result.
|
||||
fn empty_result(&self) -> BacktestResult {
|
||||
BacktestResult::new(
|
||||
BacktestMetrics {
|
||||
start_value: self.config.base.initial_capital,
|
||||
end_value: self.config.base.initial_capital,
|
||||
..Default::default()
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
|
||||
let n = 20;
|
||||
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Strategy 1: Early entry
|
||||
let mut entries1 = vec![false; n];
|
||||
let mut exits1 = vec![false; n];
|
||||
entries1[2] = true;
|
||||
exits1[8] = true;
|
||||
|
||||
// Strategy 2: Later entry
|
||||
let mut entries2 = vec![false; n];
|
||||
let mut exits2 = vec![false; n];
|
||||
entries2[4] = true;
|
||||
exits2[10] = true;
|
||||
|
||||
let signals1 = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: entries1,
|
||||
exits: exits1,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let signals2 = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: entries2,
|
||||
exits: exits2,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(ohlcv, vec![signals1, signals2])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_any_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Any,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With Any mode, should enter at index 2 (first strategy)
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_all_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With All mode, should not enter (strategies don't signal at same time)
|
||||
assert!(result.trades.is_empty() || result.trades.len() < 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_independent_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Independent,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
let result = backtest.run(&ohlcv, &strategies);
|
||||
|
||||
// With Independent mode, should have trades from both strategies
|
||||
assert!(result.trades.len() >= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Options strategy backtest implementation.
|
||||
//!
|
||||
//! Supports dynamic strike selection and options-specific position sizing.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Options position type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OptionType {
|
||||
Call,
|
||||
Put,
|
||||
}
|
||||
|
||||
/// Strike selection mode.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum StrikeSelection {
|
||||
/// At-the-money (closest to spot).
|
||||
Atm,
|
||||
/// In-the-money by N strikes.
|
||||
Itm(usize),
|
||||
/// Out-of-the-money by N strikes.
|
||||
Otm(usize),
|
||||
/// Fixed strike offset from ATM in percentage.
|
||||
PercentOffset(f64),
|
||||
/// Delta-based selection.
|
||||
Delta(f64),
|
||||
}
|
||||
|
||||
impl Default for StrikeSelection {
|
||||
fn default() -> Self {
|
||||
StrikeSelection::Atm
|
||||
}
|
||||
}
|
||||
|
||||
/// Position size type for options.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum SizeType {
|
||||
/// Fixed number of contracts.
|
||||
Contracts(usize),
|
||||
/// Percentage of capital.
|
||||
Percent(f64),
|
||||
/// Fixed notional value.
|
||||
Notional(f64),
|
||||
/// Risk-based (percentage of capital at risk).
|
||||
RiskPercent(f64),
|
||||
}
|
||||
|
||||
impl Default for SizeType {
|
||||
fn default() -> Self {
|
||||
SizeType::Percent(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Options backtest configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptionsConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Option type (call/put).
|
||||
pub option_type: OptionType,
|
||||
/// Strike selection mode.
|
||||
pub strike_selection: StrikeSelection,
|
||||
/// Position size type.
|
||||
pub size_type: SizeType,
|
||||
/// Lot size (contracts per lot).
|
||||
pub lot_size: usize,
|
||||
/// Strike interval.
|
||||
pub strike_interval: f64,
|
||||
/// Days to expiry preference.
|
||||
pub target_dte: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for OptionsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
option_type: OptionType::Call,
|
||||
strike_selection: StrikeSelection::Atm,
|
||||
size_type: SizeType::Percent(1.0),
|
||||
lot_size: 1,
|
||||
strike_interval: 50.0,
|
||||
target_dte: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct OptionsBacktest {
|
||||
/// Configuration.
|
||||
config: OptionsConfig,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl OptionsBacktest {
|
||||
/// Create a new options backtest.
|
||||
pub fn new(config: OptionsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run options backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `spot_ohlcv` - Spot/underlying OHLCV data
|
||||
/// * `option_prices` - Option premium prices (parallel array)
|
||||
/// * `signals` - Trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run(
|
||||
&self,
|
||||
spot_ohlcv: &OhlcvData,
|
||||
option_prices: &[f64],
|
||||
signals: &CompiledSignals,
|
||||
) -> BacktestResult {
|
||||
let n = spot_ohlcv.len();
|
||||
assert_eq!(n, option_prices.len());
|
||||
assert_eq!(n, signals.len());
|
||||
|
||||
// Clean signals
|
||||
let processor = crate::signals::processor::SignalProcessor::new();
|
||||
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut position: Option<OptionsPosition> = None;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let spot_price = spot_ohlcv.close[i];
|
||||
let option_price = option_prices[i];
|
||||
|
||||
// Check for exit
|
||||
if exits[i] {
|
||||
if let Some(pos) = position.take() {
|
||||
let exit_price = option_price;
|
||||
let fees = self.fee_model.calculate(
|
||||
exit_price,
|
||||
pos.contracts as f64,
|
||||
signals.direction,
|
||||
);
|
||||
|
||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
||||
let cost_basis =
|
||||
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.contracts as f64,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: spot_ohlcv.timestamps[i],
|
||||
fees,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if entries[i] && position.is_none() {
|
||||
let strike = self.select_strike(spot_price);
|
||||
let contracts = self.calculate_contracts(option_price, cash);
|
||||
|
||||
if contracts > 0 {
|
||||
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(option_price, contracts as f64, signals.direction);
|
||||
|
||||
cash -= entry_cost + fees;
|
||||
|
||||
position = Some(OptionsPosition {
|
||||
entry_idx: i,
|
||||
entry_price: option_price,
|
||||
strike,
|
||||
contracts,
|
||||
option_type: self.config.option_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let position_value = if let Some(ref pos) = position {
|
||||
option_price * pos.contracts as f64 * self.config.lot_size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining position
|
||||
if let Some(pos) = position.take() {
|
||||
let last_idx = n - 1;
|
||||
let exit_price = option_prices[last_idx];
|
||||
let fees =
|
||||
self.fee_model
|
||||
.calculate(exit_price, pos.contracts as f64, signals.direction);
|
||||
|
||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
||||
let cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.contracts as f64,
|
||||
direction: signals.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: spot_ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Select strike price based on configuration.
|
||||
fn select_strike(&self, spot_price: f64) -> f64 {
|
||||
let interval = self.config.strike_interval;
|
||||
let atm_strike = (spot_price / interval).round() * interval;
|
||||
|
||||
match self.config.strike_selection {
|
||||
StrikeSelection::Atm => atm_strike,
|
||||
StrikeSelection::Itm(n) => match self.config.option_type {
|
||||
OptionType::Call => atm_strike - (n as f64 * interval),
|
||||
OptionType::Put => atm_strike + (n as f64 * interval),
|
||||
},
|
||||
StrikeSelection::Otm(n) => match self.config.option_type {
|
||||
OptionType::Call => atm_strike + (n as f64 * interval),
|
||||
OptionType::Put => atm_strike - (n as f64 * interval),
|
||||
},
|
||||
StrikeSelection::PercentOffset(pct) => {
|
||||
let offset = spot_price * pct;
|
||||
match self.config.option_type {
|
||||
OptionType::Call => atm_strike + offset,
|
||||
OptionType::Put => atm_strike - offset,
|
||||
}
|
||||
}
|
||||
StrikeSelection::Delta(_) => atm_strike, // Simplified - would need options chain
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate number of contracts based on size type.
|
||||
fn calculate_contracts(&self, option_price: f64, available_capital: f64) -> usize {
|
||||
if option_price <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let contract_cost = option_price * self.config.lot_size as f64;
|
||||
|
||||
match self.config.size_type {
|
||||
SizeType::Contracts(n) => n,
|
||||
SizeType::Percent(pct) => {
|
||||
let allocation = available_capital * pct;
|
||||
(allocation / contract_cost) as usize
|
||||
}
|
||||
SizeType::Notional(value) => (value / contract_cost) as usize,
|
||||
SizeType::RiskPercent(pct) => {
|
||||
// Max loss is the premium paid
|
||||
let risk_amount = available_capital * pct;
|
||||
(risk_amount / contract_cost) as usize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate P&L for a position.
|
||||
fn calculate_pnl(&self, position: &OptionsPosition, current_price: f64) -> f64 {
|
||||
let multiplier = self.config.lot_size as f64;
|
||||
(current_price - position.entry_price) * position.contracts as f64 * multiplier
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
let total_trades = trades.len();
|
||||
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal options position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct OptionsPosition {
|
||||
entry_idx: usize,
|
||||
entry_price: f64,
|
||||
#[allow(dead_code)]
|
||||
strike: f64,
|
||||
contracts: usize,
|
||||
#[allow(dead_code)]
|
||||
option_type: OptionType,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strike_selection_atm() {
|
||||
let config = OptionsConfig {
|
||||
strike_interval: 50.0,
|
||||
strike_selection: StrikeSelection::Atm,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// Spot at 17834, ATM should be 17850
|
||||
let strike = backtest.select_strike(17834.0);
|
||||
assert!((strike - 17850.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strike_selection_otm() {
|
||||
let config = OptionsConfig {
|
||||
strike_interval: 50.0,
|
||||
strike_selection: StrikeSelection::Otm(2),
|
||||
option_type: OptionType::Call,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// Spot at 17834, ATM=17850, OTM 2 strikes = 17950
|
||||
let strike = backtest.select_strike(17834.0);
|
||||
assert!((strike - 17950.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_sizing_percent() {
|
||||
let config = OptionsConfig {
|
||||
size_type: SizeType::Percent(0.5),
|
||||
lot_size: 50,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = OptionsBacktest::new(config);
|
||||
|
||||
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
|
||||
let contracts = backtest.calculate_contracts(100.0, 100_000.0);
|
||||
assert_eq!(contracts, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Pairs trading strategy backtest implementation.
|
||||
//!
|
||||
//! Supports long/short legs with hedge ratios.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
|
||||
OhlcvData, Trade,
|
||||
};
|
||||
use crate::execution::FeeModel;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
|
||||
/// Pairs trading configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairsConfig {
|
||||
/// Base backtest config.
|
||||
pub base: BacktestConfig,
|
||||
/// Hedge ratio (units of leg2 per unit of leg1).
|
||||
pub hedge_ratio: f64,
|
||||
/// Whether to dynamically update hedge ratio.
|
||||
pub dynamic_hedge: bool,
|
||||
/// Lookback period for dynamic hedge calculation.
|
||||
pub hedge_lookback: usize,
|
||||
/// Maximum spread for entry.
|
||||
pub max_spread: Option<f64>,
|
||||
/// Entry z-score threshold.
|
||||
pub entry_zscore: f64,
|
||||
/// Exit z-score threshold.
|
||||
pub exit_zscore: f64,
|
||||
}
|
||||
|
||||
impl Default for PairsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: BacktestConfig::default(),
|
||||
hedge_ratio: 1.0,
|
||||
dynamic_hedge: false,
|
||||
hedge_lookback: 20,
|
||||
max_spread: None,
|
||||
entry_zscore: 2.0,
|
||||
exit_zscore: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs trading backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct PairsBacktest {
|
||||
/// Configuration.
|
||||
config: PairsConfig,
|
||||
/// Fee model.
|
||||
fee_model: FeeModel,
|
||||
}
|
||||
|
||||
impl PairsBacktest {
|
||||
/// Create a new pairs backtest.
|
||||
pub fn new(config: PairsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run pairs trading backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `leg1_ohlcv` - OHLCV data for leg 1 (long leg when spread widens)
|
||||
/// * `leg2_ohlcv` - OHLCV data for leg 2 (short leg when spread widens)
|
||||
/// * `signals` - Entry/exit signals based on spread
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run(
|
||||
&self,
|
||||
leg1_ohlcv: &OhlcvData,
|
||||
leg2_ohlcv: &OhlcvData,
|
||||
signals: &CompiledSignals,
|
||||
) -> BacktestResult {
|
||||
let n = leg1_ohlcv.len();
|
||||
assert_eq!(n, leg2_ohlcv.len());
|
||||
assert_eq!(n, signals.len());
|
||||
|
||||
// Clean signals
|
||||
let processor = crate::signals::processor::SignalProcessor::new();
|
||||
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut position: Option<PairsPosition> = None;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
let mut trade_counter = 0u64;
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let leg1_price = leg1_ohlcv.close[i];
|
||||
let leg2_price = leg2_ohlcv.close[i];
|
||||
|
||||
// Calculate current hedge ratio
|
||||
let hedge_ratio = if self.config.dynamic_hedge && i >= self.config.hedge_lookback {
|
||||
self.calculate_hedge_ratio(
|
||||
&leg1_ohlcv.close[i - self.config.hedge_lookback..=i],
|
||||
&leg2_ohlcv.close[i - self.config.hedge_lookback..=i],
|
||||
)
|
||||
} else {
|
||||
self.config.hedge_ratio
|
||||
};
|
||||
|
||||
// Check for exit
|
||||
if exits[i] {
|
||||
if let Some(pos) = position.take() {
|
||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Return capital
|
||||
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
|
||||
|
||||
// Record trades for both legs
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: format!("{}_LEG1", signals.symbol),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.leg1_entry_price,
|
||||
exit_price: leg1_price,
|
||||
size: pos.leg1_size,
|
||||
direction: pos.leg1_direction,
|
||||
pnl: pnl / 2.0, // Split P&L attribution
|
||||
return_pct: return_pct / 2.0,
|
||||
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg1_ohlcv.timestamps[i],
|
||||
fees: fees / 2.0,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: format!("{}_LEG2", signals.symbol),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: i,
|
||||
entry_price: pos.leg2_entry_price,
|
||||
exit_price: leg2_price,
|
||||
size: pos.leg2_size,
|
||||
direction: pos.leg2_direction,
|
||||
pnl: pnl / 2.0,
|
||||
return_pct: return_pct / 2.0,
|
||||
entry_time: leg2_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg2_ohlcv.timestamps[i],
|
||||
fees: fees / 2.0,
|
||||
exit_reason: ExitReason::Signal,
|
||||
});
|
||||
|
||||
trade_counter += 1;
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry
|
||||
if entries[i] && position.is_none() {
|
||||
// Determine direction from signal direction
|
||||
let (leg1_dir, leg2_dir) = match signals.direction {
|
||||
Direction::Long => (Direction::Long, Direction::Short),
|
||||
Direction::Short => (Direction::Short, Direction::Long),
|
||||
};
|
||||
|
||||
// Calculate position sizes
|
||||
let allocation = cash * 0.5; // Use 50% per leg
|
||||
let leg1_size = allocation / leg1_price;
|
||||
let leg2_size = (allocation * hedge_ratio) / leg2_price;
|
||||
|
||||
let leg1_cost = leg1_size * leg1_price;
|
||||
let leg2_cost = leg2_size * leg2_price;
|
||||
let entry_fees = self.fee_model.calculate(leg1_price, leg1_size, leg1_dir)
|
||||
+ self.fee_model.calculate(leg2_price, leg2_size, leg2_dir);
|
||||
|
||||
cash -= leg1_cost + leg2_cost + entry_fees;
|
||||
|
||||
position = Some(PairsPosition {
|
||||
entry_idx: i,
|
||||
leg1_entry_price: leg1_price,
|
||||
leg2_entry_price: leg2_price,
|
||||
leg1_size,
|
||||
leg2_size,
|
||||
leg1_direction: leg1_dir,
|
||||
leg2_direction: leg2_dir,
|
||||
leg1_cost,
|
||||
leg2_cost,
|
||||
hedge_ratio,
|
||||
});
|
||||
}
|
||||
|
||||
// Update equity
|
||||
let position_value = if let Some(ref pos) = position {
|
||||
let _leg1_value = pos.leg1_size * leg1_price;
|
||||
let _leg2_value = pos.leg2_size * leg2_price;
|
||||
|
||||
// For pairs, value is long leg - short leg + cash equivalent
|
||||
let leg1_pnl = (leg1_price - pos.leg1_entry_price)
|
||||
* pos.leg1_size
|
||||
* pos.leg1_direction.multiplier();
|
||||
let leg2_pnl = (leg2_price - pos.leg2_entry_price)
|
||||
* pos.leg2_size
|
||||
* pos.leg2_direction.multiplier();
|
||||
|
||||
pos.leg1_cost + pos.leg2_cost + leg1_pnl + leg2_pnl
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Update drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining position
|
||||
if let Some(pos) = position.take() {
|
||||
let last_idx = n - 1;
|
||||
let leg1_price = leg1_ohlcv.close[last_idx];
|
||||
let leg2_price = leg2_ohlcv.close[last_idx];
|
||||
|
||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
symbol: signals.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx: last_idx,
|
||||
entry_price: pos.leg1_entry_price,
|
||||
exit_price: leg1_price,
|
||||
size: pos.leg1_size + pos.leg2_size,
|
||||
direction: pos.leg1_direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
|
||||
exit_time: leg1_ohlcv.timestamps[last_idx],
|
||||
fees,
|
||||
exit_reason: ExitReason::EndOfData,
|
||||
});
|
||||
|
||||
streaming.update(return_pct / 100.0);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Calculate hedge ratio using OLS regression.
|
||||
fn calculate_hedge_ratio(&self, leg1_prices: &[f64], leg2_prices: &[f64]) -> f64 {
|
||||
let n = leg1_prices.len() as f64;
|
||||
if n < 2.0 {
|
||||
return self.config.hedge_ratio;
|
||||
}
|
||||
|
||||
let sum_x: f64 = leg2_prices.iter().sum();
|
||||
let sum_y: f64 = leg1_prices.iter().sum();
|
||||
let sum_xy: f64 = leg1_prices
|
||||
.iter()
|
||||
.zip(leg2_prices.iter())
|
||||
.map(|(y, x)| x * y)
|
||||
.sum();
|
||||
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
|
||||
|
||||
let denominator = n * sum_x2 - sum_x * sum_x;
|
||||
if denominator.abs() < 1e-10 {
|
||||
return self.config.hedge_ratio;
|
||||
}
|
||||
|
||||
let beta = (n * sum_xy - sum_x * sum_y) / denominator;
|
||||
beta.max(0.1).min(10.0) // Constrain to reasonable range
|
||||
}
|
||||
|
||||
/// Close position and calculate P&L.
|
||||
fn close_position(
|
||||
&self,
|
||||
position: &PairsPosition,
|
||||
leg1_price: f64,
|
||||
leg2_price: f64,
|
||||
) -> (f64, f64) {
|
||||
let leg1_pnl = (leg1_price - position.leg1_entry_price)
|
||||
* position.leg1_size
|
||||
* position.leg1_direction.multiplier();
|
||||
|
||||
let leg2_pnl = (leg2_price - position.leg2_entry_price)
|
||||
* position.leg2_size
|
||||
* position.leg2_direction.multiplier();
|
||||
|
||||
let exit_fees =
|
||||
self.fee_model
|
||||
.calculate(leg1_price, position.leg1_size, position.leg1_direction)
|
||||
+ self
|
||||
.fee_model
|
||||
.calculate(leg2_price, position.leg2_size, position.leg2_direction);
|
||||
|
||||
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
|
||||
|
||||
(total_pnl, exit_fees)
|
||||
}
|
||||
|
||||
/// Calculate metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
trades: &[Trade],
|
||||
streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.base.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
// For pairs, count trade pairs (every 2 trades = 1 round trip)
|
||||
let total_trades = trades.len() / 2;
|
||||
let winning_trades = trades
|
||||
.chunks(2)
|
||||
.filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0)
|
||||
.count();
|
||||
let losing_trades = total_trades.saturating_sub(winning_trades);
|
||||
|
||||
let win_rate_pct = if total_trades > 0 {
|
||||
winning_trades as f64 / total_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||
let gross_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio: streaming.sharpe_ratio(252.0),
|
||||
sortino_ratio: streaming.sortino_ratio(252.0),
|
||||
calmar_ratio: if max_drawdown_pct > 0.0 {
|
||||
total_return_pct / max_drawdown_pct
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_drawdown_pct,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
total_trades,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal pairs position state.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PairsPosition {
|
||||
entry_idx: usize,
|
||||
leg1_entry_price: f64,
|
||||
leg2_entry_price: f64,
|
||||
leg1_size: f64,
|
||||
leg2_size: f64,
|
||||
leg1_direction: Direction,
|
||||
leg2_direction: Direction,
|
||||
leg1_cost: f64,
|
||||
leg2_cost: f64,
|
||||
#[allow(dead_code)]
|
||||
hedge_ratio: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_pairs_data() -> (OhlcvData, OhlcvData, CompiledSignals) {
|
||||
let n = 20;
|
||||
|
||||
// Leg 1: Trending up
|
||||
let leg1 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (100..100 + n).map(|x| x as f64).collect(),
|
||||
high: (101..101 + n).map(|x| x as f64).collect(),
|
||||
low: (99..99 + n).map(|x| x as f64).collect(),
|
||||
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Leg 2: Correlated but with different magnitude
|
||||
let leg2 = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: (50..50 + n).map(|x| x as f64).collect(),
|
||||
high: (51..51 + n).map(|x| x as f64).collect(),
|
||||
low: (49..49 + n).map(|x| x as f64).collect(),
|
||||
close: (50..50 + n).map(|x| x as f64 + 0.2).collect(),
|
||||
volume: vec![2000.0; n],
|
||||
};
|
||||
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[2] = true;
|
||||
exits[10] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "PAIR".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long, // Long leg1, short leg2
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(leg1, leg2, signals)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairs_backtest() {
|
||||
let config = PairsConfig::default();
|
||||
let backtest = PairsBacktest::new(config);
|
||||
let (leg1, leg2, signals) = sample_pairs_data();
|
||||
|
||||
let result = backtest.run(&leg1, &leg2, &signals);
|
||||
|
||||
// Should have trades for both legs
|
||||
assert!(result.trades.len() >= 2);
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hedge_ratio_calculation() {
|
||||
let config = PairsConfig {
|
||||
dynamic_hedge: true,
|
||||
hedge_lookback: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let backtest = PairsBacktest::new(config);
|
||||
|
||||
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
|
||||
let leg2 = vec![50.0, 51.0, 52.0, 53.0, 54.0];
|
||||
|
||||
let ratio = backtest.calculate_hedge_ratio(&leg1, &leg2);
|
||||
|
||||
// Ratio should be approximately 2 (leg1 moves 2x leg2)
|
||||
assert!(ratio > 1.5 && ratio < 2.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Single instrument backtest implementation.
|
||||
|
||||
use crate::core::types::{BacktestConfig, BacktestResult, CompiledSignals, OhlcvData};
|
||||
use crate::portfolio::engine::PortfolioEngine;
|
||||
|
||||
/// Single instrument backtest runner.
|
||||
#[derive(Debug)]
|
||||
pub struct SingleBacktest {
|
||||
/// Portfolio engine.
|
||||
engine: PortfolioEngine,
|
||||
}
|
||||
|
||||
impl SingleBacktest {
|
||||
/// Create a new single instrument backtest.
|
||||
pub fn new(config: BacktestConfig) -> Self {
|
||||
Self {
|
||||
engine: PortfolioEngine::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the backtest.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV price data
|
||||
/// * `signals` - Compiled trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result with metrics, trades, and equity curve
|
||||
pub fn run(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
|
||||
self.engine.run_single(ohlcv, signals)
|
||||
}
|
||||
|
||||
/// Run backtest from raw arrays.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `timestamps` - Timestamp array
|
||||
/// * `open` - Open prices
|
||||
/// * `high` - High prices
|
||||
/// * `low` - Low prices
|
||||
/// * `close` - Close prices
|
||||
/// * `volume` - Volume
|
||||
/// * `entries` - Entry signals
|
||||
/// * `exits` - Exit signals
|
||||
/// * `direction` - Trade direction (1 = long, -1 = short)
|
||||
/// * `symbol` - Symbol name
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_from_arrays(
|
||||
&self,
|
||||
timestamps: &[i64],
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
entries: &[bool],
|
||||
exits: &[bool],
|
||||
direction: i32,
|
||||
symbol: &str,
|
||||
) -> BacktestResult {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: timestamps.to_vec(),
|
||||
open: open.to_vec(),
|
||||
high: high.to_vec(),
|
||||
low: low.to_vec(),
|
||||
close: close.to_vec(),
|
||||
volume: volume.to_vec(),
|
||||
};
|
||||
|
||||
let dir = crate::core::types::Direction::from_int(direction)
|
||||
.unwrap_or(crate::core::types::Direction::Long);
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: symbol.to_string(),
|
||||
entries: entries.to_vec(),
|
||||
exits: exits.to_vec(),
|
||||
position_sizes: None,
|
||||
direction: dir,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
self.run(&ohlcv, &signals)
|
||||
}
|
||||
|
||||
/// Run backtest with position sizing.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV price data
|
||||
/// * `signals` - Compiled trading signals
|
||||
/// * `position_sizes` - Position size for each bar (fraction of capital)
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_with_sizing(
|
||||
&self,
|
||||
ohlcv: &OhlcvData,
|
||||
signals: &CompiledSignals,
|
||||
position_sizes: Vec<f64>,
|
||||
) -> BacktestResult {
|
||||
let mut signals_with_sizing = signals.clone();
|
||||
signals_with_sizing.position_sizes = Some(position_sizes);
|
||||
self.engine.run_single(ohlcv, &signals_with_sizing)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::types::{Direction, StopConfig, TargetConfig};
|
||||
|
||||
fn sample_data() -> (OhlcvData, CompiledSignals) {
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..20).map(|i| i as i64).collect(),
|
||||
open: vec![
|
||||
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
|
||||
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
|
||||
],
|
||||
high: vec![
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
|
||||
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
|
||||
],
|
||||
low: vec![
|
||||
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
|
||||
],
|
||||
close: vec![
|
||||
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
|
||||
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
|
||||
],
|
||||
volume: vec![1000.0; 20],
|
||||
};
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![
|
||||
false, true, false, false, false, false, false, false, false, false, false, true,
|
||||
false, false, false, false, false, false, false, false,
|
||||
],
|
||||
exits: vec![
|
||||
false, false, false, false, false, true, false, false, false, false, false, false,
|
||||
false, false, false, true, false, false, false, false,
|
||||
],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
(ohlcv, signals)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_backtest() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let backtest = SingleBacktest::new(config);
|
||||
let (ohlcv, signals) = sample_data();
|
||||
|
||||
let result = backtest.run(&ohlcv, &signals);
|
||||
|
||||
assert_eq!(result.trades.len(), 2);
|
||||
assert!(result.metrics.total_return_pct > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_arrays() {
|
||||
let config = BacktestConfig::default();
|
||||
let backtest = SingleBacktest::new(config);
|
||||
|
||||
let timestamps: Vec<i64> = (0..10).collect();
|
||||
let close: Vec<f64> = (100..110).map(|x| x as f64).collect();
|
||||
let open = close.clone();
|
||||
let high: Vec<f64> = close.iter().map(|x| x + 1.0).collect();
|
||||
let low: Vec<f64> = close.iter().map(|x| x - 1.0).collect();
|
||||
let volume = vec![1000.0; 10];
|
||||
|
||||
let entries = vec![
|
||||
false, true, false, false, false, false, false, false, false, false,
|
||||
];
|
||||
let exits = vec![
|
||||
false, false, false, false, false, true, false, false, false, false,
|
||||
];
|
||||
|
||||
let result = backtest.run_from_arrays(
|
||||
×tamps,
|
||||
&open,
|
||||
&high,
|
||||
&low,
|
||||
&close,
|
||||
&volume,
|
||||
&entries,
|
||||
&exits,
|
||||
1,
|
||||
"TEST",
|
||||
);
|
||||
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Integration tests for RaptorBT indicators.
|
||||
|
||||
use raptorbt::indicators::momentum::{macd, rsi, stochastic};
|
||||
use raptorbt::indicators::strength::adx;
|
||||
use raptorbt::indicators::trend::{ema, sma, supertrend};
|
||||
use raptorbt::indicators::volatility::{atr, bollinger_bands};
|
||||
use raptorbt::indicators::volume::vwap;
|
||||
|
||||
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
// Create sample OHLCV data with 50 bars
|
||||
let n = 50;
|
||||
let mut close: Vec<f64> = vec![100.0];
|
||||
let mut high: Vec<f64> = vec![101.0];
|
||||
let mut low: Vec<f64> = vec![99.0];
|
||||
let mut open: Vec<f64> = vec![100.0];
|
||||
let volume: Vec<f64> = vec![1000.0; n];
|
||||
|
||||
// Generate trending data
|
||||
for i in 1..n {
|
||||
let prev_close = close[i - 1];
|
||||
let change = ((i as f64 * 0.2).sin() * 2.0) + 0.5; // Slight uptrend with oscillation
|
||||
let new_close = prev_close + change;
|
||||
close.push(new_close);
|
||||
open.push(prev_close);
|
||||
high.push(new_close.max(prev_close) + 0.5);
|
||||
low.push(new_close.min(prev_close) - 0.5);
|
||||
}
|
||||
|
||||
(open, high, low, close, volume)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sma_correctness() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let result = sma(&data, 3).unwrap();
|
||||
|
||||
// First 2 values should be NaN
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
|
||||
// SMA(3) for [1,2,3] = 2.0
|
||||
assert!((result[2] - 2.0).abs() < 1e-10);
|
||||
// SMA(3) for [2,3,4] = 3.0
|
||||
assert!((result[3] - 3.0).abs() < 1e-10);
|
||||
// SMA(3) for [8,9,10] = 9.0
|
||||
assert!((result[9] - 9.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema_correctness() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let result = ema(&data, 3).unwrap();
|
||||
|
||||
// First 2 values should be NaN
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
|
||||
// EMA should be valid from index 2
|
||||
assert!(!result[2].is_nan());
|
||||
assert!(!result[9].is_nan());
|
||||
|
||||
// EMA should be between min and max
|
||||
assert!(result[9] >= 1.0 && result[9] <= 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rsi_range() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = rsi(&close, 14).unwrap();
|
||||
|
||||
// Check RSI is in valid range [0, 100]
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= 0.0 && value <= 100.0,
|
||||
"RSI at index {} is out of range: {}",
|
||||
i,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macd_structure() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = macd(&close, 12, 26, 9).unwrap();
|
||||
|
||||
assert_eq!(result.macd_line.len(), close.len());
|
||||
assert_eq!(result.signal_line.len(), close.len());
|
||||
assert_eq!(result.histogram.len(), close.len());
|
||||
|
||||
// MACD line should be valid from index 25 (slow_period - 1)
|
||||
assert!(result.macd_line[24].is_nan());
|
||||
assert!(!result.macd_line[25].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stochastic_range() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = stochastic(&high, &low, &close, 14, 3).unwrap();
|
||||
|
||||
// %K and %D should be in [0, 100]
|
||||
for (i, &k) in result.k.iter().enumerate() {
|
||||
if !k.is_nan() {
|
||||
assert!(
|
||||
k >= 0.0 && k <= 100.0,
|
||||
"%K at index {} is out of range: {}",
|
||||
i,
|
||||
k
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (i, &d) in result.d.iter().enumerate() {
|
||||
if !d.is_nan() {
|
||||
assert!(
|
||||
d >= 0.0 && d <= 100.0,
|
||||
"%D at index {} is out of range: {}",
|
||||
i,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_positive() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = atr(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// ATR should always be non-negative
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(value >= 0.0, "ATR at index {} is negative: {}", i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bollinger_bands_ordering() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = bollinger_bands(&close, 20, 2.0).unwrap();
|
||||
|
||||
// Upper > Middle > Lower
|
||||
for i in 19..close.len() {
|
||||
if !result.upper[i].is_nan() {
|
||||
assert!(
|
||||
result.upper[i] >= result.middle[i],
|
||||
"Upper band should be >= middle at index {}",
|
||||
i
|
||||
);
|
||||
assert!(
|
||||
result.middle[i] >= result.lower[i],
|
||||
"Middle band should be >= lower at index {}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adx_range() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = adx(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// ADX should be in [0, 100]
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= 0.0 && value <= 100.0,
|
||||
"ADX at index {} is out of range: {}",
|
||||
i,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vwap_bounds() {
|
||||
let (_, high, low, close, volume) = sample_ohlcv();
|
||||
let result = vwap(&high, &low, &close, &volume).unwrap();
|
||||
|
||||
// VWAP should be between the overall min low and max high
|
||||
let min_low = low.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let max_high = high.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= min_low && value <= max_high,
|
||||
"VWAP at index {} is out of bounds: {} (should be between {} and {})",
|
||||
i,
|
||||
value,
|
||||
min_low,
|
||||
max_high
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supertrend_direction() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = supertrend(&high, &low, &close, 10, 3.0).unwrap();
|
||||
|
||||
// Direction should be either 1 or -1
|
||||
for (i, &dir) in result.direction.iter().enumerate() {
|
||||
if dir != 0 {
|
||||
assert!(
|
||||
dir == 1 || dir == -1,
|
||||
"Supertrend direction at index {} is invalid: {}",
|
||||
i,
|
||||
dir
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_period() {
|
||||
let data = vec![1.0, 2.0, 3.0];
|
||||
|
||||
// Period of 0 should error
|
||||
assert!(sma(&data, 0).is_err());
|
||||
assert!(ema(&data, 0).is_err());
|
||||
assert!(rsi(&data, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_data() {
|
||||
let empty: Vec<f64> = vec![];
|
||||
|
||||
let result = sma(&empty, 10).unwrap();
|
||||
assert!(result.is_empty());
|
||||
|
||||
let result = ema(&empty, 10).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Integration tests for RaptorBT portfolio engine.
|
||||
|
||||
use raptorbt::core::types::{
|
||||
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
|
||||
};
|
||||
use raptorbt::portfolio::engine::PortfolioEngine;
|
||||
|
||||
fn sample_ohlcv() -> OhlcvData {
|
||||
// Create trending sample data
|
||||
let n = 100;
|
||||
let mut close = vec![100.0];
|
||||
let mut open = vec![100.0];
|
||||
let mut high = vec![101.0];
|
||||
let mut low = vec![99.0];
|
||||
|
||||
for i in 1..n {
|
||||
let trend = (i as f64) * 0.5; // Upward trend
|
||||
let noise = ((i as f64) * 0.3).sin() * 2.0;
|
||||
let new_close = 100.0 + trend + noise;
|
||||
close.push(new_close);
|
||||
open.push(close[i - 1]);
|
||||
high.push(new_close + 1.0);
|
||||
low.push(new_close - 1.0);
|
||||
}
|
||||
|
||||
OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume: vec![1000.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_signals(n: usize) -> CompiledSignals {
|
||||
// Entry at bar 10, exit at bar 50
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[50] = true;
|
||||
|
||||
CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_backtest() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 1 complete trade
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
|
||||
// Equity curve should have same length as data
|
||||
assert_eq!(result.equity_curve.len(), ohlcv.len());
|
||||
|
||||
// In an uptrend, should have positive return
|
||||
assert!(result.metrics.total_return_pct > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_trades() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Multiple trades
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[20] = true;
|
||||
entries[30] = true;
|
||||
exits[40] = true;
|
||||
entries[50] = true;
|
||||
exits[60] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 3 trades
|
||||
assert_eq!(result.trades.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_fees() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig {
|
||||
fees: 0.01, // 1% fee
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Trade should have fees deducted
|
||||
assert!(result.trades[0].fees > 0.0);
|
||||
|
||||
// Return should be lower due to fees
|
||||
let config_no_fees = BacktestConfig::default();
|
||||
let engine_no_fees = PortfolioEngine::new(config_no_fees);
|
||||
let result_no_fees = engine_no_fees.run_single(&ohlcv, &signals);
|
||||
|
||||
assert!(result.metrics.end_value < result_no_fees.metrics.end_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_stop_loss() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Entry at bar 10
|
||||
let mut entries = vec![false; n];
|
||||
entries[10] = true;
|
||||
let exits = vec![false; n]; // No exit signal
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig {
|
||||
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have at least one trade (may exit on stop or end of data)
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_take_profit() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Entry at bar 10
|
||||
let mut entries = vec![false; n];
|
||||
entries[10] = true;
|
||||
let exits = vec![false; n]; // No exit signal
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig {
|
||||
target: TargetConfig::Fixed { percent: 0.10 }, // 10% target
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have at least one trade
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trades() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// No entry signals
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![false; n],
|
||||
exits: vec![false; n],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have no trades
|
||||
assert_eq!(result.trades.len(), 0);
|
||||
assert_eq!(result.metrics.total_trades, 0);
|
||||
|
||||
// Equity should remain at initial capital
|
||||
assert!((result.metrics.end_value - result.metrics.start_value).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_positive() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// All drawdown values should be non-negative
|
||||
for dd in &result.drawdown_curve {
|
||||
assert!(*dd >= 0.0, "Drawdown should be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_direction() {
|
||||
// Create downtrend data
|
||||
let n = 100;
|
||||
let mut close = vec![100.0];
|
||||
for i in 1..n {
|
||||
close.push(100.0 - (i as f64) * 0.3); // Downward trend
|
||||
}
|
||||
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: close
|
||||
.iter()
|
||||
.skip(1)
|
||||
.chain(std::iter::once(&close[n - 1]))
|
||||
.cloned()
|
||||
.collect(),
|
||||
high: close.iter().map(|c| c + 1.0).collect(),
|
||||
low: close.iter().map(|c| c - 1.0).collect(),
|
||||
close: close.clone(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Entry at bar 10, exit at bar 50
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[50] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Short, // Short direction
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Short in a downtrend should be profitable
|
||||
assert!(result.trades[0].pnl > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_consistency() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Multiple trades for statistics
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
for i in (10..90).step_by(20) {
|
||||
entries[i] = true;
|
||||
exits[i + 10] = true;
|
||||
}
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Total trades should equal winning + losing
|
||||
assert_eq!(
|
||||
result.metrics.total_trades,
|
||||
result.metrics.winning_trades + result.metrics.losing_trades
|
||||
);
|
||||
|
||||
// Win rate should be in [0, 100]
|
||||
assert!(result.metrics.win_rate_pct >= 0.0);
|
||||
assert!(result.metrics.win_rate_pct <= 100.0);
|
||||
|
||||
// Exposure should be in [0, 100]
|
||||
assert!(result.metrics.exposure_pct >= 0.0);
|
||||
assert!(result.metrics.exposure_pct <= 100.0);
|
||||
}
|
||||
Reference in New Issue
Block a user