commit f6c60d7b8bacf1a6a80fd510272a2f31d2674cce Author: porcelaincode Date: Wed Jan 28 06:30:03 2026 +0530 Initial backtesting engine diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e23c5f6 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ff45d14 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5e99e17 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..66d4daf --- /dev/null +++ b/README.md @@ -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 diff --git a/benches/backtest_benchmark.rs b/benches/backtest_benchmark.rs new file mode 100644 index 0000000..7280c1d --- /dev/null +++ b/benches/backtest_benchmark.rs @@ -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); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..adfbca4 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py new file mode 100644 index 0000000..f51aabd --- /dev/null +++ b/python/raptorbt/__init__.py @@ -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", +] diff --git a/python/raptorbt/__pycache__/__init__.cpython-311.pyc b/python/raptorbt/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b49869a Binary files /dev/null and b/python/raptorbt/__pycache__/__init__.cpython-311.pyc differ diff --git a/python/raptorbt/_raptorbt.cpython-311-darwin.so b/python/raptorbt/_raptorbt.cpython-311-darwin.so new file mode 100755 index 0000000..d9a4ddf Binary files /dev/null and b/python/raptorbt/_raptorbt.cpython-311-darwin.so differ diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6af9106 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +imports_granularity = "Module" +group_imports = "StdExternalCrate" \ No newline at end of file diff --git a/src/core/error.rs b/src/core/error.rs new file mode 100644 index 0000000..4cada1f --- /dev/null +++ b/src/core/error.rs @@ -0,0 +1,91 @@ +//! Error types for RaptorBT. + +use thiserror::Error; + +/// Result type alias for RaptorBT operations. +pub type Result = std::result::Result; + +/// 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) -> 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) -> Self { + Self::InvalidConfig { + message: message.into(), + } + } + + /// Create a division by zero error. + pub fn division_by_zero(context: impl Into) -> Self { + Self::DivisionByZero { + context: context.into(), + } + } + + /// Create an empty data error. + pub fn empty_data(context: impl Into) -> Self { + Self::EmptyData { + context: context.into(), + } + } +} + +impl From for pyo3::PyErr { + fn from(err: RaptorError) -> pyo3::PyErr { + pyo3::exceptions::PyValueError::new_err(err.to_string()) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..c8ebc70 --- /dev/null +++ b/src/core/mod.rs @@ -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::*; diff --git a/src/core/timeseries.rs b/src/core/timeseries.rs new file mode 100644 index 0000000..fdf38ed --- /dev/null +++ b/src/core/timeseries.rs @@ -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 { + /// Timestamps for each value. + pub timestamps: Vec, + /// Values. + pub values: Vec, +} + +impl TimeSeries { + /// Create a new time series. + pub fn new(timestamps: Vec, values: Vec) -> Self { + debug_assert_eq!(timestamps.len(), values.len()); + Self { timestamps, values } + } + + /// Create from values only (no timestamps). + pub fn from_values(values: Vec) -> 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 { + 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(&self, f: F) -> TimeSeries + 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 { + self.timestamps.iter().copied().zip(self.values.iter()) + } +} + +impl TimeSeries { + /// Create with default values. + pub fn with_default(timestamps: Vec) -> Self { + let len = timestamps.len(); + Self { + timestamps, + values: vec![T::default(); len], + } + } +} + +impl TimeSeries { + /// 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::() / 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::() / (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(&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::() / 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::() / slice.len() as f64; + let variance = + slice.iter().map(|v| (v - mean).powi(2)).sum::() / (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 { + /// 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 { + 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); + } +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..95e657f --- /dev/null +++ b/src/core/types.rs @@ -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 { + 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, + pub open: Vec, + pub high: Vec, + pub low: Vec, + pub close: Vec, + pub volume: Vec, +} + +impl OhlcvData { + /// Create new OHLCV data from vectors. + pub fn new( + timestamps: Vec, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> 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 { + 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, + /// Exit signals (true = exit position). + pub exits: Vec, + /// Optional position sizes (fraction of capital). + pub position_sizes: Option>, + /// 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, + exits: Vec, + 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) -> 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, + /// Drawdown curve (drawdown percentage over time). + pub drawdown_curve: Vec, + /// List of executed trades. + pub trades: Vec, + /// Daily returns. + pub returns: Vec, +} + +impl BacktestResult { + /// Create a new backtest result. + pub fn new( + metrics: BacktestMetrics, + equity_curve: Vec, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, + ) -> 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, + /// Current target price. + pub target_price: Option, + /// 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, + target_price: Option, + 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() + } +} diff --git a/src/execution/fees.rs b/src/execution/fees.rs new file mode 100644 index 0000000..4f3e926 --- /dev/null +++ b/src/execution/fees.rs @@ -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); + } +} diff --git a/src/execution/fill.rs b/src/execution/fill.rs new file mode 100644 index 0000000..9f2b1ba --- /dev/null +++ b/src/execution/fill.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/src/execution/mod.rs b/src/execution/mod.rs new file mode 100644 index 0000000..261e273 --- /dev/null +++ b/src/execution/mod.rs @@ -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; diff --git a/src/execution/slippage.rs b/src/execution/slippage.rs new file mode 100644 index 0000000..7332e69 --- /dev/null +++ b/src/execution/slippage.rs @@ -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 { + 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, + ) -> 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); + } +} diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs new file mode 100644 index 0000000..f22ca22 --- /dev/null +++ b/src/indicators/mod.rs @@ -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}; diff --git a/src/indicators/momentum.rs b/src/indicators/momentum.rs new file mode 100644 index 0000000..b80e66d --- /dev/null +++ b/src/indicators/momentum.rs @@ -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> { + 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::() / period as f64; + let mut avg_loss: f64 = losses[1..=period].iter().sum::() / 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, + /// Signal line (EMA of MACD line). + pub signal_line: Vec, + /// Histogram (MACD line - signal line). + pub histogram: Vec, +} + +/// 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 { + 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, + /// %D line (slow stochastic, SMA of %K). + pub d: Vec, +} + +/// 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 { + 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 = (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()); + } +} diff --git a/src/indicators/strength.rs b/src/indicators/strength.rs new file mode 100644 index 0000000..1697ff4 --- /dev/null +++ b/src/indicators/strength.rs @@ -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> { + 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::() / 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, + /// -DI values. + pub minus_di: Vec, + /// ADX values. + pub adx: Vec, +} + +/// 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 { + 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::() / 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 = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (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 = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (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]); + } +} diff --git a/src/indicators/trend.rs b/src/indicators/trend.rs new file mode 100644 index 0000000..d0477b7 --- /dev/null +++ b/src/indicators/trend.rs @@ -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> { + 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> { + 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::() / 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 { + 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, + /// Direction: 1 = bullish (below price), -1 = bearish (above price). + pub direction: Vec, +} + +/// 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 { + 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())); + } +} diff --git a/src/indicators/volatility.rs b/src/indicators/volatility.rs new file mode 100644 index 0000000..4daacbf --- /dev/null +++ b/src/indicators/volatility.rs @@ -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> { + 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::() / 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, + /// Upper band (SMA + std_dev * multiplier). + pub upper: Vec, + /// Lower band (SMA - std_dev * multiplier). + pub lower: Vec, + /// Bandwidth: (upper - lower) / middle. + pub bandwidth: Vec, + /// %B: (price - lower) / (upper - lower). + pub percent_b: Vec, +} + +/// 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 { + 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::() + / 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, Vec, Vec)> { + 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 = (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); + } +} diff --git a/src/indicators/volume.rs b/src/indicators/volume.rs new file mode 100644 index 0000000..cfce796 --- /dev/null +++ b/src/indicators/volume.rs @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9490344 --- /dev/null +++ b/src/lib.rs @@ -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::()?; + m.add_class::()?; + m.add_class::()?; + + // Register result classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // 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(()) +} diff --git a/src/metrics/drawdown.rs b/src/metrics/drawdown.rs new file mode 100644 index 0000000..ddd952d --- /dev/null +++ b/src/metrics/drawdown.rs @@ -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 { + 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::() / 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); + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs new file mode 100644 index 0000000..a45da02 --- /dev/null +++ b/src/metrics/mod.rs @@ -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; diff --git a/src/metrics/streaming.rs b/src/metrics/streaming.rs new file mode 100644 index 0000000..34c2a54 --- /dev/null +++ b/src/metrics/streaming.rs @@ -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); + } +} diff --git a/src/metrics/trade_stats.rs b/src/metrics/trade_stats.rs new file mode 100644 index 0000000..a20a144 --- /dev/null +++ b/src/metrics/trade_stats.rs @@ -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::() / 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::() + / 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 { + use crate::core::types::ExitReason; + use std::collections::HashMap; + + let mut grouped: HashMap> = 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_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 = trades + .iter() + .filter(|t| t.direction == Direction::Long) + .cloned() + .collect(); + + let short_trades: Vec = 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 { + 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); + } +} diff --git a/src/portfolio/allocation.rs b/src/portfolio/allocation.rs new file mode 100644 index 0000000..55bfeab --- /dev/null +++ b/src/portfolio/allocation.rs @@ -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), + /// 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 { + 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 { + 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 = 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 = 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); + } +} diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs new file mode 100644 index 0000000..86ab836 --- /dev/null +++ b/src/portfolio/engine.rs @@ -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 = 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 = 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, Option) { + 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::() / 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 = closed_trades.iter().map(|t| t.pnl).collect(); + let mean = expectancy; + let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::() + / (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::() / 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::() + / 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::() + / 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::() + / 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::() + / 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::() + / 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 = 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::() / n_valid; + + // Calculate standard deviation + let variance = valid_returns + .iter() + .map(|r| (r - mean).powi(2)) + .sum::() + / (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 = 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::() / 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); + } +} diff --git a/src/portfolio/mod.rs b/src/portfolio/mod.rs new file mode 100644 index 0000000..8338c30 --- /dev/null +++ b/src/portfolio/mod.rs @@ -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; diff --git a/src/portfolio/position.rs b/src/portfolio/position.rs new file mode 100644 index 0000000..c0b7cf1 --- /dev/null +++ b/src/portfolio/position.rs @@ -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 { + 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, + target_price: Option, + 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 { + 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 + } +} diff --git a/src/python/bindings.rs b/src/python/bindings.rs new file mode 100644 index 0000000..c548408 --- /dev/null +++ b/src/python/bindings.rs @@ -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, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, +} + +#[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, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, + #[pyo3(get, set)] + pub ratio: Option, +} + +#[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 { + 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, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, +} + +#[pymethods] +impl PyBacktestResult { + /// Get equity curve as numpy array. + fn equity_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + 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 { + vec_to_numpy_f64(py, self.drawdown_curve.clone()) + } + + /// Get returns as numpy array. + fn returns<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.returns.clone()) + } + + /// Get list of trades. + fn trades(&self) -> Vec { + 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, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + weight: f64, + symbol: &str, + config: Option<&PyBacktestConfig>, + position_sizes: Option>, +) -> PyResult { + 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, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + i32, + f64, + String, + )>, + config: Option<&PyBacktestConfig>, + sync_mode: &str, +) -> PyResult { + 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, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + option_prices: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + 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 { + 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, + leg1_open: PyReadonlyArray1, + leg1_high: PyReadonlyArray1, + leg1_low: PyReadonlyArray1, + leg1_close: PyReadonlyArray1, + leg1_volume: PyReadonlyArray1, + leg2_timestamps: PyReadonlyArray1, + leg2_open: PyReadonlyArray1, + leg2_high: PyReadonlyArray1, + leg2_low: PyReadonlyArray1, + leg2_close: PyReadonlyArray1, + leg2_volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + symbol: &str, + config: Option<&PyBacktestConfig>, + hedge_ratio: f64, + dynamic_hedge: bool, +) -> PyResult { + 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, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + strategies: Vec<( + PyReadonlyArray1, + PyReadonlyArray1, + i32, + f64, + String, + )>, + config: Option<&PyBacktestConfig>, + combine_mode: &str, +) -> PyResult { + 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 = 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, + period: usize, +) -> PyResult<&'py PyArray1> { + 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, + period: usize, +) -> PyResult<&'py PyArray1> { + 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, + period: usize, +) -> PyResult<&'py PyArray1> { + 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, + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + 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, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + k_period: usize, + d_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + 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, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + 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, + period: usize, + std_dev: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + 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, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + 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, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + 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, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, + multiplier: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + 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 = 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, + } +} diff --git a/src/python/mod.rs b/src/python/mod.rs new file mode 100644 index 0000000..c4527d9 --- /dev/null +++ b/src/python/mod.rs @@ -0,0 +1,4 @@ +//! Python bindings for RaptorBT. + +pub mod bindings; +pub mod numpy_bridge; diff --git a/src/python/numpy_bridge.rs b/src/python/numpy_bridge.rs new file mode 100644 index 0000000..9770b53 --- /dev/null +++ b/src/python/numpy_bridge.rs @@ -0,0 +1,34 @@ +//! Zero-copy numpy array interface. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_f64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_i64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy bool array to Vec. +pub fn numpy_to_vec_bool(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} diff --git a/src/signals/expression.rs b/src/signals/expression.rs new file mode 100644 index 0000000..ba7af07 --- /dev/null +++ b/src/signals/expression.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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] + } +} diff --git a/src/signals/mod.rs b/src/signals/mod.rs new file mode 100644 index 0000000..e16b71a --- /dev/null +++ b/src/signals/mod.rs @@ -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}; diff --git a/src/signals/processor.rs b/src/signals/processor.rs new file mode 100644 index 0000000..a1097c3 --- /dev/null +++ b/src/signals/processor.rs @@ -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, Vec) { + 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, Vec, Vec, Vec) { + 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 = 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, Vec) { + 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, Vec) { + let entry_indices: Vec = entries + .iter() + .enumerate() + .filter_map(|(i, &e)| if e { Some(i) } else { None }) + .collect(); + + let exit_indices: Vec = 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/signals/synchronizer.rs b/src/signals/synchronizer.rs new file mode 100644 index 0000000..a843e1c --- /dev/null +++ b/src/signals/synchronizer.rs @@ -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, +} + +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 { + 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 { + 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, Vec) { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/stops/atr.rs b/src/stops/atr.rs new file mode 100644 index 0000000..71391b5 --- /dev/null +++ b/src/stops/atr.rs @@ -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 { + 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, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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, + direction: Direction, + ) -> Option { + 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 { + 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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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()); + } +} diff --git a/src/stops/fixed.rs b/src/stops/fixed.rs new file mode 100644 index 0000000..3718478 --- /dev/null +++ b/src/stops/fixed.rs @@ -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 { + 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, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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, + direction: Direction, + ) -> Option { + 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, + direction: Direction, + ) -> Option { + 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()); + } +} diff --git a/src/stops/mod.rs b/src/stops/mod.rs new file mode 100644 index 0000000..1baa81d --- /dev/null +++ b/src/stops/mod.rs @@ -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; + + /// Update stop price for trailing stops. + fn update_stop( + &self, + current_stop: Option, + current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option; +} + +/// Take-profit calculator trait. +pub trait TargetCalculator { + /// Calculate target price for a new position. + fn calculate_target( + &self, + entry_price: Price, + stop_price: Option, + direction: Direction, + ) -> Option; +} diff --git a/src/stops/trailing.rs b/src/stops/trailing.rs new file mode 100644 index 0000000..e781272 --- /dev/null +++ b/src/stops/trailing.rs @@ -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, +} + +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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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 { + Some(self.stop_for_step(entry_price, 0, direction)) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + // 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 { + if self.current_sar > 0.0 { + Some(self.current_sar) + } else { + None + } + } + + fn update_stop( + &self, + _current_stop: Option, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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); + } +} diff --git a/src/strategies/basket.rs b/src/strategies/basket.rs new file mode 100644 index 0000000..f34a0cb --- /dev/null +++ b/src/strategies/basket.rs @@ -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> = 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 = 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 = instruments.iter().map(|(o, _)| o.close[i]).collect(); + let weights: Vec = 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 { + 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()); + } +} diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs new file mode 100644 index 0000000..88e54e9 --- /dev/null +++ b/src/strategies/mod.rs @@ -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; diff --git a/src/strategies/multi.rs b/src/strategies/multi.rs new file mode 100644 index 0000000..89b6bb3 --- /dev/null +++ b/src/strategies/multi.rs @@ -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, + /// Strategy weights (for weighted mode). + pub strategy_weights: Vec, +} + +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 = Vec::new(); + let mut strategy_equities: Vec> = 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::() + .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) { + 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); + } +} diff --git a/src/strategies/options.rs b/src/strategies/options.rs new file mode 100644 index 0000000..6254450 --- /dev/null +++ b/src/strategies/options.rs @@ -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, +} + +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 = 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 = 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); + } +} diff --git a/src/strategies/pairs.rs b/src/strategies/pairs.rs new file mode 100644 index 0000000..a1e7bea --- /dev/null +++ b/src/strategies/pairs.rs @@ -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, + /// 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 = 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 = 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::() > 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); + } +} diff --git a/src/strategies/single.rs b/src/strategies/single.rs new file mode 100644 index 0000000..7a52d61 --- /dev/null +++ b/src/strategies/single.rs @@ -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, + ) -> 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 = (0..10).collect(); + let close: Vec = (100..110).map(|x| x as f64).collect(); + let open = close.clone(); + let high: Vec = close.iter().map(|x| x + 1.0).collect(); + let low: Vec = 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); + } +} diff --git a/tests/test_indicators.rs b/tests/test_indicators.rs new file mode 100644 index 0000000..2850a1b --- /dev/null +++ b/tests/test_indicators.rs @@ -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, Vec, Vec, Vec, Vec) { + // Create sample OHLCV data with 50 bars + let n = 50; + let mut close: Vec = vec![100.0]; + let mut high: Vec = vec![101.0]; + let mut low: Vec = vec![99.0]; + let mut open: Vec = vec![100.0]; + let volume: Vec = 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 = vec![]; + + let result = sma(&empty, 10).unwrap(); + assert!(result.is_empty()); + + let result = ema(&empty, 10).unwrap(); + assert!(result.is_empty()); +} diff --git a/tests/test_portfolio.rs b/tests/test_portfolio.rs new file mode 100644 index 0000000..a919c49 --- /dev/null +++ b/tests/test_portfolio.rs @@ -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); +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8a412b3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "raptorbt" +version = "0.1.0" +source = { editable = "." }