Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d9e47a2e | |||
| 511d3a27f7 | |||
| 5b23b36261 | |||
| 5867f71450 | |||
| 2be21df803 | |||
| 498b74a5ae | |||
| 921a250715 | |||
| 0479191b66 | |||
| 4631519885 | |||
| 0b85142ad1 | |||
| 1ab9bc70d1 | |||
| 2ab578bee8 | |||
| 2be39b8b98 | |||
| 99af5f8ee1 | |||
| bff1148d20 | |||
| ebddc5e376 | |||
| eab2649f1c | |||
| f7f947e048 | |||
| 01dd08714b | |||
| 0fa70c9882 | |||
| debe4523d5 | |||
| a046c441e5 | |||
| f7b91f6fa5 | |||
| 5030360a0c | |||
| 1dd487fabc | |||
| cee174c0de | |||
| c8e5d8a658 | |||
| dc2e19e762 | |||
| d8212beff6 | |||
| 86a595d505 | |||
| 9309bf9d60 | |||
| 3f05342f72 | |||
| eb4454ab27 | |||
| 3093f194a2 | |||
| 21c86f348f | |||
| a2ff35f5f9 | |||
| 01aeb965d1 | |||
| f1fed6cdd5 |
@@ -0,0 +1,3 @@
|
||||
# Shell scripts must keep LF line endings so they run on Linux/macOS CI and
|
||||
# local shells regardless of the committer's platform autocrlf setting.
|
||||
*.sh text eol=lf
|
||||
@@ -3,4 +3,3 @@
|
||||
# Leave a key empty (e.g. patreon:) to skip a platform.
|
||||
|
||||
github: [kingchenc]
|
||||
custom: ["https://wickra.org/sponsor"]
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
|
||||
and the `README.md` are updated (If applicable). Wiki edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
|
||||
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
@@ -27,6 +27,19 @@ updates:
|
||||
commit-message:
|
||||
prefix: "deps(pip)"
|
||||
|
||||
# Hash-pinned CI/bench Python tooling under .github/requirements/. Each
|
||||
# <name>.in is the loose source; the matching hash-locked <name>.txt is the
|
||||
# output regenerated by scripts/update-lockfiles.sh (uv). Dependabot keeps the
|
||||
# pins fresh; ci-dev-py39.in caps numpy <2.1 so 3.9 stays installable. Any
|
||||
# bump that breaks a matrix row surfaces in the PR's CI run.
|
||||
- package-ecosystem: pip
|
||||
directory: "/.github/requirements"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(ci-pip)"
|
||||
|
||||
# GitHub Actions — keeps the SHA-pinned actions current (Dependabot reads
|
||||
# the version comment after each pinned SHA and bumps both together).
|
||||
- package-ecosystem: github-actions
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Python deps + peer TA libraries for the bench.yml cross-library benchmark.
|
||||
# Loose source spec — the pinned, hash-locked output is generated from this:
|
||||
# bench.txt (Python 3.11) via scripts/update-lockfiles.sh
|
||||
# bench.yml runs on a single Python version (3.11), so one output suffices.
|
||||
maturin
|
||||
numpy
|
||||
pandas
|
||||
talipp
|
||||
finta
|
||||
@@ -0,0 +1,167 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
finta==1.3 \
|
||||
--hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \
|
||||
--hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2
|
||||
# via -r .github/requirements/bench.in
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/bench.in
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via
|
||||
# -r .github/requirements/bench.in
|
||||
# finta
|
||||
# pandas
|
||||
pandas==3.0.3 \
|
||||
--hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
|
||||
--hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
|
||||
--hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \
|
||||
--hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \
|
||||
--hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \
|
||||
--hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \
|
||||
--hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \
|
||||
--hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \
|
||||
--hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \
|
||||
--hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \
|
||||
--hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \
|
||||
--hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \
|
||||
--hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \
|
||||
--hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \
|
||||
--hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \
|
||||
--hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \
|
||||
--hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \
|
||||
--hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \
|
||||
--hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \
|
||||
--hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \
|
||||
--hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \
|
||||
--hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \
|
||||
--hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \
|
||||
--hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \
|
||||
--hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \
|
||||
--hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \
|
||||
--hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \
|
||||
--hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \
|
||||
--hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \
|
||||
--hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \
|
||||
--hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \
|
||||
--hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \
|
||||
--hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \
|
||||
--hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \
|
||||
--hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \
|
||||
--hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \
|
||||
--hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \
|
||||
--hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \
|
||||
--hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \
|
||||
--hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \
|
||||
--hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \
|
||||
--hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \
|
||||
--hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \
|
||||
--hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \
|
||||
--hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \
|
||||
--hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \
|
||||
--hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \
|
||||
--hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09
|
||||
# via
|
||||
# -r .github/requirements/bench.in
|
||||
# finta
|
||||
python-dateutil==2.9.0.post0 \
|
||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
# via pandas
|
||||
six==1.17.0 \
|
||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
||||
# via python-dateutil
|
||||
talipp==2.7.0 \
|
||||
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
|
||||
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
|
||||
# via -r .github/requirements/bench.in
|
||||
tzdata==2026.2 \
|
||||
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
|
||||
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
|
||||
# via pandas
|
||||
@@ -0,0 +1,7 @@
|
||||
# Python 3.10+ dev/test tooling for the ci.yml binding test job
|
||||
# (covers the 3.11 / 3.12 / 3.13 matrix rows). Locked output: ci-dev-py3.txt
|
||||
# Refresh via scripts/update-lockfiles.sh.
|
||||
maturin
|
||||
pytest
|
||||
numpy
|
||||
hypothesis
|
||||
@@ -0,0 +1,124 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
colorama==0.4.6 \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via pytest
|
||||
hypothesis==6.155.1 \
|
||||
--hash=sha256:07c102031612b98d7c1be15ca3608c43e1234d9d07e3a190a53fa01536700196 \
|
||||
--hash=sha256:2753f469df3ba3c483b08e0c37dbcbc41d8316ebb921abcc07493ee9c8a7d187
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pytest==9.0.3 \
|
||||
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
|
||||
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
sortedcontainers==2.4.0 \
|
||||
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
|
||||
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
|
||||
# via hypothesis
|
||||
@@ -0,0 +1,8 @@
|
||||
# Python 3.9 dev/test tooling for the ci.yml binding test job.
|
||||
# numpy is capped <2.1 because that is the last series shipping cp39 wheels
|
||||
# (>=2.1 dropped Python 3.9). Locked output: ci-dev-py39.txt
|
||||
# Refresh via scripts/update-lockfiles.sh.
|
||||
maturin
|
||||
pytest
|
||||
numpy<2.1
|
||||
hypothesis
|
||||
@@ -0,0 +1,162 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
attrs==26.1.0 \
|
||||
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
||||
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
||||
# via hypothesis
|
||||
colorama==0.4.6 \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via pytest
|
||||
exceptiongroup==1.3.1 \
|
||||
--hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \
|
||||
--hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598
|
||||
# via
|
||||
# hypothesis
|
||||
# pytest
|
||||
hypothesis==6.141.1 \
|
||||
--hash=sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f \
|
||||
--hash=sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
iniconfig==2.1.0 \
|
||||
--hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
|
||||
--hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
|
||||
# via pytest
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
numpy==2.0.2 \
|
||||
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
|
||||
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
|
||||
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
|
||||
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
|
||||
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
|
||||
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
|
||||
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
|
||||
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
|
||||
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
|
||||
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
|
||||
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
|
||||
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
|
||||
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
|
||||
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
|
||||
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
|
||||
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
|
||||
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
|
||||
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
|
||||
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
|
||||
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
|
||||
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
|
||||
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
|
||||
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
|
||||
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
|
||||
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
|
||||
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
|
||||
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
|
||||
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
|
||||
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
|
||||
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
|
||||
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
|
||||
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
|
||||
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
|
||||
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
|
||||
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
|
||||
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
|
||||
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
|
||||
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
|
||||
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
|
||||
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
|
||||
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
|
||||
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
|
||||
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
|
||||
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
|
||||
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pytest==8.4.2 \
|
||||
--hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \
|
||||
--hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
sortedcontainers==2.4.0 \
|
||||
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
|
||||
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
|
||||
# via hypothesis
|
||||
tomli==2.4.1 \
|
||||
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
|
||||
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
|
||||
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
|
||||
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
|
||||
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
|
||||
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
|
||||
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
|
||||
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
|
||||
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
|
||||
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
|
||||
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
|
||||
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
|
||||
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
|
||||
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
|
||||
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
|
||||
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
|
||||
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
|
||||
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
|
||||
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
|
||||
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
|
||||
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
|
||||
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
|
||||
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
|
||||
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
|
||||
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
|
||||
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
|
||||
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
|
||||
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
|
||||
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
|
||||
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
|
||||
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
|
||||
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
|
||||
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
|
||||
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
|
||||
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
|
||||
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
|
||||
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
|
||||
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
|
||||
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
|
||||
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
|
||||
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
|
||||
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
|
||||
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
|
||||
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
|
||||
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
|
||||
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
|
||||
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
|
||||
# via
|
||||
# maturin
|
||||
# pytest
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
# via exceptiongroup
|
||||
@@ -22,8 +22,26 @@ on:
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The single job
|
||||
# only builds and uploads an artifact (upload-artifact uses the artifact
|
||||
# storage API, not the contents scope), so it never needs repo write (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / PyPI inside any build step (cargo,
|
||||
# maturin, pip) retries automatically instead of failing the job. Cargo treats
|
||||
# "couldn't resolve host" / connect / timeout as spurious and retries with
|
||||
# backoff; 10 attempts ride out a transient DNS blip on a runner.
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
cross-library-bench:
|
||||
@@ -35,16 +53,39 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/bench.txt
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/bench.txt
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
# Hash-locked deps (OpenSSF Scorecard PinnedDependencies). bench.yml
|
||||
# runs on a single Python version (3.11), so one lock file suffices.
|
||||
python -m pip install --require-hashes -r .github/requirements/bench.txt
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
@@ -56,10 +97,16 @@ jobs:
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
# workflow_dispatch inputs are untrusted; pass them through the
|
||||
# environment and quote them rather than interpolating into the shell
|
||||
# command (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
|
||||
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries \
|
||||
--size ${{ github.event.inputs.size || '20000' }} \
|
||||
--iterations ${{ github.event.inputs.iterations || '10' }} \
|
||||
--size "$BENCH_SIZE" \
|
||||
--iterations "$BENCH_ITERATIONS" \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
|
||||
+176
-2
@@ -6,9 +6,29 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
|
||||
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
|
||||
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
|
||||
# napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
|
||||
# failing the job and needing a manual re-run. Cargo treats "couldn't resolve
|
||||
# host" / connect / timeout as spurious and retries with backoff; 10 attempts
|
||||
# ride out a transient DNS blip on a runner. Complements the setup-action /
|
||||
# cache retries (which only covered toolchain download + cache restore).
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -28,6 +48,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -55,6 +77,95 @@ jobs:
|
||||
# streaming.
|
||||
run: cargo build -p wickra-examples --bins
|
||||
|
||||
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
|
||||
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
|
||||
# browser-WASM and Python examples otherwise have no build gate, so a broken
|
||||
# edit could land unnoticed. This is a parse-only smoke — actually running the
|
||||
# examples needs the built native binding / wasm module / wheel, which the
|
||||
# binding jobs provide separately.
|
||||
examples-smoke:
|
||||
name: Examples (syntax smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Node examples — syntax check
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/node/*.js examples/wasm/*.js; do
|
||||
echo "node --check $f"
|
||||
node --check "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "checked $count Node/WASM .js files"
|
||||
|
||||
- name: WASM demo module scripts — syntax check
|
||||
# The .html demos embed an ES module; extract it and parse-check so a
|
||||
# broken edit to the in-page strategy logic fails CI.
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/wasm/*.html; do
|
||||
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
|
||||
echo "node --check (module of) $f"
|
||||
node --check module-check.mjs
|
||||
count=$((count + 1))
|
||||
done
|
||||
rm -f module-check.mjs
|
||||
echo "checked $count WASM .html module scripts"
|
||||
|
||||
- name: Python examples — byte-compile
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/python/*.py; do
|
||||
echo "py_compile $f"
|
||||
python -m py_compile "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "compiled $count Python files"
|
||||
|
||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||
@@ -71,17 +182,49 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
@@ -118,6 +261,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
@@ -139,9 +284,12 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
@@ -191,6 +339,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
with:
|
||||
workspaces: fuzz
|
||||
|
||||
@@ -203,6 +353,7 @@ jobs:
|
||||
# never gets off the ground. The prebuilt binary avoids the entire
|
||||
# transitive-dep compile.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
@@ -242,6 +393,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# setup-python downloads the interpreter from the Actions tool cache /
|
||||
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
||||
@@ -254,6 +407,8 @@ jobs:
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/ci-dev-*.txt
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
@@ -267,11 +422,21 @@ jobs:
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/ci-dev-*.txt
|
||||
|
||||
- name: Install Python dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin pytest numpy hypothesis
|
||||
# Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
|
||||
# Split by Python version: numpy ships no single release with wheels
|
||||
# for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
|
||||
if [ "${{ matrix.python-version }}" = "3.9" ]; then
|
||||
python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
|
||||
else
|
||||
python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
|
||||
fi
|
||||
|
||||
- name: Build wheel
|
||||
working-directory: bindings/python
|
||||
@@ -304,6 +469,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install wasm-pack
|
||||
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
|
||||
@@ -314,6 +481,7 @@ jobs:
|
||||
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||
# release, which has `--features` as a top-level flag (since 0.12).
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -345,6 +513,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
||||
# Windows runners with "Attempting to download 18..." followed by a
|
||||
@@ -356,6 +526,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: bindings/node/package-lock.json
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
@@ -369,10 +541,12 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: bindings/node/package-lock.json
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build native module
|
||||
working-directory: bindings/node
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: CodeQL
|
||||
|
||||
# Static analysis security testing (findings P13.x). Analyses the Rust core and
|
||||
# the Python / JavaScript binding surfaces with GitHub's CodeQL engine. Results
|
||||
# appear under Security → Code scanning. `build-mode: none` analyses source
|
||||
# directly — no compilation step — for every language here.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '31 3 * * 0' # Sundays 03:31 UTC
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The analyze job
|
||||
# raises exactly the scopes CodeQL needs (security-events: write to upload
|
||||
# results) in its own job-level block below; this top-level read-only default
|
||||
# covers any future job (OpenSSF Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload CodeQL results to code-scanning
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: rust
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
+230
-14
@@ -5,8 +5,30 @@ on:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
|
||||
# (cargo/python/node) push to external registries via their own secrets
|
||||
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
|
||||
# they need no repo write. The jobs that genuinely write through the
|
||||
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
|
||||
# attestations (id-token / attestations: write) — declare those rights in their
|
||||
# own job-level permissions blocks, which override this default (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / npm inside any build or publish step
|
||||
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
|
||||
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
|
||||
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -26,6 +48,8 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# Idempotent publishing: if the version is already on crates.io we
|
||||
# treat that as success so re-runs of the workflow don't fail.
|
||||
@@ -85,16 +109,21 @@ jobs:
|
||||
# re-resolving Cargo.lock.
|
||||
- name: Install cargo-cyclonedx
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
|
||||
- name: Generate CycloneDX SBOMs
|
||||
run: |
|
||||
cargo cyclonedx --format json --top-level -p wickra-core
|
||||
cargo cyclonedx --format json --top-level -p wickra-data
|
||||
cargo cyclonedx --format json --top-level -p wickra
|
||||
# cargo-cyclonedx walks the whole workspace in a single pass and
|
||||
# writes a <package>.cdx.json next to each member's Cargo.toml; it
|
||||
# has no -p/--package selector. Collect the three crates.io crates
|
||||
# (the .crate files published by this job) into the upload dir.
|
||||
cargo cyclonedx --format json --top-level
|
||||
mkdir -p sboms
|
||||
find . -name "*.cdx.json" -not -path "./target/*" -exec cp {} sboms/ \;
|
||||
cp crates/wickra-core/wickra-core.cdx.json sboms/
|
||||
cp crates/wickra-data/wickra-data.cdx.json sboms/
|
||||
cp crates/wickra/wickra.cdx.json sboms/
|
||||
ls -lh sboms/
|
||||
|
||||
- name: Upload SBOMs
|
||||
@@ -127,7 +156,21 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Sync root README into bindings/python so it ships with the wheel
|
||||
@@ -202,7 +245,23 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -211,10 +270,12 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build native module
|
||||
working-directory: bindings/node
|
||||
@@ -243,14 +304,31 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Download all platform binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -393,7 +471,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -406,6 +501,7 @@ jobs:
|
||||
# See the matching note in ci.yml: jetli's default installs an old
|
||||
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -424,7 +520,7 @@ jobs:
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
||||
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
||||
pkg.author = 'kingchenc <support@wickra.org>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||
@@ -453,13 +549,24 @@ jobs:
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GitHub Release: attach every built artefact to the tag's release page.
|
||||
#
|
||||
# The release is created as a DRAFT here and only flipped to published by the
|
||||
# downstream publish-release job, after the provenance bundle is attached. That
|
||||
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
|
||||
# with GitHub release immutability, which locks assets at publish time (P24):
|
||||
# the old "publish, then upload provenance" order would have the provenance
|
||||
# upload rejected once immutability is enabled.
|
||||
# --------------------------------------------------------------------------
|
||||
github-release:
|
||||
name: Attach assets to the GitHub Release
|
||||
name: Attach assets to the draft GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# Expose the resolved tag so the attestations job can attach the provenance
|
||||
# bundle to this same release without re-resolving it.
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -504,7 +611,7 @@ jobs:
|
||||
ls -lh release-assets/
|
||||
echo "asset-count=$(ls release-assets/ | wc -l)"
|
||||
|
||||
- name: Create / update GitHub Release with assets
|
||||
- name: Create / update the draft GitHub Release with assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
@@ -512,6 +619,9 @@ jobs:
|
||||
files: release-assets/*
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
# Created as a draft; publish-release flips it to published + latest once
|
||||
# the provenance bundle is attached (P24, immutability-ready).
|
||||
draft: true
|
||||
body: |
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||
|
||||
@@ -537,4 +647,110 @@ jobs:
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Build provenance attestations (findings P13.2)
|
||||
# --------------------------------------------------------------------------
|
||||
attestations:
|
||||
name: Attest build provenance
|
||||
needs: [cargo-publish, python-wheels, python-sdist, github-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Signed SLSA build-provenance attestations for the published crates and
|
||||
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
|
||||
# from `npm publish --provenance`, so they are covered there.
|
||||
#
|
||||
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
|
||||
# of github-release, so a Sigstore hiccup here can never block or corrupt a
|
||||
# publish (the isolation the SBOM step lacked before #79). It additionally
|
||||
# `needs: github-release` so the (still-draft) GitHub Release already exists
|
||||
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
|
||||
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
|
||||
# not GitHub's separate attestations store, so the bundle has to live on the
|
||||
# release. The release is published afterwards by the publish-release job
|
||||
# whether or not this attestation succeeds (P24), so a failure here still only
|
||||
# costs the provenance asset, never the release.
|
||||
permissions:
|
||||
id-token: write # OIDC for keyless Sigstore signing
|
||||
attestations: write # write the attestations to this repo
|
||||
contents: write # upload the provenance bundle as a release asset
|
||||
steps:
|
||||
- name: Download crate files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: crate-files
|
||||
path: artifacts/crates
|
||||
- name: Download wheels + sdist
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: wheels-*
|
||||
path: artifacts/python
|
||||
merge-multiple: true
|
||||
- name: Attest build provenance
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/crates/*.crate
|
||||
artifacts/python/*.whl
|
||||
artifacts/python/*.tar.gz
|
||||
|
||||
# Attach the Sigstore provenance bundle to the GitHub Release as a
|
||||
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
|
||||
# signed provenance on the release itself (P21.1e). attest-build-provenance
|
||||
# writes a single JSONL bundle covering every subject above; copy it to a
|
||||
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
|
||||
# idempotent. github.token has contents: write here, which is all gh needs.
|
||||
- name: Attach provenance bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.github-release.outputs.tag }}
|
||||
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::no tag resolved from github-release; cannot attach provenance."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
|
||||
echo "::error::attestation bundle not found at '$BUNDLE'."
|
||||
exit 1
|
||||
fi
|
||||
dest="wickra-${TAG}.provenance.intoto.jsonl"
|
||||
cp "$BUNDLE" "$dest"
|
||||
echo "Uploading $dest to release $TAG"
|
||||
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Publish the drafted release LAST (P24 — immutability-ready).
|
||||
#
|
||||
# github-release creates the release as a draft and attestations attaches the
|
||||
# provenance bundle to it; only now, with every asset in place, is it flipped to
|
||||
# published + latest. With GitHub release immutability enabled, assets lock at
|
||||
# this publish step — so the provenance bundle and every build artefact are
|
||||
# already present and never need a (rejected) post-publish upload.
|
||||
#
|
||||
# `if: always() && needs.github-release.result == 'success'` preserves the old
|
||||
# robustness: the release is published whenever the draft was created, even if
|
||||
# the attestations job hit a Sigstore hiccup — that only costs the provenance
|
||||
# asset, exactly as before. If github-release was skipped (a publish job failed)
|
||||
# there is no draft, so this is skipped too and no release is published.
|
||||
# --------------------------------------------------------------------------
|
||||
publish-release:
|
||||
name: Publish the GitHub Release
|
||||
needs: [github-release, attestations]
|
||||
if: always() && needs.github-release.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # flip the draft release to published
|
||||
steps:
|
||||
- name: Flip the draft release to published (latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.github-release.outputs.tag }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::no tag resolved from github-release; cannot publish."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::publishing release $TAG (draft -> published, latest)"
|
||||
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
|
||||
@@ -0,0 +1,49 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
|
||||
# schedule, on branch-protection changes, and on push to main. `publish_results`
|
||||
# uploads the score to the public OpenSSF API so the README badge resolves, and
|
||||
# the SARIF is surfaced under the repo's Security → Code scanning tab.
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Read-only by default; the analysis job widens to exactly what it needs.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload the SARIF result to code-scanning
|
||||
id-token: write # OIDC token to publish results to the OpenSSF API
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard analysis
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# Publish to the public OpenSSF endpoint that backs the README badge.
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload SARIF to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -7,9 +7,34 @@ name: Sync indicator count
|
||||
#
|
||||
# 1. README.md prose — synced on PR branches (this workflow)
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
|
||||
# — synced on push to main / v* tag
|
||||
# 4. site/index.md (local-only marketing site, not synced from CI)
|
||||
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||
# .vitepress/config.ts) — push to main / v* tag*
|
||||
# 5. org profile README count (wickra-lib/.github, profile/README.md)
|
||||
# — synced on push to main / v* tag*
|
||||
# 6. org description ("… N indicators, install-free.")
|
||||
# — synced on push to main / v* tag*
|
||||
# 7. docs site published version (wickra-lib/wickra-docs: the
|
||||
# "Published versions" table in overview.md + the Rust quickstart prose)
|
||||
# — synced on v* tag only*
|
||||
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
|
||||
# nav version label, and the wickra-wasm dep) — synced on v* tag only*
|
||||
# 9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
|
||||
# collapsed to a single page that points at docs.wickra.org but still names
|
||||
# the count) — synced on push to main / v* tag*
|
||||
#
|
||||
# *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
|
||||
# wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
|
||||
# need write on wickra-lib/.github and admin:org for the org-description PATCH;
|
||||
# surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
|
||||
# repo's permission).
|
||||
# Until that scope is granted these steps emit a ::warning:: and soft-skip —
|
||||
# they never fail the run. The repo "About" homepage URL is also enforced in
|
||||
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
|
||||
#
|
||||
# Note: surface 7 carries the release *version*, not the indicator count, so
|
||||
# it is driven by the v* tag (which is the version) rather than the count.
|
||||
#
|
||||
# We count public types (not `mod xxx;` lines) because some modules export
|
||||
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
|
||||
@@ -35,18 +60,24 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# `contents: write` is needed so the workflow can push the counter
|
||||
# fix-up commit to the PR head branch via the auto-provided
|
||||
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
|
||||
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
|
||||
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
|
||||
# write` the workflow needs — to push the counter fix-up commit to the PR head
|
||||
# branch — is raised at the job level below, not here, so the top-level default
|
||||
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
|
||||
# docs / webpage / org writes still go through the fine-grained PAT
|
||||
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
|
||||
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
# On PRs from forks the head ref lives in another repo; pushing
|
||||
# back to it from this workflow is blocked by GitHub. We still
|
||||
@@ -54,11 +85,20 @@ jobs:
|
||||
# falls back to a hard failure when push isn't possible.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
|
||||
# controlled on fork PRs) are passed through the environment, never
|
||||
# interpolated straight into the shell, so a crafted branch name cannot
|
||||
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
@@ -72,7 +112,7 @@ jobs:
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
@@ -125,6 +165,10 @@ jobs:
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
|
||||
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
|
||||
# profile image (regenerated with the new count by .github/banner.yml)
|
||||
# instead of serving a stale cached copy.
|
||||
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
|
||||
if git diff --quiet; then
|
||||
echo "No README changes after sed (counter regex did not match anything); skipping push."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -134,12 +178,18 @@ jobs:
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
# head_ref still carries the (untrusted) PR branch name forwarded by the
|
||||
# ctx step; pass it through the environment so the push refspec cannot be
|
||||
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
COUNT: ${{ steps.count.outputs.count }}
|
||||
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
|
||||
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
|
||||
git commit -m "chore: sync indicator count to ${COUNT}"
|
||||
git push origin "HEAD:${HEAD_REF}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
@@ -150,36 +200,314 @@ jobs:
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
|
||||
- name: Update GitHub About description
|
||||
- name: Update GitHub About (description + homepage)
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
# Canonical homepage — the docs site (P8.3). This is enforced on every
|
||||
# run, so it must only point at docs.wickra.org once that domain is
|
||||
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||
# gated on the domain resolving, otherwise the About link would 404.
|
||||
homepage="https://docs.wickra.org"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||
# Same Administration-write permission as --description (no extra scope).
|
||||
gh repo edit --homepage "$homepage"
|
||||
current=$(gh repo view --json description -q .description)
|
||||
if [ "$current" = "$desc" ]; then
|
||||
echo "About unchanged."
|
||||
echo "About description unchanged; homepage enforced."
|
||||
else
|
||||
gh repo edit --description "$desc"
|
||||
echo "About updated."
|
||||
echo "About description + homepage updated."
|
||||
fi
|
||||
|
||||
- name: Sync Wiki
|
||||
# Counter sync target moved from the retired GitHub wiki to the docs site
|
||||
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
|
||||
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
|
||||
# org steps so a token/scope gap never fails the run. Uses its own clone
|
||||
# dir (docs-count) so it cannot collide with the tag-only version step
|
||||
# below, which clones the same repo into `docs`.
|
||||
- name: Sync docs indicator count (wickra-docs)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
|
||||
cd wiki
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd docs-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki unchanged."
|
||||
echo "Docs indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md FAQ.md Streaming-vs-Batch.md
|
||||
git add index.md overview.md Indicators-Overview.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
git push
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Docs indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
# The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
|
||||
# Home.md pointer page that sends visitors to docs.wickra.org, but that
|
||||
# page still names the count ("… for all N indicators"), so keep it in
|
||||
# sync here too. Mirrors the docs/webpage count steps: own clone dir
|
||||
# (wiki-count) and the same soft-skip contract. Wiki write rides on the
|
||||
# parent repo's permission, so the PAT needs write on wickra-lib/wickra;
|
||||
# the wiki has no signing gate, so a plain wickra-bot commit is fine.
|
||||
- name: Sync wiki pointer indicator count (wickra.wiki)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra.wiki.git" wiki-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd wiki-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki pointer indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
|
||||
else
|
||||
echo "Wiki pointer indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
# ----- org-profile sync (soft-skip until PAT scope lands) -------
|
||||
#
|
||||
# These two steps keep the org page (github.com/wickra-lib) in sync
|
||||
# with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
|
||||
# syncs do not: write on wickra-lib/.github, and admin:org for the org
|
||||
# description PATCH. Both are written to soft-skip with a ::warning::
|
||||
# (never fail the run) so this workflow stays green before the scope is
|
||||
# granted — once it is, they start syncing with no further code change.
|
||||
|
||||
- name: Sync org profile README count
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
|
||||
exit 0
|
||||
fi
|
||||
cd orgprofile
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
|
||||
if git diff --quiet; then
|
||||
echo "Org profile README count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add profile/README.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Org profile README synced to ${n}."
|
||||
fi
|
||||
|
||||
- name: Sync org description
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
org="wickra-lib"
|
||||
# Reading the org description is public; the PATCH needs admin:org.
|
||||
current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
|
||||
if [ -z "$current" ]; then
|
||||
echo "::warning::could not read org description (network/PAT?). Skipping."
|
||||
exit 0
|
||||
fi
|
||||
updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
|
||||
if [ "$current" = "$updated" ]; then
|
||||
echo "Org description count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
|
||||
echo "Org description synced to ${n}."
|
||||
else
|
||||
echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
|
||||
fi
|
||||
|
||||
# ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
|
||||
#
|
||||
# Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
|
||||
# version in the "Published versions" table (overview.md) and the Rust
|
||||
# quickstart prose. Unlike the indicator count these change only on a
|
||||
# release, so this step runs on v* tag pushes only and takes the version
|
||||
# straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
|
||||
# wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
|
||||
# soft-skips with a ::warning:: and never fails the run; once granted, every
|
||||
# release self-heals the docs version with no code change (replaces the old
|
||||
# manual P0.5 post-release wiki bump).
|
||||
- name: Sync docs version (wickra-docs)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
|
||||
exit 0
|
||||
fi
|
||||
# Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
|
||||
# the wickra repo at the workspace root, which already contains a
|
||||
# top-level `docs/` directory, so `git clone … docs` fails with
|
||||
# "destination path 'docs' already exists" — silently, because of the
|
||||
# 2>/dev/null below — and the version sync never runs (this is exactly
|
||||
# why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
|
||||
# `docs-count` dir used by the count step above and collides with
|
||||
# nothing in the repo.
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
|
||||
exit 0
|
||||
fi
|
||||
cd docs-ver
|
||||
# Published-versions table rows (crates.io / PyPI / npm): replace only the
|
||||
# version number, leaving the trailing padding + pipe intact. The '.' in
|
||||
# the quickstart pattern matches the literal backtick around the version
|
||||
# without needing a backtick in this shell string. Historical "since
|
||||
# X.Y.Z" references contain no such anchor and are never matched.
|
||||
sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
|
||||
sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
|
||||
if git diff --quiet; then
|
||||
echo "Docs version already at ${version}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add overview.md Quickstart-Rust.md
|
||||
git commit -m "chore: sync published version to ${version}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Docs version synced to ${version}."
|
||||
fi
|
||||
|
||||
# ----- webpage (marketing site) self-update (findings P12.1) ------------
|
||||
#
|
||||
# The marketing site (wickra-lib/webpage) carries the same indicator count
|
||||
# and published version as the docs. Mirrors the docs steps above: the
|
||||
# count syncs on push-to-main + tag, the version syncs on v* tags only.
|
||||
# Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
|
||||
# a tag run. Soft-skips with a ::warning:: if the token can't reach the
|
||||
# repo, so the run never fails.
|
||||
- name: Sync webpage indicator count (wickra-lib/webpage)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
|
||||
if git diff --quiet; then
|
||||
echo "Webpage indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add index.md .vitepress/config.ts
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Webpage indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
- name: Sync webpage version (wickra-lib/webpage)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
|
||||
exit 0
|
||||
fi
|
||||
# The webpage pins wickra-wasm to the released version in package.json,
|
||||
# and its Cloudflare Pages build runs `npm clean-install`. release.yml
|
||||
# publishes wickra-wasm to npm in parallel on this same tag and finishes
|
||||
# minutes later, so committing the bump immediately would point the site
|
||||
# at a version npm cannot resolve yet (ETARGET) and break the build —
|
||||
# exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
|
||||
# actually live on npm before committing; if it never appears (the wasm
|
||||
# publish failed), skip rather than push a build-breaking commit.
|
||||
echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
|
||||
attempts=0
|
||||
until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 30 ]; then
|
||||
echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
|
||||
exit 0
|
||||
fi
|
||||
echo " not on npm yet (attempt ${attempts}/30); waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-ver
|
||||
# api/*.md "Latest" lines, the nav version label, and the wickra-wasm
|
||||
# dep pin. The '.' anchors match the backtick / quote / caret without a
|
||||
# literal in this shell string; historical "Since X.Y.Z" prose has no
|
||||
# such anchor and is never matched.
|
||||
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
|
||||
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
|
||||
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
|
||||
# Keep package-lock.json in sync with the package.json bump. The site's
|
||||
# Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
|
||||
# with EUSAGE if the lockfile still pins the previous wickra-wasm —
|
||||
# editing package.json alone is not enough. The npm-wait above already
|
||||
# proved wickra-wasm@$version is resolvable, so --package-lock-only
|
||||
# regenerates the lock (version + resolved + integrity) without fetching
|
||||
# node_modules. Guard it: if the regen fails, skip the whole commit so we
|
||||
# never push a package.json/lock mismatch that would break the build.
|
||||
if ! npm install --package-lock-only --no-audit --no-fund; then
|
||||
echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
|
||||
exit 0
|
||||
fi
|
||||
if git diff --quiet; then
|
||||
echo "Webpage version already at ${version}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add api/*.md .vitepress/config.ts package.json package-lock.json
|
||||
git commit -m "chore: sync published version to ${version}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Webpage version synced to ${version}."
|
||||
fi
|
||||
|
||||
@@ -14,8 +14,8 @@ jobs:
|
||||
name: metadata audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Audit repo-metadata.toml drift
|
||||
|
||||
+7
-3
@@ -44,10 +44,14 @@ tarpaulin-report.html
|
||||
# Node binding artifacts
|
||||
**/node_modules/
|
||||
bindings/node/*.node
|
||||
bindings/node/index.d.ts
|
||||
bindings/node/npm-debug.log*
|
||||
# package-lock.json is committed (under bindings/node/) so contributors
|
||||
# get reproducible npm installs. Top-level lockfiles still aren't expected.
|
||||
# index.js + index.d.ts are generated by `napi build` but committed (a matched
|
||||
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
|
||||
# regenerating both when a binding's public API changes.
|
||||
# package-lock.json is committed for the tracked Node packages — bindings/node/
|
||||
# and examples/node/ — so contributors get reproducible npm installs. There is
|
||||
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
|
||||
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
|
||||
|
||||
# WASM build output
|
||||
bindings/wasm/pkg/
|
||||
|
||||
+146
-1
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.2] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- **Microstructure family — order book (part 1).** A new family of indicators
|
||||
that consume an order-book depth snapshot (`OrderBook` of sorted, uncrossed
|
||||
bid/ask `Level`s) rather than OHLCV, exposed in Rust, Python, Node and WASM:
|
||||
- **Order-Book Imbalance** — `OrderBookImbalanceTop1`, `OrderBookImbalanceTopN`
|
||||
(configurable depth) and `OrderBookImbalanceFull` measure signed depth
|
||||
pressure `(bidDepth − askDepth) / (bidDepth + askDepth)` over the top level,
|
||||
the top-N levels, or the full book.
|
||||
- **Microprice** — the size-weighted fair value
|
||||
`(bidPx·askSz + askPx·bidSz) / (bidSz + askSz)`, tilting the mid toward the
|
||||
side more likely to be hit.
|
||||
- **Quoted Spread** — the top-of-book spread in basis points of the mid.
|
||||
- **Microstructure family — trade flow (part 2).** Indicators over a trade tape
|
||||
(`Trade` with an aggressor `Side`), exposed in Rust, Python, Node and WASM:
|
||||
- **Signed Volume** — per-trade size signed by aggressor side (`+size` buy,
|
||||
`−size` sell).
|
||||
- **Cumulative Volume Delta** — the running total of signed volume; reset to
|
||||
re-anchor per session.
|
||||
- **Trade Imbalance** — the rolling `(buyVol − sellVol)/(buyVol + sellVol)`
|
||||
over a configurable window of trades.
|
||||
|
||||
New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote`
|
||||
back this and the upcoming trade-flow and price-impact indicators. Python and
|
||||
Node accept a batch over a list of snapshots; WASM exposes per-snapshot
|
||||
`update`.
|
||||
- **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode
|
||||
(`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that
|
||||
classifies a detected Doji by the position of its body within the bar range —
|
||||
a dragonfly (long lower shadow) emits `+1.0` (bullish), a gravestone (long
|
||||
upper shadow) emits `−1.0` (bearish), and a long-legged / standard Doji emits
|
||||
`0.0` (neutral). The default construction is unchanged — a direction-less
|
||||
`+1.0` / `0.0` detection flag — so existing callers are unaffected. This
|
||||
completes the uniform `+1` bull / `−1` bear / `0` none sign convention across
|
||||
every candlestick pattern, making the family a drop-in machine-learning
|
||||
feature where bullish and bearish instances share a single dimension.
|
||||
|
||||
### Fixed
|
||||
- **README banner now self-updates.** The top README banner points at the org
|
||||
profile image that `.github/banner.yml` regenerates from the indicator count,
|
||||
and `sync-about.yml` bumps a `?v=<count>` cache-buster so GitHub's Camo proxy
|
||||
refetches it immediately. Also fixes the webpage indicator-count sync, which
|
||||
silently crashed on a removed `public/hero.svg` and left the marketing site's
|
||||
count (and its OG banner) stale.
|
||||
|
||||
### Security
|
||||
- **CI dependency installs are pinned by hash.** The Node binding now installs
|
||||
with `npm ci` (strict `package-lock.json`), and the Python CI/bench tooling is
|
||||
installed from hash-locked `--require-hashes` requirements under
|
||||
`.github/requirements/` (OpenSSF Scorecard PinnedDependencies). The `ci-dev`
|
||||
tooling is locked twice — for Python 3.9 and for 3.10+ — because numpy ships no
|
||||
single release with wheels for both cp39 and cp313. A new
|
||||
`scripts/update-lockfiles.sh` regenerates every workspace lockfile (Rust, Node
|
||||
and the hash-pinned Python requirements) via `uv`, and Dependabot keeps the
|
||||
pinned requirements current.
|
||||
|
||||
## [0.4.1] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- **Cross-asset pairwise indicators.** A new two-series family of
|
||||
`Indicator<Input = (f64, f64)>` implementations that relate two distinct
|
||||
assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
|
||||
Node, and WASM:
|
||||
- **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
|
||||
**log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
|
||||
it is fed, `PairwiseBeta` differences consecutive prices into log-returns
|
||||
internally — the conventional way to measure cross-asset beta, where a beta
|
||||
on price levels would be dominated by the shared trend.
|
||||
- **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
|
||||
`ln(a) − β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
|
||||
spread is z-scored over its own look-back. The canonical mean-reversion /
|
||||
statistical-arbitrage entry signal, with independent `beta_period` and
|
||||
`z_period` windows.
|
||||
- **Lead–Lag Cross-Correlation** (`LeadLagCrossCorrelation`) — the integer
|
||||
offset `k ∈ [−max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`,
|
||||
answering which of two assets leads the other and by how many bars. Emits
|
||||
`{ lag, correlation }`; a positive lag means `a` leads `b`.
|
||||
- **Cointegration** (`Cointegration`) — the Engle–Granger two-step screen for
|
||||
pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
|
||||
`a − (α + β·b)`, and an augmented Dickey–Fuller `t`-statistic on the spread
|
||||
(configurable `adf_lags`). A strongly negative statistic flags a
|
||||
mean-reverting, tradeable spread. Emits `{ hedge_ratio, spread, adf_stat }`.
|
||||
- **Relative Strength A-vs-B** (`RelativeStrengthAB`) — the comparative
|
||||
relative strength of two assets: the ratio line `a / b` together with its
|
||||
moving average and its RSI, the classic asset-vs-asset / asset-vs-index
|
||||
rotation screen. Emits `{ ratio, ratio_ma, ratio_rsi }`.
|
||||
|
||||
## [0.4.0] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- **Build-provenance attestations for release artifacts.** The release workflow
|
||||
now emits signed SLSA build-provenance attestations for the published crates
|
||||
and Python wheels/sdist (`actions/attest-build-provenance`); npm packages
|
||||
carry inline Sigstore provenance from `npm publish --provenance`. Every
|
||||
published artifact is cryptographically traceable to this repository's release
|
||||
workflow run.
|
||||
|
||||
### Security
|
||||
- **CodeQL static analysis and OpenSSF Scorecard run in CI.** CodeQL (Rust,
|
||||
Python, JavaScript) and the OpenSSF Scorecard workflow now run on every push;
|
||||
results appear under Security → Code scanning and a public Scorecard badge is
|
||||
shown in the README.
|
||||
- **CI workflows hardened against script injection.** Untrusted event contexts
|
||||
(PR branch names, `workflow_dispatch` inputs) are passed through the step
|
||||
environment instead of being interpolated directly into shell commands.
|
||||
|
||||
### Changed
|
||||
- **Node binding: invalid indicator periods now throw instead of being silently
|
||||
clamped.** The scalar-indicator constructors previously clamped `period = 0`
|
||||
to `1`; every Node constructor now propagates the core's validation error
|
||||
(e.g. `period must be greater than zero`), matching the Python and WASM
|
||||
bindings and the Rust core. Constructing with a valid period is unaffected.
|
||||
- **Binding package READMEs are now per-ecosystem.** The Python, Node.js, and
|
||||
WebAssembly READMEs were byte-identical 314-line copies of the workspace
|
||||
README and had drifted out of sync (stale indicator count, Python snippets
|
||||
shown on the Node and WASM package pages). Each is now a focused landing page
|
||||
with the correct install command, a language-correct quick-start snippet, and
|
||||
links to the canonical documentation — removing the manual three-way sync
|
||||
burden. No code or API changes.
|
||||
- **CONTRIBUTING now states the correct MSRV (1.86 workspace / 1.88
|
||||
`bindings/node`)** and documents that these are the dependency-forced floors,
|
||||
kept minimal on purpose. The previous text claimed 1.75 / 1.77, which the
|
||||
`msrv` CI job has enforced against since the criterion and napi-build bumps.
|
||||
|
||||
## [0.3.1] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- **Release pipeline — CycloneDX SBOM generation.** `cargo-cyclonedx` has no
|
||||
`-p`/`--package` selector; it walks the whole workspace in a single pass.
|
||||
The `release.yml` SBOM step invoked it as `cargo cyclonedx … -p <crate>` and
|
||||
aborted with `error: unexpected argument '-p' found`, which failed the
|
||||
crates.io publish job *after* the crates were already published and skipped
|
||||
the GitHub Release attach-assets job (no release page, no SBOM artefacts).
|
||||
The step now runs a single workspace pass and collects the three crates.io
|
||||
crate SBOMs. No library changes relative to 0.3.0 — this patch republishes
|
||||
the same code with a working release pipeline.
|
||||
|
||||
## [0.3.0] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
|
||||
pragmatically as standard `Indicator`s rather than a separate
|
||||
@@ -817,7 +957,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.2.7...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.2...HEAD
|
||||
[0.4.2]: https://github.com/wickra-lib/wickra/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/wickra-lib/wickra/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
|
||||
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
||||
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ message: >-
|
||||
type: software
|
||||
authors:
|
||||
- alias: kingchenc
|
||||
email: wickra.lib@gmail.com
|
||||
email: support@wickra.org
|
||||
repository-code: "https://github.com/wickra-lib/wickra"
|
||||
url: "https://wickra.org"
|
||||
abstract: >-
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ project in public spaces.
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
|
||||
at **support@wickra.org**. All reports will be reviewed and investigated
|
||||
promptly and fairly, and the maintainer will respect the privacy and security
|
||||
of the reporter.
|
||||
|
||||
|
||||
+33
-5
@@ -35,8 +35,13 @@ cargo test --workspace
|
||||
cargo test -p wickra-data --features live-binance
|
||||
```
|
||||
|
||||
The minimum supported Rust version is **1.75** for the workspace crates and
|
||||
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
|
||||
The minimum supported Rust version is **1.86** for the workspace crates and
|
||||
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
|
||||
not chosen freely — they are the lowest versions our dependencies allow
|
||||
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
|
||||
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
|
||||
the library builds for the widest possible audience; please don't raise it
|
||||
without a dependency that actually requires it.
|
||||
|
||||
### Python
|
||||
|
||||
@@ -63,6 +68,29 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
wasm-pack test --node bindings/wasm
|
||||
```
|
||||
|
||||
## Lockfile policy
|
||||
|
||||
| Component | Lockfile | Tracked? | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
|
||||
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
|
||||
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
|
||||
| `bindings/python` | — | n/a (no lockfile) | The published package pins only `numpy>=1.22` at runtime; its native code is pinned through the workspace `Cargo.lock`. The CI/bench dev tooling it installs is hash-locked separately — see the `.github/requirements` row. |
|
||||
| `.github/requirements` | `*.txt` (hash-pinned) | **yes** | CI/bench Python tooling, locked with `uv pip compile --generate-hashes` (OpenSSF Scorecard PinnedDependencies). `ci-dev` is split per Python version — `ci-dev-py39.txt` and `ci-dev-py3.txt` — because numpy ships no single release with wheels for both cp39 and cp313; `bench.txt` covers the single-version bench job. |
|
||||
| `fuzz` | `fuzz/Cargo.lock` | **no** (ignored) | `fuzz/` is a detached crate; `cargo-fuzz init` generates `fuzz/.gitignore` which ignores its `Cargo.lock`. The fuzz smoke job resolves dependencies fresh, so the lock is not needed for reproducibility here. |
|
||||
| `site` (marketing) | `package-lock.json` | **no** (ghost-ignored) | The VitePress site is a local-only project excluded via `.git/info/exclude`; its lockfile stays local. |
|
||||
|
||||
When adding a new committed Node package, commit its `package-lock.json` too and
|
||||
remove any matching ignore rule. Do **not** add a top-level `package-lock.json` —
|
||||
the repository root is not an npm package.
|
||||
|
||||
To refresh every committed lockfile in the workspace — `Cargo.lock`,
|
||||
`fuzz/Cargo.lock`, the Node binding lock, and the hash-pinned Python
|
||||
requirements — run `./scripts/update-lockfiles.sh`. It uses `uv` for the Python
|
||||
locks (and bootstraps it on Linux/macOS if absent) so each target Python
|
||||
version's hashed transitive closure can be regenerated without that interpreter
|
||||
installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
|
||||
## Standards for a change
|
||||
|
||||
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
|
||||
@@ -76,9 +104,9 @@ wasm-pack test --node bindings/wasm
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
- **Docs.** Update the relevant page on the
|
||||
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
|
||||
`README.md` when behaviour or the public API changes. The Wiki lives in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
[documentation site](https://docs.wickra.org) and the
|
||||
`README.md` when behaviour or the public API changes. The docs live in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+4
-4
@@ -12,11 +12,11 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
authors = ["kingchenc <wickra.lib@gmail.com>"]
|
||||
version = "0.4.2"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
license-file = "LICENSE"
|
||||
repository = "https://github.com/wickra-lib/wickra"
|
||||
homepage = "https://github.com/wickra-lib/wickra"
|
||||
readme = "README.md"
|
||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.3.0" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.2" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -131,6 +131,31 @@ software under these terms.
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
|
||||
## Additional Permissions Granted by the Licensor
|
||||
|
||||
These additional permissions supplement the PolyForm Noncommercial
|
||||
License 1.0.0 above. They only broaden, and never narrow, the
|
||||
licenses granted to you. The text of the PolyForm Noncommercial
|
||||
License 1.0.0 above is unmodified.
|
||||
|
||||
Use by a natural person, acting for their own personal account and
|
||||
not on behalf of any third party, is use for a permitted purpose.
|
||||
This includes operating an automated trading bot or trading strategy
|
||||
on that person's own capital, whether or not it earns that person
|
||||
money.
|
||||
|
||||
For the avoidance of doubt, the licenses above already let you use,
|
||||
fork, modify, and redistribute the software, and file issues and
|
||||
contribute changes, for any permitted purpose. Personal projects,
|
||||
research, education, nonprofit organizations, government use, and
|
||||
hobby trading bots are permitted purposes.
|
||||
|
||||
Any other commercial use — in particular the commercial sale of the
|
||||
software itself, or the commercial sale of services built around it —
|
||||
requires a separate commercial license from the licensor. If you want
|
||||
to use Wickra commercially, get in touch about a license at
|
||||
<https://github.com/wickra-lib/wickra>.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
# Wickra
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=227" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
|
||||
@@ -31,6 +38,25 @@ for price in live_feed:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
|
||||
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 227 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), the
|
||||
[data layer](https://docs.wickra.org/Data-Layer).
|
||||
- **Guides** — [Cookbook](https://docs.wickra.org/Cookbook),
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
@@ -109,9 +135,10 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
227 streaming-first indicators across seventeen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
@@ -123,15 +150,22 @@ semantics tests.
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
|
||||
`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
|
||||
as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
|
||||
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
|
||||
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
|
||||
@@ -203,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 214 indicators
|
||||
│ ├── wickra-core/ core engine + all 227 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
# Roadmap
|
||||
|
||||
What Wickra is heading toward, what is explicitly out of scope, and what
|
||||
contributors can expect across the next 0.x versions. Roadmap items are
|
||||
**aspirations, not commitments** — order may shift based on real
|
||||
user-feedback and bug-priority.
|
||||
|
||||
For "what shipped already" see [`CHANGELOG.md`](CHANGELOG.md). For the
|
||||
internal structure that the roadmap items will plug into, see
|
||||
[`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
## North star
|
||||
|
||||
> A streaming-first technical-analysis core that is the obvious default
|
||||
> for anyone writing a Rust, Python, Node or browser-based trading
|
||||
> system — drop-in fast, drop-in correct, drop-in tested.
|
||||
|
||||
Three measurable proxies for "obvious default":
|
||||
|
||||
1. **Coverage.** Every textbook indicator from TA-Lib, pandas-ta and
|
||||
talipp is in Wickra and produces matching reference values.
|
||||
2. **Performance.** Streaming `update` is the fastest published number
|
||||
across Rust / Python / Node / WASM for any technical indicator.
|
||||
3. **Trust.** Releases are reproducible, signed, SBOM-attached, with a
|
||||
public 100% test/branch coverage badge.
|
||||
|
||||
## 0.3.0 — target window: Q3 2026
|
||||
|
||||
The first release after the org migration to `wickra-lib` lands and the
|
||||
project portal at `wickra.org` is live.
|
||||
|
||||
### Headline goals
|
||||
|
||||
- **WASM has automated tests.** `wasm-bindgen-test` job in CI exercising
|
||||
a representative subset (~30 indicators across all families). Today
|
||||
WASM is only smoke-validated through manual examples.
|
||||
- **Release-pipeline trust.** SBOM (CycloneDX) generated per release,
|
||||
Sigstore cosign signatures attached to every published artifact,
|
||||
npm `--provenance` flag enabled, PyPI Trusted Publishers configured.
|
||||
- **Hosted documentation portal.** `wickra.org` (Cloudflare Pages,
|
||||
VitePress) replaces the GitHub Wiki as the canonical doc surface.
|
||||
Per-indicator deep-dives, quickstarts, FAQ, search.
|
||||
- **End-to-end strategy examples.** 3 runnable examples that wire
|
||||
Wickra indicators into a full mean-reversion / trend-following /
|
||||
breakout strategy with PnL and equity-curve output.
|
||||
- **`ARCHITECTURE.md` + `ROADMAP.md` + `CITATION.cff`** governance
|
||||
baseline (this is it).
|
||||
|
||||
### Stretch goals (might slip to 0.4.0)
|
||||
|
||||
- **Per-binding hosted API reference.** TypeDoc for Node/WASM,
|
||||
Sphinx for Python, both hosted on `wickra.org/api/*`. Rust stays on
|
||||
`docs.rs`.
|
||||
- **Property-tests (`proptest`) for mathematical invariants.** Bound
|
||||
checks on RSI ∈ [0, 100], Bollinger ordering, batch-streaming
|
||||
equivalence on random inputs.
|
||||
- **Nightly long-fuzz workflow.** Each fuzz target gets ~1h overnight,
|
||||
findings auto-converted to issues.
|
||||
|
||||
## 0.4.0 — target window: Q4 2026
|
||||
|
||||
### Indicator catalogue expansion
|
||||
|
||||
The current 214 indicators cover the textbook canon. The next wave is
|
||||
the "stuff people actually ask for but skip because it's painful in
|
||||
other libs":
|
||||
|
||||
- **Anchored indicators.** AnchoredVwap is already in, but anchored
|
||||
variants of common indicators (AnchoredATR, AnchoredVolatility) are
|
||||
on the wishlist for explicit session-based analysis.
|
||||
- **Multi-timeframe (MTF) chaining helpers.** A `Mtf<Indicator>` wrapper
|
||||
that runs an indicator on a different timeframe of the same input
|
||||
stream — solves the most common reason people drop down to ad-hoc
|
||||
buffering code.
|
||||
- **Order-flow primitives** (gated on tick-data ingestion landing in
|
||||
`wickra-data`): CumulativeDelta, BidAskImbalance, VolumeAtPrice.
|
||||
- **Pivot-confirmation patterns.** WilliamsFractals is already in;
|
||||
ZigZag is in. Next: PivotHigh/PivotLow with configurable
|
||||
left/right-bar confirmation, used as a feature for higher-level
|
||||
pattern detectors.
|
||||
- **Harmonic-chart patterns** (Gartley, Bat, Butterfly, Crab, Shark).
|
||||
These need the pivot-detector + ratio-matcher framework first; the
|
||||
candlestick-pattern family from 0.2.8 is the precedent.
|
||||
|
||||
### Live-data layer
|
||||
|
||||
- **Tick-data ingestion.** Extend `wickra-data` from OHLCV-only to
|
||||
also accept raw ticks; the existing `Aggregator` already aggregates
|
||||
ticks into bars but isn't exposed in the public live-feed API yet.
|
||||
- **Generic exchange trait.** `LiveFeed { fn subscribe(...) -> impl
|
||||
Stream<Item = Candle> }` so the Binance adapter is one implementor
|
||||
among many. **Note**: Wickra will not aggregate exchanges itself
|
||||
(use `ccxt` if you need that) — the trait exists so user code can
|
||||
swap feeds without touching indicator code.
|
||||
|
||||
### Performance & reliability
|
||||
|
||||
- **Performance-regression tracking.** `bench.yml` outputs deployed to
|
||||
a `gh-pages` branch, `github-action-benchmark` plot over time,
|
||||
threshold-based alerts on regression.
|
||||
- **Indicator parity test suite.** Every indicator that has a TA-Lib
|
||||
/ pandas-ta / talipp equivalent must pass a golden-value test
|
||||
against that reference. Currently most do but the test names are
|
||||
scattered — consolidate into one `parity_tests` module.
|
||||
|
||||
## 0.5.0 — target window: 2027 H1
|
||||
|
||||
### Possible new bindings
|
||||
|
||||
Open questions, prioritised by demand signals (≈ GitHub stars + issue
|
||||
requests + community polls):
|
||||
|
||||
- **Java / Kotlin** binding via UniFFI — interest from Android-side
|
||||
trading-app developers and the JVM-quant community.
|
||||
- **Go** binding — interest from algorithmic-trading shops running on
|
||||
Linux + Go infra.
|
||||
- **C / C++** header export (`cbindgen`) — drops Wickra into existing
|
||||
C/C++ trading stacks (e.g. older Bloomberg / FIX-protocol shops).
|
||||
- **Swift** — niche but real, iOS / macOS native trading apps.
|
||||
- **.NET** — closes the Windows-native gap that NAPI-Node doesn't fill
|
||||
(some shops are still on .NET Framework).
|
||||
|
||||
None of these are committed — each is a 1-2-month project on its own,
|
||||
and bindings without active maintenance are a liability. Priority will
|
||||
be driven by which language community shows up with PRs.
|
||||
|
||||
### `wickra-data` widening
|
||||
|
||||
- **Historical fetch from > 1 source.** Today only Binance REST/WS.
|
||||
Add Coinbase and Kraken as reference implementations — explicitly
|
||||
not exchange-aggregation, just "here are two more adapters using
|
||||
the trait".
|
||||
|
||||
## Beyond — long-term aspirations
|
||||
|
||||
- **`wickra-backtest` sub-crate** (decision pending). A minimal
|
||||
event-driven backtester wrapping signal generation + position
|
||||
sizing + fees + slippage. Decision factor: do users keep building
|
||||
these ad-hoc from `examples/`? If yes, codifying it saves the
|
||||
ecosystem time. If most users plug Wickra into existing backtesters
|
||||
(vectorbt, backtrader, Lean, Hummingbot), keep Wickra focused.
|
||||
- **`wickra-plot` companion** (low priority). `plotters`-backed Rust
|
||||
rendering of indicator outputs. Mainly useful for static report
|
||||
generation; live charting belongs in the JS/web layer.
|
||||
- **Academic adoption.** Citable `CITATION.cff`; targeting at least
|
||||
one peer-reviewed paper using Wickra as the reference indicator
|
||||
engine.
|
||||
|
||||
## What is explicitly **not** on the roadmap
|
||||
|
||||
These are recurring requests that Wickra will decline so contributors
|
||||
don't waste time on them:
|
||||
|
||||
| Feature | Why declined |
|
||||
|---|---|
|
||||
| Exchange aggregation across N venues | That's `ccxt`'s job. Wickra ships *one* feed implementor (Binance) for tests and demo; users plug their preferred exchange. |
|
||||
| Full backtesting framework (à la Lean, vectorbt) | Scope creep. May land as `wickra-backtest` *if* a clear minimal API surfaces from user demand, but Wickra core stays an indicator library. |
|
||||
| Strategy auto-tuning / hyperparameter search | Wrong abstraction layer — belongs in user-side ML/backtester. |
|
||||
| Order-management / broker integration | Even further out of scope. |
|
||||
| GUI / web-based studio | Marketing-site demos are fine; full IDE is not. |
|
||||
| Indicator implementations that require optional Python deps (e.g. scipy KDE) | Bindings must work on a clean install. Pure-Rust math only. |
|
||||
| Proprietary indicator implementations (e.g. paywalled vendor formulas) | License conflicts + audit burden. |
|
||||
| GPU / CUDA acceleration | O(1) per update means the bottleneck is API overhead, not compute. SIMD-batch is a maybe; GPU adds no value for streaming. |
|
||||
|
||||
## How indicator wishlist requests are handled
|
||||
|
||||
1. **Open an issue** using the `feature_request` template, naming the
|
||||
indicator, citing one of:
|
||||
- TA-Lib reference
|
||||
- pandas-ta reference
|
||||
- peer-reviewed paper
|
||||
- widely-published trading book
|
||||
2. Maintainer triages within ~1 week. Acceptance criteria:
|
||||
- Formula has at least one written reference (no random
|
||||
YouTuber-only indicators)
|
||||
- Implementation can be O(1) streaming
|
||||
- Test vectors are obtainable (from reference lib, hand-computed,
|
||||
or paper-provided)
|
||||
3. Once accepted, the indicator gets added to the next family-batch
|
||||
PR. Family-batches typically ship 5-20 indicators at a time.
|
||||
|
||||
## Versioning
|
||||
|
||||
Wickra follows [SemVer](https://semver.org/). The promise:
|
||||
|
||||
- **0.x.0 (minor):** new indicators, new bindings, new optional features,
|
||||
new optional config knobs. Adding methods to `Indicator` with default
|
||||
impls is minor.
|
||||
- **0.x.y (patch):** bug fixes, performance improvements, doc fixes.
|
||||
- **Major (1.0.0 and later):** changes to the `Indicator` trait
|
||||
signature, removal of indicators, breaking changes to `Candle` /
|
||||
`OHLCV` field order.
|
||||
|
||||
The 1.0 milestone is reserved for when the indicator catalogue is
|
||||
considered "stable enough" and the bindings API has stabilised — likely
|
||||
~2027 once 2-3 more bindings have shipped and stress-tested the trait.
|
||||
|
||||
## Discussion
|
||||
|
||||
For roadmap discussion, open a [Discussions](https://github.com/wickra-lib/wickra/discussions)
|
||||
thread tagged `roadmap`. Specific indicator wishlist items go in
|
||||
[Issues](https://github.com/wickra-lib/wickra/issues) via the
|
||||
`feature_request` template.
|
||||
+1
-1
@@ -18,7 +18,7 @@ Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||
("Report a vulnerability" under the repository's *Security* tab), or
|
||||
- email to **wickra.lib@gmail.com** with a subject line starting with
|
||||
- email to **support@wickra.org** with a subject line starting with
|
||||
`[wickra security]`.
|
||||
|
||||
Please include:
|
||||
|
||||
@@ -9,7 +9,7 @@ edition.workspace = true
|
||||
# also emits `cargo::` directives that require >= 1.77 — that older floor is
|
||||
# subsumed by the 1.88 requirement now.
|
||||
rust-version = "1.88"
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
+45
-286
@@ -1,314 +1,73 @@
|
||||
# Wickra
|
||||
# Wickra — Node.js
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
**Streaming-first technical indicators for Node.js. `npm install wickra` —
|
||||
prebuilt native binary, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||
it exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
||||
> cleanly on Windows without C build tooling — which is precisely the install
|
||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||
> them in your environment to see those rows light up too.
|
||||
|
||||
Run the suite yourself:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
npm install wickra
|
||||
```
|
||||
|
||||
## Indicators
|
||||
The native addon ships as a prebuilt binary per platform (Linux, macOS,
|
||||
Windows — x64 and arm64), selected automatically through optional
|
||||
dependencies. There is nothing to compile.
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
## Quick start
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
```js
|
||||
const wickra = require('wickra');
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
// Batch: run an indicator over a whole array.
|
||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
|
||||
const values = new wickra.RSI(14).batch(prices); // null during warmup
|
||||
|
||||
## Languages
|
||||
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||
|
||||
// Streaming or batch — same trait, same code.
|
||||
let mut sma = Sma::new(14)?;
|
||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
for price in live_feed {
|
||||
if let Some(v) = rsi.update(price) {
|
||||
println!("RSI = {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||
chain.update(price);
|
||||
```
|
||||
|
||||
## Live data sources
|
||||
|
||||
`wickra-data` (separate crate, opt-in) ships:
|
||||
|
||||
- A streaming OHLCV **CSV reader**.
|
||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, Rsi};
|
||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||
|
||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
while let Some(event) = stream.next_event().await? {
|
||||
if event.is_closed {
|
||||
if let Some(v) = rsi.update(event.candle.close) {
|
||||
println!("RSI = {v:.2}");
|
||||
}
|
||||
}
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
const rsi = new wickra.RSI(14);
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // no recomputation over history
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Python live-trading example using the public `websockets` package lives at
|
||||
`examples/python/live_trading.py`.
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
## Project layout
|
||||
## Documentation
|
||||
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||
|
||||
## Building everything from source
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
```bash
|
||||
# Rust core + tests
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo bench -p wickra
|
||||
## Disclaimer
|
||||
|
||||
# Python binding (requires Rust toolchain + maturin)
|
||||
cd bindings/python
|
||||
maturin develop --release
|
||||
pytest
|
||||
|
||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||
proptest. The four bindings inherit your indicator automatically once
|
||||
you expose it in the language wrappers.
|
||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||
most regressions; please don't disable them.
|
||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||
its own tests; please keep the `batch == streaming` invariant.
|
||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
are CI gates; running them locally before pushing keeps reviews short.
|
||||
|
||||
For larger architectural changes, open an issue first so we can sketch the
|
||||
shape together before you invest the time.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Completeness contract for the Wickra Node bindings: every exported indicator
|
||||
// class must expose the full streaming + batch + lifecycle interface. This
|
||||
// catches a new indicator being wired into the binding without the standard
|
||||
// methods (or an export silently disappearing) without needing a hand-written
|
||||
// test per indicator.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function) and any
|
||||
// non-indicator export.
|
||||
function indicatorClasses() {
|
||||
return Object.keys(wickra).filter((name) => {
|
||||
const value = wickra[name];
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.prototype &&
|
||||
typeof value.prototype.update === 'function'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the binding exports the full indicator catalogue', () => {
|
||||
const names = indicatorClasses();
|
||||
// The published catalogue is 214 indicators. Guard against a regression that
|
||||
// silently drops exported classes (e.g. a stale or partial native build).
|
||||
assert.ok(
|
||||
names.length >= 200,
|
||||
`expected at least 200 indicator classes, got ${names.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
|
||||
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
|
||||
const missing = [];
|
||||
for (const name of indicatorClasses()) {
|
||||
const proto = wickra[name].prototype;
|
||||
for (const method of required) {
|
||||
if (typeof proto[method] !== 'function') {
|
||||
missing.push(`${name}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`indicator classes missing required methods: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
|
||||
// Every indicator that takes no constructor arguments must still satisfy the
|
||||
// pre-warmup contract. (Indicators with required parameters are exercised by
|
||||
// the dedicated suites; here we cover the zero-arg ones generically.)
|
||||
let checked = 0;
|
||||
for (const name of indicatorClasses()) {
|
||||
let instance;
|
||||
try {
|
||||
instance = new wickra[name]();
|
||||
} catch {
|
||||
continue; // needs constructor arguments — covered elsewhere
|
||||
}
|
||||
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
|
||||
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
|
||||
checked += 1;
|
||||
}
|
||||
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
|
||||
});
|
||||
@@ -461,6 +461,8 @@ test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
|
||||
const pairFactories = {
|
||||
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
||||
Beta: () => new wickra.Beta(14),
|
||||
PairwiseBeta: () => new wickra.PairwiseBeta(14),
|
||||
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
|
||||
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
|
||||
};
|
||||
|
||||
@@ -492,6 +494,85 @@ test('Beta perfect two-to-one', () => {
|
||||
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('PairwiseBeta squared price is two', () => {
|
||||
// b needs varying returns; a = b² ⇒ a's log-returns are exactly 2× b's.
|
||||
const bench = Array.from({ length: 20 }, (_, i) => 100 + 10 * Math.sin(i * 0.5));
|
||||
const asset = bench.map((v) => v * v);
|
||||
const out = new wickra.PairwiseBeta(5).batch(asset, bench);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('PairSpreadZScore flat benchmark is sign of last move', () => {
|
||||
// Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of move.
|
||||
const a = [100, 100, 110, 105, 130];
|
||||
const b = [100, 100, 100, 100, 100];
|
||||
const out = new wickra.PairSpreadZScore(2, 2).batch(a, b);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
||||
assert.ok(Math.abs(out[out.length - 2] + 1) < 1e-9);
|
||||
});
|
||||
|
||||
const llSignal = (t) =>
|
||||
Math.sin(t * 0.4) + 0.4 * Math.sin(t * 1.1) + 0.2 * Math.cos(t * 0.27);
|
||||
|
||||
test('LeadLagCrossCorrelation detects positive lead (object output)', () => {
|
||||
const ll = new wickra.LeadLagCrossCorrelation(12, 5);
|
||||
let last = null;
|
||||
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3.
|
||||
for (let t = 0; t < 60; t++) last = ll.update(llSignal(t), llSignal(t - 3));
|
||||
assert.equal(last.lag, 3);
|
||||
assert.ok(last.correlation > 0.99);
|
||||
});
|
||||
|
||||
test('LeadLagCrossCorrelation batch is flat 2*n with last row matching', () => {
|
||||
const n = 60;
|
||||
const a = Array.from({ length: n }, (_, t) => llSignal(t));
|
||||
const b = Array.from({ length: n }, (_, t) => llSignal(t - 3));
|
||||
const out = new wickra.LeadLagCrossCorrelation(12, 5).batch(a, b);
|
||||
assert.equal(out.length, 2 * n);
|
||||
assert.equal(out[2 * (n - 1)], 3);
|
||||
assert.ok(out[2 * (n - 1) + 1] > 0.99);
|
||||
});
|
||||
|
||||
test('Cointegration detects mean-reverting pair (object output)', () => {
|
||||
const n = 80;
|
||||
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
|
||||
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
|
||||
const co = new wickra.Cointegration(40, 1);
|
||||
let last = null;
|
||||
for (let i = 0; i < n; i++) last = co.update(a[i], b[i]);
|
||||
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.1);
|
||||
assert.ok(last.adfStat < -2);
|
||||
});
|
||||
|
||||
test('Cointegration batch is flat 3*n with last row matching', () => {
|
||||
const n = 80;
|
||||
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
|
||||
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
|
||||
const out = new wickra.Cointegration(40, 1).batch(a, b);
|
||||
assert.equal(out.length, 3 * n);
|
||||
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.1);
|
||||
assert.ok(out[3 * (n - 1) + 2] < -2);
|
||||
});
|
||||
|
||||
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
|
||||
const rs = new wickra.RelativeStrengthAB(5, 5);
|
||||
let last = null;
|
||||
for (let i = 0; i < 30; i++) last = rs.update(200, 100); // ratio is a constant 2
|
||||
assert.ok(Math.abs(last.ratio - 2) < 1e-12);
|
||||
assert.ok(Math.abs(last.ratioMa - 2) < 1e-12);
|
||||
assert.ok(Math.abs(last.ratioRsi - 50) < 1e-9);
|
||||
});
|
||||
|
||||
test('RelativeStrengthAB batch is flat 3*n with last row matching', () => {
|
||||
const n = 30;
|
||||
const a = Array.from({ length: n }, () => 200);
|
||||
const b = Array.from({ length: n }, () => 100);
|
||||
const out = new wickra.RelativeStrengthAB(5, 5).batch(a, b);
|
||||
assert.equal(out.length, 3 * n);
|
||||
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 1e-12);
|
||||
assert.ok(Math.abs(out[3 * (n - 1) + 2] - 50) < 1e-9);
|
||||
});
|
||||
|
||||
test('SpearmanCorrelation monotone non-linear is 1', () => {
|
||||
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
||||
const y = x.map((v) => v ** 3);
|
||||
@@ -815,3 +896,88 @@ test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
|
||||
// simple mean of 20.
|
||||
assert.ok(out[2] > 20);
|
||||
});
|
||||
|
||||
test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => {
|
||||
// Default: direction-less detection flag (+1 doji / 0 otherwise).
|
||||
const flag = new wickra.Doji();
|
||||
assert.equal(flag.isSigned(), false);
|
||||
assert.equal(flag.update(10, 11, 9, 10), 1); // body 0, range 2 -> doji
|
||||
assert.equal(flag.update(10, 12, 10, 12), 0); // body == range -> not a doji
|
||||
|
||||
// Signed: classify a detected doji by its body position within the range.
|
||||
const d = new wickra.Doji(true);
|
||||
assert.equal(d.isSigned(), true);
|
||||
assert.equal(d.update(10, 10.05, 6, 10), 1); // dragonfly -> bullish +1
|
||||
assert.equal(d.update(10, 14, 9.95, 10), -1); // gravestone -> bearish -1
|
||||
assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0
|
||||
assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0
|
||||
});
|
||||
|
||||
test('order-book indicators reference values', () => {
|
||||
// Top-1: (3 - 1) / (3 + 1) = 0.5.
|
||||
assert.equal(new wickra.OrderBookImbalanceTop1().update([100], [3], [101], [1]), 0.5);
|
||||
// Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
|
||||
assert.ok(
|
||||
Math.abs(new wickra.OrderBookImbalanceTopN(2).update([100, 99], [2, 1], [101, 102], [1, 1]) - 0.2) < 1e-12,
|
||||
);
|
||||
// Full: bidDepth 1, askDepth 3 -> -0.5.
|
||||
assert.equal(new wickra.OrderBookImbalanceFull().update([100], [1], [101, 102], [2, 1]), -0.5);
|
||||
// Microprice: (100*3 + 101*1) / 4 = 100.25.
|
||||
assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25);
|
||||
// Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps.
|
||||
assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6);
|
||||
});
|
||||
|
||||
test('order-book streaming update matches batch', () => {
|
||||
const snaps = Array.from({ length: 30 }, (_, i) => ({
|
||||
bidPx: [100, 99],
|
||||
bidSz: [1 + (i % 5), 1],
|
||||
askPx: [101, 102],
|
||||
askSz: [1 + ((i + 1) % 3), 1],
|
||||
}));
|
||||
const batch = new wickra.Microprice().batch(snaps);
|
||||
const streamer = new wickra.Microprice();
|
||||
assert.equal(batch.length, snaps.length);
|
||||
for (let i = 0; i < snaps.length; i++) {
|
||||
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
|
||||
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('order-book TopN rejects zero levels', () => {
|
||||
assert.throws(() => new wickra.OrderBookImbalanceTopN(0));
|
||||
});
|
||||
|
||||
test('order-book update rejects a crossed book', () => {
|
||||
assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1]));
|
||||
});
|
||||
|
||||
test('trade-flow indicators reference values', () => {
|
||||
assert.equal(new wickra.SignedVolume().update(100, 2, true), 2);
|
||||
assert.equal(new wickra.SignedVolume().update(100, 3, false), -3);
|
||||
const cvd = new wickra.CumulativeVolumeDelta();
|
||||
assert.equal(cvd.update(100, 5, true), 5);
|
||||
assert.equal(cvd.update(100, 2, false), 3);
|
||||
const ti = new wickra.TradeImbalance(2);
|
||||
assert.equal(ti.update(100, 3, true), null); // warming up
|
||||
assert.equal(ti.update(100, 1, false), 0.5); // (3 - 1) / 4
|
||||
});
|
||||
|
||||
test('trade-flow streaming update matches batch', () => {
|
||||
const n = 30;
|
||||
const price = Array.from({ length: n }, () => 100);
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
|
||||
const batch = new wickra.CumulativeVolumeDelta().batch(price, size, isBuy);
|
||||
const streamer = new wickra.CumulativeVolumeDelta();
|
||||
assert.equal(batch.length, n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = streamer.update(price[i], size[i], isBuy[i]);
|
||||
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('trade-flow rejects bad input', () => {
|
||||
assert.throws(() => new wickra.TradeImbalance(0));
|
||||
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Input-validation tests for the Wickra Node bindings: malformed constructor
|
||||
// parameters and mismatched batch inputs must raise a JS Error (the napi
|
||||
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
|
||||
// Node counterpart of bindings/python/tests/test_input_validation.py.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// --- Constructors reject invalid periods / parameters ---
|
||||
|
||||
test('ATR rejects a zero period at construction', () => {
|
||||
// ATR validates its period (it drives the Wilder-smoothing length). The
|
||||
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
|
||||
// warmup-1 pass-through rather than an error, so they are not asserted here.
|
||||
assert.throws(() => new wickra.ATR(0), /.*/);
|
||||
});
|
||||
|
||||
test('MACD rejects zero and non-increasing fast/slow periods', () => {
|
||||
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
|
||||
// fast must be strictly less than slow.
|
||||
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
|
||||
});
|
||||
|
||||
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
|
||||
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
|
||||
});
|
||||
|
||||
test('PSAR rejects a step greater than its maximum', () => {
|
||||
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
|
||||
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
|
||||
});
|
||||
|
||||
test('InitialBalance and OpeningRange reject a zero period', () => {
|
||||
assert.throws(() => new wickra.InitialBalance(0), /.*/);
|
||||
assert.throws(() => new wickra.OpeningRange(0), /.*/);
|
||||
});
|
||||
|
||||
test('Ichimoku rejects zero and non-increasing periods', () => {
|
||||
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
|
||||
// Periods must satisfy tenkan < kijun < senkouB.
|
||||
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
|
||||
});
|
||||
|
||||
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
|
||||
// InverseFisherTransform needs a non-zero scaling factor.
|
||||
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
|
||||
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
|
||||
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
|
||||
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
|
||||
// MAMA needs fast limit > slow limit.
|
||||
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
|
||||
// EmpiricalModeDecomposition needs a positive fraction.
|
||||
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
|
||||
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
|
||||
// binding treats their period 0 as a warmup-1 pass-through (same as the
|
||||
// simple moving averages) rather than an error.
|
||||
});
|
||||
|
||||
// --- Batch methods reject mismatched input lengths ---
|
||||
|
||||
test('candle batch methods reject unequal-length columns', () => {
|
||||
const high = [10, 11, 12];
|
||||
const low = [9, 10]; // one short
|
||||
const close = [9.5, 10.5, 11.5];
|
||||
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea batch rejects unequal-length columns', () => {
|
||||
const high = [1, 2, 3];
|
||||
const low = [0.5, 1.5]; // short
|
||||
const volume = [10, 10, 10];
|
||||
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
|
||||
});
|
||||
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('zero period is clamped to a valid window', () => {
|
||||
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
|
||||
// clamp pathological values like period=0 to the smallest valid window.
|
||||
const sma = new wickra.SMA(0);
|
||||
assert.equal(sma.warmupPeriod(), 1);
|
||||
assert.equal(sma.update(42), 42);
|
||||
test('zero period is rejected at construction', () => {
|
||||
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
|
||||
// it as a thrown JS error, consistent with the Python and WASM bindings.
|
||||
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
|
||||
// A valid period still constructs and runs.
|
||||
assert.equal(new wickra.SMA(1).update(42), 42);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Throughput benchmark for the Wickra Node bindings.
|
||||
//
|
||||
// Measures how many indicator updates per second the native binding sustains,
|
||||
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
|
||||
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
|
||||
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
|
||||
// O(1) streaming engine (there is no install-free TA library on npm with a
|
||||
// comparable surface to compare against), so the headline number is raw
|
||||
// throughput, not a cross-library ratio.
|
||||
//
|
||||
// Run after building the binding:
|
||||
//
|
||||
// cd bindings/node && npm install && npx napi build --platform --release
|
||||
// node benchmarks/throughput.js # 200k bars (default)
|
||||
// node benchmarks/throughput.js --bars 1000000
|
||||
|
||||
const wickra = require('..');
|
||||
|
||||
function parseBars() {
|
||||
const idx = process.argv.indexOf('--bars');
|
||||
if (idx !== -1 && process.argv[idx + 1]) {
|
||||
const n = Number(process.argv[idx + 1]);
|
||||
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
|
||||
console.error('--bars must be a number >= 1000');
|
||||
process.exit(1);
|
||||
}
|
||||
return 200_000;
|
||||
}
|
||||
|
||||
const BARS = parseBars();
|
||||
|
||||
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
|
||||
const close = new Array(BARS);
|
||||
const high = new Array(BARS);
|
||||
const low = new Array(BARS);
|
||||
const volume = new Array(BARS);
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
|
||||
close[i] = mid + Math.sin(i * 0.05) * 2;
|
||||
high[i] = Math.max(close[i], mid) + 1.5;
|
||||
low[i] = Math.min(close[i], mid) - 1.5;
|
||||
volume[i] = 1000 + (i % 97) * 13;
|
||||
}
|
||||
|
||||
// Median elapsed-ns over a few repetitions, after one warmup pass.
|
||||
function timeNs(fn, reps = 3) {
|
||||
fn(); // warmup (JIT + cache)
|
||||
const samples = [];
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
fn();
|
||||
samples.push(Number(process.hrtime.bigint() - t0));
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length / 2)];
|
||||
}
|
||||
|
||||
function mupsFromNs(ns) {
|
||||
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
|
||||
}
|
||||
|
||||
// Each indicator: a streaming step and a batch call over the full series.
|
||||
const indicators = [
|
||||
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
];
|
||||
|
||||
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
|
||||
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
for (const ind of indicators) {
|
||||
const streamNs = timeNs(() => {
|
||||
const inst = ind.make();
|
||||
for (let i = 0; i < BARS; i++) ind.step(inst, i);
|
||||
});
|
||||
const batchNs = timeNs(() => {
|
||||
ind.batch(ind.make());
|
||||
});
|
||||
console.log(
|
||||
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
|
||||
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
|
||||
'better. Numbers are machine-dependent — use them for relative comparison.',
|
||||
);
|
||||
Vendored
+2423
File diff suppressed because it is too large
Load Diff
+62
-50
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
|
||||
module.exports.UlcerIndex = UlcerIndex
|
||||
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
||||
module.exports.ZScore = ZScore
|
||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
module.exports.SuperSmoother = SuperSmoother
|
||||
module.exports.FisherTransform = FisherTransform
|
||||
module.exports.Decycler = Decycler
|
||||
module.exports.CenterOfGravity = CenterOfGravity
|
||||
module.exports.CyberneticCycle = CyberneticCycle
|
||||
module.exports.InstantaneousTrendline = InstantaneousTrendline
|
||||
module.exports.EhlersStochastic = EhlersStochastic
|
||||
module.exports.RVIVolatility = RVIVolatility
|
||||
module.exports.Variance = Variance
|
||||
module.exports.CoefficientOfVariation = CoefficientOfVariation
|
||||
module.exports.Skewness = Skewness
|
||||
module.exports.Kurtosis = Kurtosis
|
||||
module.exports.StandardError = StandardError
|
||||
module.exports.DetrendedStdDev = DetrendedStdDev
|
||||
module.exports.RSquared = RSquared
|
||||
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
|
||||
module.exports.Autocorrelation = Autocorrelation
|
||||
module.exports.HurstExponent = HurstExponent
|
||||
module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.PairwiseBeta = PairwiseBeta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.PairSpreadZScore = PairSpreadZScore
|
||||
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
|
||||
module.exports.Cointegration = Cointegration
|
||||
module.exports.RelativeStrengthAB = RelativeStrengthAB
|
||||
module.exports.MACD = MACD
|
||||
module.exports.BollingerBands = BollingerBands
|
||||
module.exports.ATR = ATR
|
||||
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
|
||||
module.exports.RollingVWAP = RollingVWAP
|
||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||
module.exports.Aroon = Aroon
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.RVI = RVI
|
||||
module.exports.PGO = PGO
|
||||
module.exports.KST = KST
|
||||
module.exports.SMI = SMI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.Inertia = Inertia
|
||||
module.exports.ALMA = ALMA
|
||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
module.exports.VIDYA = VIDYA
|
||||
module.exports.JMA = JMA
|
||||
module.exports.Alligator = Alligator
|
||||
module.exports.EVWMA = EVWMA
|
||||
module.exports.APO = APO
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.SMI = SMI
|
||||
module.exports.KST = KST
|
||||
module.exports.PGO = PGO
|
||||
module.exports.RVI = RVI
|
||||
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
||||
module.exports.CFO = CFO
|
||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
||||
module.exports.ElderImpulse = ElderImpulse
|
||||
module.exports.STC = STC
|
||||
module.exports.ElderImpulse = ElderImpulse
|
||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
||||
module.exports.CFO = CFO
|
||||
module.exports.APO = APO
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.EVWMA = EVWMA
|
||||
module.exports.Alligator = Alligator
|
||||
module.exports.JMA = JMA
|
||||
module.exports.VIDYA = VIDYA
|
||||
module.exports.ALMA = ALMA
|
||||
module.exports.T3 = T3
|
||||
module.exports.TSI = TSI
|
||||
module.exports.PMO = PMO
|
||||
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
|
||||
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
|
||||
module.exports.ChaikinOscillator = ChaikinOscillator
|
||||
module.exports.ForceIndex = ForceIndex
|
||||
module.exports.EaseOfMovement = EaseOfMovement
|
||||
module.exports.KVO = KVO
|
||||
module.exports.VolumeOscillator = VolumeOscillator
|
||||
module.exports.NVI = NVI
|
||||
module.exports.PVI = PVI
|
||||
module.exports.VolumeOscillator = VolumeOscillator
|
||||
module.exports.KVO = KVO
|
||||
module.exports.WilliamsAD = WilliamsAD
|
||||
module.exports.AnchoredVWAP = AnchoredVWAP
|
||||
module.exports.DemandIndex = DemandIndex
|
||||
module.exports.TSV = TSV
|
||||
module.exports.VZO = VZO
|
||||
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
||||
module.exports.EaseOfMovement = EaseOfMovement
|
||||
module.exports.SuperTrend = SuperTrend
|
||||
module.exports.ChandelierExit = ChandelierExit
|
||||
module.exports.ChandeKrollStop = ChandeKrollStop
|
||||
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
|
||||
module.exports.ChoppinessIndex = ChoppinessIndex
|
||||
module.exports.TrueRange = TrueRange
|
||||
module.exports.ChaikinVolatility = ChaikinVolatility
|
||||
module.exports.YangZhangVolatility = YangZhangVolatility
|
||||
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
|
||||
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
|
||||
module.exports.ParkinsonVolatility = ParkinsonVolatility
|
||||
module.exports.LinRegAngle = LinRegAngle
|
||||
module.exports.BollingerBandwidth = BollingerBandwidth
|
||||
module.exports.PercentB = PercentB
|
||||
module.exports.NATR = NATR
|
||||
module.exports.HistoricalVolatility = HistoricalVolatility
|
||||
module.exports.AroonOscillator = AroonOscillator
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.RWI = RWI
|
||||
module.exports.WaveTrend = WaveTrend
|
||||
module.exports.RWI = RWI
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.MassIndex = MassIndex
|
||||
module.exports.StochRSI = StochRSI
|
||||
module.exports.UltimateOscillator = UltimateOscillator
|
||||
module.exports.PPO = PPO
|
||||
module.exports.Coppock = Coppock
|
||||
module.exports.VWMA = VWMA
|
||||
module.exports.RVIVolatility = RVIVolatility
|
||||
module.exports.ParkinsonVolatility = ParkinsonVolatility
|
||||
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
|
||||
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
|
||||
module.exports.YangZhangVolatility = YangZhangVolatility
|
||||
module.exports.MaEnvelope = MaEnvelope
|
||||
module.exports.AccelerationBands = AccelerationBands
|
||||
module.exports.StarcBands = StarcBands
|
||||
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
|
||||
module.exports.TDDifferential = TDDifferential
|
||||
module.exports.TDOpen = TDOpen
|
||||
module.exports.TDRiskLevel = TDRiskLevel
|
||||
module.exports.SuperSmoother = SuperSmoother
|
||||
module.exports.FisherTransform = FisherTransform
|
||||
module.exports.InverseFisherTransform = InverseFisherTransform
|
||||
module.exports.Decycler = Decycler
|
||||
module.exports.DecyclerOscillator = DecyclerOscillator
|
||||
module.exports.RoofingFilter = RoofingFilter
|
||||
module.exports.CenterOfGravity = CenterOfGravity
|
||||
module.exports.CyberneticCycle = CyberneticCycle
|
||||
module.exports.InstantaneousTrendline = InstantaneousTrendline
|
||||
module.exports.EhlersStochastic = EhlersStochastic
|
||||
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
|
||||
module.exports.HilbertDominantCycle = HilbertDominantCycle
|
||||
module.exports.AdaptiveCycle = AdaptiveCycle
|
||||
@@ -479,19 +497,6 @@ module.exports.MAMA = MAMA
|
||||
module.exports.FAMA = FAMA
|
||||
module.exports.Ichimoku = Ichimoku
|
||||
module.exports.HeikinAshi = HeikinAshi
|
||||
module.exports.Variance = Variance
|
||||
module.exports.CoefficientOfVariation = CoefficientOfVariation
|
||||
module.exports.Skewness = Skewness
|
||||
module.exports.Kurtosis = Kurtosis
|
||||
module.exports.StandardError = StandardError
|
||||
module.exports.DetrendedStdDev = DetrendedStdDev
|
||||
module.exports.RSquared = RSquared
|
||||
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
|
||||
module.exports.Autocorrelation = Autocorrelation
|
||||
module.exports.HurstExponent = HurstExponent
|
||||
module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.ValueArea = ValueArea
|
||||
module.exports.InitialBalance = InitialBalance
|
||||
module.exports.OpeningRange = OpeningRange
|
||||
@@ -510,7 +515,14 @@ module.exports.Tweezer = Tweezer
|
||||
module.exports.SpinningTop = SpinningTop
|
||||
module.exports.ThreeInside = ThreeInside
|
||||
module.exports.ThreeOutside = ThreeOutside
|
||||
// Family 15: Risk / Performance metrics
|
||||
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
|
||||
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
|
||||
module.exports.Microprice = Microprice
|
||||
module.exports.QuotedSpread = QuotedSpread
|
||||
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
|
||||
module.exports.SignedVolume = SignedVolume
|
||||
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
|
||||
module.exports.TradeImbalance = TradeImbalance
|
||||
module.exports.SharpeRatio = SharpeRatio
|
||||
module.exports.SortinoRatio = SortinoRatio
|
||||
module.exports.CalmarRatio = CalmarRatio
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
"wickra.darwin-arm64.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
"wickra.darwin-x64.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-arm64-msvc.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0"
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-linux-x64-gnu": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.2.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.2.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.2.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.2.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.2.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.0.tgz",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.2.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
+11
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <wickra.lib@gmail.com>",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"keywords": [
|
||||
"trading",
|
||||
"indicators",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0"
|
||||
"wickra-linux-x64-gnu": "0.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
@@ -60,7 +60,8 @@
|
||||
"artifacts": "napi artifacts",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
"test": "node --test __tests__/",
|
||||
"bench": "node benchmarks/throughput.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
|
||||
+676
-80
@@ -22,26 +22,6 @@ fn map_err(e: wc::Error) -> NapiError {
|
||||
NapiError::new(Status::InvalidArg, e.to_string())
|
||||
}
|
||||
|
||||
/// Helper for the scalar-indicator macro only. Scalar `new` functions can fail
|
||||
/// solely on `period == 0`, which `clamp_period` already rules out, so the
|
||||
/// `Result` is provably `Ok` here. Candle indicators and the multi-parameter
|
||||
/// indicators have genuinely fallible parameters and instead use fallible
|
||||
/// `#[napi(constructor)]`s that return `napi::Result<Self>` and throw a JS error.
|
||||
fn must<T>(r: Result<T, wc::Error>) -> T {
|
||||
r.expect("wickra: scalar indicator parameter clamped to a valid range")
|
||||
}
|
||||
|
||||
/// Clamp a period parameter so the underlying indicator never sees zero. JS
|
||||
/// callers who pass `0` get a window of `1` instead of a thrown exception —
|
||||
/// effectively a pass-through indicator that still produces valid outputs.
|
||||
const fn clamp_period(p: u32) -> usize {
|
||||
if p == 0 {
|
||||
1
|
||||
} else {
|
||||
p as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
|
||||
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
|
||||
}
|
||||
@@ -64,10 +44,10 @@ macro_rules! node_scalar_indicator {
|
||||
#[napi]
|
||||
impl $wrapper {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> Self {
|
||||
Self {
|
||||
inner: must(<$rust_ty>::new(clamp_period(period))),
|
||||
}
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
@@ -137,11 +117,10 @@ node_scalar_indicator!(
|
||||
);
|
||||
|
||||
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
|
||||
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
|
||||
// parameter and additionally rejects `period == 1` (a 1-bar standard
|
||||
// deviation is always zero), so the `clamp_period`-to-1 strategy from
|
||||
// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible
|
||||
// constructor instead, throws a JS error on bad period.
|
||||
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` and
|
||||
// rejects both `period == 0` and `period == 1` (a 1-bar standard deviation is
|
||||
// always zero). Hand-rolled, but behaves like `node_scalar_indicator!`: the
|
||||
// fallible `new` propagates the core error and throws a JS error on bad period.
|
||||
#[napi(js_name = "RVIVolatility")]
|
||||
pub struct RviVolatilityNode {
|
||||
inner: wc::RviVolatility,
|
||||
@@ -327,12 +306,271 @@ node_pair_indicator!(
|
||||
wc::PearsonCorrelation
|
||||
);
|
||||
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
|
||||
node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
|
||||
node_pair_indicator!(
|
||||
SpearmanCorrelationNode,
|
||||
"SpearmanCorrelation",
|
||||
wc::SpearmanCorrelation
|
||||
);
|
||||
|
||||
// ============================== PairSpreadZScore ==============================
|
||||
|
||||
/// Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
|
||||
/// price pair per update, a single z-score out.
|
||||
#[napi(js_name = "PairSpreadZScore")]
|
||||
pub struct PairSpreadZScoreNode {
|
||||
inner: wc::PairSpreadZScore,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PairSpreadZScoreNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(beta_period: u32, z_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PairSpreadZScore::new(beta_period as usize, z_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays of prices. Returns a length-`n`
|
||||
/// array with `NaN` for warmup positions.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== LeadLagCrossCorrelation ==============================
|
||||
|
||||
/// Lead/lag result: the offset that maximises correlation, and that correlation.
|
||||
#[napi(object)]
|
||||
pub struct LeadLagValue {
|
||||
/// Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`.
|
||||
pub lag: i32,
|
||||
/// Signed correlation at that lag, in `[-1, 1]`.
|
||||
pub correlation: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "LeadLagCrossCorrelation")]
|
||||
pub struct LeadLagCrossCorrelationNode {
|
||||
inner: wc::LeadLagCrossCorrelation,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl LeadLagCrossCorrelationNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(window: u32, max_lag: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::LeadLagCrossCorrelation::new(window as usize, max_lag as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<LeadLagValue> {
|
||||
self.inner.update((a, b)).map(|o| LeadLagValue {
|
||||
lag: o.lag as i32,
|
||||
correlation: o.correlation,
|
||||
})
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a flat array of length
|
||||
/// `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
|
||||
/// column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = vec![f64::NAN; a.len() * 2];
|
||||
for i in 0..a.len() {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 2] = o.lag as f64;
|
||||
out[i * 2 + 1] = o.correlation;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Cointegration ==============================
|
||||
|
||||
/// Cointegration result: hedge ratio, current spread, and the ADF statistic.
|
||||
#[napi(object)]
|
||||
pub struct CointegrationValue {
|
||||
/// Engle–Granger hedge ratio (OLS slope of `a` on `b`).
|
||||
pub hedge_ratio: f64,
|
||||
/// Current spread (regression residual) `a - (alpha + beta*b)`.
|
||||
pub spread: f64,
|
||||
/// Augmented Dickey–Fuller statistic on the spread; more negative ⇒ more
|
||||
/// strongly mean-reverting.
|
||||
pub adf_stat: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "Cointegration")]
|
||||
pub struct CointegrationNode {
|
||||
inner: wc::Cointegration,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl CointegrationNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, adf_lags: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cointegration::new(period as usize, adf_lags as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<CointegrationValue> {
|
||||
self.inner.update((a, b)).map(|o| CointegrationValue {
|
||||
hedge_ratio: o.hedge_ratio,
|
||||
spread: o.spread,
|
||||
adf_stat: o.adf_stat,
|
||||
})
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a flat array of length
|
||||
/// `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
|
||||
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = vec![f64::NAN; a.len() * 3];
|
||||
for i in 0..a.len() {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.spread;
|
||||
out[i * 3 + 2] = o.adf_stat;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RelativeStrengthAB ==============================
|
||||
|
||||
/// Relative-strength triple: the a/b ratio, its moving average, and its RSI.
|
||||
#[napi(object)]
|
||||
pub struct RelativeStrengthValue {
|
||||
/// Raw ratio `a / b`.
|
||||
pub ratio: f64,
|
||||
/// Moving average of the ratio.
|
||||
pub ratio_ma: f64,
|
||||
/// RSI of the ratio.
|
||||
pub ratio_rsi: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "RelativeStrengthAB")]
|
||||
pub struct RelativeStrengthAbNode {
|
||||
inner: wc::RelativeStrengthAB,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl RelativeStrengthAbNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(ma_period: u32, rsi_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RelativeStrengthAB::new(ma_period as usize, rsi_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<RelativeStrengthValue> {
|
||||
self.inner.update((a, b)).map(|o| RelativeStrengthValue {
|
||||
ratio: o.ratio,
|
||||
ratio_ma: o.ratio_ma,
|
||||
ratio_rsi: o.ratio_rsi,
|
||||
})
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a flat array of length
|
||||
/// `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
|
||||
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = vec![f64::NAN; a.len() * 3];
|
||||
for i in 0..a.len() {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.ratio;
|
||||
out[i * 3 + 1] = o.ratio_ma;
|
||||
out[i * 3 + 2] = o.ratio_rsi;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== MACD ==============================
|
||||
|
||||
/// MACD triple: macd line, signal line, histogram.
|
||||
@@ -1351,7 +1589,7 @@ impl InertiaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period))
|
||||
inner: wc::Inertia::new(rvi_period as usize, linreg_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
@@ -1412,9 +1650,9 @@ impl ConnorsRsiNode {
|
||||
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ConnorsRsi::new(
|
||||
clamp_period(period_rsi),
|
||||
clamp_period(period_streak),
|
||||
clamp_period(period_rank),
|
||||
period_rsi as usize,
|
||||
period_streak as usize,
|
||||
period_rank as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1484,12 +1722,8 @@ impl SmiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Smi::new(
|
||||
clamp_period(period),
|
||||
clamp_period(d_period),
|
||||
clamp_period(d2_period),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Smi::new(period as usize, d_period as usize, d2_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1559,15 +1793,15 @@ impl KstNode {
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kst::new(
|
||||
clamp_period(roc1),
|
||||
clamp_period(roc2),
|
||||
clamp_period(roc3),
|
||||
clamp_period(roc4),
|
||||
clamp_period(sma1),
|
||||
clamp_period(sma2),
|
||||
clamp_period(sma3),
|
||||
clamp_period(sma4),
|
||||
clamp_period(signal),
|
||||
roc1 as usize,
|
||||
roc2 as usize,
|
||||
roc3 as usize,
|
||||
roc4 as usize,
|
||||
sma1 as usize,
|
||||
sma2 as usize,
|
||||
sma3 as usize,
|
||||
sma4 as usize,
|
||||
signal as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1620,7 +1854,7 @@ impl PgoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Pgo::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Pgo::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1672,7 +1906,7 @@ impl RviNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Rvi::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1732,9 +1966,9 @@ impl AwesomeOscillatorHistogramNode {
|
||||
pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::AwesomeOscillatorHistogram::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(sma_period),
|
||||
fast as usize,
|
||||
slow as usize,
|
||||
sma_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1783,13 +2017,8 @@ impl StcNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Stc::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(schaff_period),
|
||||
factor,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Stc::new(fast as usize, slow as usize, schaff_period as usize, factor)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1829,10 +2058,10 @@ impl ElderImpulseNode {
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderImpulse::new(
|
||||
clamp_period(ema_period),
|
||||
clamp_period(macd_fast),
|
||||
clamp_period(macd_slow),
|
||||
clamp_period(macd_signal),
|
||||
ema_period as usize,
|
||||
macd_fast as usize,
|
||||
macd_slow as usize,
|
||||
macd_signal as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1875,12 +2104,8 @@ impl ZeroLagMacdNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ZeroLagMacd::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(signal),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::ZeroLagMacd::new(fast as usize, slow as usize, signal as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1927,7 +2152,7 @@ impl CfoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Cfo::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1961,7 +2186,7 @@ impl ApoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Apo::new(clamp_period(fast), clamp_period(slow)).map_err(map_err)?,
|
||||
inner: wc::Apo::new(fast as usize, slow as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2032,7 +2257,7 @@ impl EvwmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Evwma::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Evwma::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2088,7 +2313,7 @@ impl AlligatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(jaw: u32, teeth: u32, lips: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Alligator::new(clamp_period(jaw), clamp_period(teeth), clamp_period(lips))
|
||||
inner: wc::Alligator::new(jaw as usize, teeth as usize, lips as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
@@ -2146,7 +2371,7 @@ impl JmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, phase: f64, power: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Jma::new(clamp_period(period), phase, power).map_err(map_err)?,
|
||||
inner: wc::Jma::new(period as usize, phase, power).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2182,8 +2407,7 @@ impl VidyaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, cmo_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Vidya::new(clamp_period(period), clamp_period(cmo_period))
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Vidya::new(period as usize, cmo_period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2219,7 +2443,7 @@ impl AlmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Alma::new(clamp_period(period), offset, sigma).map_err(map_err)?,
|
||||
inner: wc::Alma::new(period as usize, offset, sigma).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -8357,7 +8581,8 @@ impl OpeningRangeNode {
|
||||
//
|
||||
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
|
||||
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
|
||||
// direction-less and emits 0/+1 only.
|
||||
// direction-less by default (0/+1); pass `signed = true` to its constructor for
|
||||
// the dragonfly/gravestone signed +-1 encoding.
|
||||
|
||||
macro_rules! node_candle_pattern {
|
||||
($node:ident, $inner:ty, $js:literal) => {
|
||||
@@ -8428,7 +8653,80 @@ macro_rules! node_candle_pattern {
|
||||
};
|
||||
}
|
||||
|
||||
node_candle_pattern!(DojiNode, wc::Doji, "Doji");
|
||||
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
|
||||
// rather than generated by `node_candle_pattern!`.
|
||||
#[napi(js_name = "Doji")]
|
||||
pub struct DojiNode {
|
||||
inner: wc::Doji,
|
||||
}
|
||||
|
||||
impl Default for DojiNode {
|
||||
fn default() -> Self {
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DojiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(signed: Option<bool>) -> Self {
|
||||
let inner = if signed.unwrap_or(false) {
|
||||
wc::Doji::new().signed()
|
||||
} else {
|
||||
wc::Doji::new()
|
||||
};
|
||||
Self { inner }
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(candle))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(open.len());
|
||||
for i in 0..open.len() {
|
||||
let candle =
|
||||
wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
#[napi(js_name = "isSigned")]
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.inner.is_signed()
|
||||
}
|
||||
}
|
||||
|
||||
node_candle_pattern!(HammerNode, wc::Hammer, "Hammer");
|
||||
node_candle_pattern!(InvertedHammerNode, wc::InvertedHammer, "InvertedHammer");
|
||||
node_candle_pattern!(HangingManNode, wc::HangingMan, "HangingMan");
|
||||
@@ -8456,6 +8754,304 @@ node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop");
|
||||
node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside");
|
||||
node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside");
|
||||
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
|
||||
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length arrays for one
|
||||
// snapshot (bids best-first = descending price, asks best-first = ascending
|
||||
// price); `batch` takes an array of `{ bidPx, bidSz, askPx, askSz }` snapshots
|
||||
// and returns one value per snapshot.
|
||||
|
||||
/// One order-book depth snapshot for batch evaluation.
|
||||
#[napi(object)]
|
||||
pub struct ObSnapshot {
|
||||
pub bid_px: Vec<f64>,
|
||||
pub bid_sz: Vec<f64>,
|
||||
pub ask_px: Vec<f64>,
|
||||
pub ask_sz: Vec<f64>,
|
||||
}
|
||||
|
||||
fn build_order_book(
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> napi::Result<wc::OrderBook> {
|
||||
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"bid/ask price and size arrays must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let bids = bid_px
|
||||
.iter()
|
||||
.zip(bid_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
let asks = ask_px
|
||||
.iter()
|
||||
.zip(ask_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
wc::OrderBook::new(bids, asks).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! node_ob_indicator {
|
||||
($node:ident, $inner:ty, $js:literal) => {
|
||||
#[napi(js_name = $js)]
|
||||
pub struct $node {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $node {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl $node {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for snap in &snapshots {
|
||||
let book =
|
||||
build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
node_ob_indicator!(
|
||||
OrderBookImbalanceTop1Node,
|
||||
wc::OrderBookImbalanceTop1,
|
||||
"OrderBookImbalanceTop1"
|
||||
);
|
||||
node_ob_indicator!(
|
||||
OrderBookImbalanceFullNode,
|
||||
wc::OrderBookImbalanceFull,
|
||||
"OrderBookImbalanceFull"
|
||||
);
|
||||
node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice");
|
||||
node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread");
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[napi(js_name = "OrderBookImbalanceTopN")]
|
||||
pub struct OrderBookImbalanceTopNNode {
|
||||
inner: wc::OrderBookImbalanceTopN,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl OrderBookImbalanceTopNNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(levels: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::OrderBookImbalanceTopN::new(levels as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for snap in &snapshots {
|
||||
let book = build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Trade Flow ==============================
|
||||
//
|
||||
// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming
|
||||
// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a
|
||||
// buyer-initiated trade); `batch` takes three equal-length arrays.
|
||||
|
||||
fn build_trade(price: f64, size: f64, is_buy: bool) -> napi::Result<wc::Trade> {
|
||||
let side = if is_buy {
|
||||
wc::Side::Buy
|
||||
} else {
|
||||
wc::Side::Sell
|
||||
};
|
||||
wc::Trade::new(price, size, side, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! node_trade_indicator {
|
||||
($node:ident, $inner:ty, $js:literal) => {
|
||||
#[napi(js_name = $js)]
|
||||
pub struct $node {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $node {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl $node {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let trade = build_trade(price[i], size[i], is_buy[i])?;
|
||||
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
node_trade_indicator!(SignedVolumeNode, wc::SignedVolume, "SignedVolume");
|
||||
node_trade_indicator!(
|
||||
CumulativeVolumeDeltaNode,
|
||||
wc::CumulativeVolumeDelta,
|
||||
"CumulativeVolumeDelta"
|
||||
);
|
||||
|
||||
// Trade imbalance carries a `window` parameter, so it is hand-written.
|
||||
#[napi(js_name = "TradeImbalance")]
|
||||
pub struct TradeImbalanceNode {
|
||||
inner: wc::TradeImbalance,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl TradeImbalanceNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(window: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::TradeImbalance::new(window as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let trade = build_trade(price[i], size[i], is_buy[i])?;
|
||||
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
+41
-283
@@ -1,314 +1,72 @@
|
||||
# Wickra
|
||||
# Wickra — Python
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
**Streaming-first technical indicators for Python. `pip install wickra` — no
|
||||
system dependencies, no C build tooling.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Python binding (PyO3); it
|
||||
exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install wickra
|
||||
```
|
||||
|
||||
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
|
||||
compile and no C library to track down.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
# Batch: classic TA-Lib-style usage over a whole array.
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
# Streaming: the same indicator, fed tick by tick in O(1).
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
value = rsi.update(price) # no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
## Documentation
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
## Disclaimer
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
||||
> cleanly on Windows without C build tooling — which is precisely the install
|
||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||
> them in your environment to see those rows light up too.
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
|
||||
## Indicators
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
|
||||
## Languages
|
||||
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||
|
||||
// Streaming or batch — same trait, same code.
|
||||
let mut sma = Sma::new(14)?;
|
||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
for price in live_feed {
|
||||
if let Some(v) = rsi.update(price) {
|
||||
println!("RSI = {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||
chain.update(price);
|
||||
```
|
||||
|
||||
## Live data sources
|
||||
|
||||
`wickra-data` (separate crate, opt-in) ships:
|
||||
|
||||
- A streaming OHLCV **CSV reader**.
|
||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, Rsi};
|
||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||
|
||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
while let Some(event) = stream.next_event().await? {
|
||||
if event.is_closed {
|
||||
if let Some(v) = rsi.update(event.candle.close) {
|
||||
println!("RSI = {v:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Python live-trading example using the public `websockets` package lives at
|
||||
`examples/python/live_trading.py`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
|
||||
## Building everything from source
|
||||
|
||||
```bash
|
||||
# Rust core + tests
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo bench -p wickra
|
||||
|
||||
# Python binding (requires Rust toolchain + maturin)
|
||||
cd bindings/python
|
||||
maturin develop --release
|
||||
pytest
|
||||
|
||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||
proptest. The four bindings inherit your indicator automatically once
|
||||
you expose it in the language wrappers.
|
||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||
most regressions; please don't disable them.
|
||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||
its own tests; please keep the `batch == streaming` invariant.
|
||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
are CI gates; running them locally before pushing keeps reviews short.
|
||||
|
||||
For larger architectural changes, open an issue first so we can sketch the
|
||||
shape together before you invest the time.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
|
||||
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
|
||||
@@ -161,6 +161,11 @@ from ._wickra import (
|
||||
HurstExponent,
|
||||
PearsonCorrelation,
|
||||
Beta,
|
||||
PairwiseBeta,
|
||||
PairSpreadZScore,
|
||||
LeadLagCrossCorrelation,
|
||||
Cointegration,
|
||||
RelativeStrengthAB,
|
||||
SpearmanCorrelation,
|
||||
# Ehlers / Cycle
|
||||
SuperSmoother,
|
||||
@@ -235,6 +240,16 @@ from ._wickra import (
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
# Microstructure: order book
|
||||
OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN,
|
||||
OrderBookImbalanceFull,
|
||||
Microprice,
|
||||
QuotedSpread,
|
||||
# Microstructure: trade flow
|
||||
SignedVolume,
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
@@ -393,6 +408,11 @@ __all__ = [
|
||||
"HurstExponent",
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"PairwiseBeta",
|
||||
"PairSpreadZScore",
|
||||
"LeadLagCrossCorrelation",
|
||||
"Cointegration",
|
||||
"RelativeStrengthAB",
|
||||
"SpearmanCorrelation",
|
||||
# Ehlers / Cycle
|
||||
"SuperSmoother",
|
||||
@@ -467,6 +487,16 @@ __all__ = [
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
# Microstructure: order book
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
# Microstructure: trade flow
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
|
||||
+765
-4
@@ -26,7 +26,9 @@ fn map_err(e: wc::Error) -> PyErr {
|
||||
| wc::Error::NonPositiveMultiplier
|
||||
| wc::Error::NonFiniteInput
|
||||
| wc::Error::InvalidCandle { .. }
|
||||
| wc::Error::InvalidTick { .. } => PyValueError::new_err(e.to_string()),
|
||||
| wc::Error::InvalidTick { .. }
|
||||
| wc::Error::InvalidOrderBook { .. }
|
||||
| wc::Error::InvalidTrade { .. } => PyValueError::new_err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10751,6 +10753,383 @@ impl PyBeta {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== PairwiseBeta ==============================
|
||||
|
||||
#[pyclass(name = "PairwiseBeta", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyPairwiseBeta {
|
||||
inner: wc::PairwiseBeta,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPairwiseBeta {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("PairwiseBeta(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== PairSpreadZScore ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "PairSpreadZScore",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyPairSpreadZScore {
|
||||
inner: wc::PairSpreadZScore,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPairSpreadZScore {
|
||||
#[new]
|
||||
#[pyo3(signature = (beta_period=20, z_period=20))]
|
||||
fn new(beta_period: usize, z_period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn beta_period(&self) -> usize {
|
||||
self.inner.beta_period()
|
||||
}
|
||||
#[getter]
|
||||
fn z_period(&self) -> usize {
|
||||
self.inner.z_period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"PairSpreadZScore(beta_period={}, z_period={})",
|
||||
self.inner.beta_period(),
|
||||
self.inner.z_period()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== LeadLagCrossCorrelation ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "LeadLagCrossCorrelation",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyLeadLagCrossCorrelation {
|
||||
inner: wc::LeadLagCrossCorrelation,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyLeadLagCrossCorrelation {
|
||||
#[new]
|
||||
#[pyo3(signature = (window=20, max_lag=10))]
|
||||
fn new(window: usize, max_lag: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(lag, correlation)` or `None` during warmup. A positive lag
|
||||
/// means `a` leads `b`.
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<(i64, f64)> {
|
||||
self.inner.update((a, b)).map(|o| (o.lag, o.correlation))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
|
||||
/// `(n, 2)` with columns `[lag, correlation]`. Warmup rows are NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let n = xs.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((xs[i], ys[i])) {
|
||||
out[i * 2] = o.lag as f64;
|
||||
out[i * 2 + 1] = o.correlation;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn window(&self) -> usize {
|
||||
self.inner.window()
|
||||
}
|
||||
#[getter]
|
||||
fn max_lag(&self) -> usize {
|
||||
self.inner.max_lag()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"LeadLagCrossCorrelation(window={}, max_lag={})",
|
||||
self.inner.window(),
|
||||
self.inner.max_lag()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Cointegration ==============================
|
||||
|
||||
#[pyclass(name = "Cointegration", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyCointegration {
|
||||
inner: wc::Cointegration,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCointegration {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=30, adf_lags=1))]
|
||||
fn new(period: usize, adf_lags: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(hedge_ratio, spread, adf_stat)` or `None` during warmup.
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
|
||||
self.inner
|
||||
.update((a, b))
|
||||
.map(|o| (o.hedge_ratio, o.spread, o.adf_stat))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
|
||||
/// `(n, 3)` with columns `[hedge_ratio, spread, adf_stat]`. Warmup rows are
|
||||
/// NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let n = xs.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((xs[i], ys[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.spread;
|
||||
out[i * 3 + 2] = o.adf_stat;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
#[getter]
|
||||
fn adf_lags(&self) -> usize {
|
||||
self.inner.adf_lags()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Cointegration(period={}, adf_lags={})",
|
||||
self.inner.period(),
|
||||
self.inner.adf_lags()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RelativeStrengthAB ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "RelativeStrengthAB",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyRelativeStrengthAB {
|
||||
inner: wc::RelativeStrengthAB,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRelativeStrengthAB {
|
||||
#[new]
|
||||
#[pyo3(signature = (ma_period=20, rsi_period=14))]
|
||||
fn new(ma_period: usize, rsi_period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(ratio, ratio_ma, ratio_rsi)` or `None` during warmup.
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
|
||||
self.inner
|
||||
.update((a, b))
|
||||
.map(|o| (o.ratio, o.ratio_ma, o.ratio_rsi))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
|
||||
/// `(n, 3)` with columns `[ratio, ratio_ma, ratio_rsi]`. Warmup rows are
|
||||
/// NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let n = xs.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((xs[i], ys[i])) {
|
||||
out[i * 3] = o.ratio;
|
||||
out[i * 3 + 1] = o.ratio_ma;
|
||||
out[i * 3 + 2] = o.ratio_rsi;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn ma_period(&self) -> usize {
|
||||
self.inner.ma_period()
|
||||
}
|
||||
#[getter]
|
||||
fn rsi_period(&self) -> usize {
|
||||
self.inner.rsi_period()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"RelativeStrengthAB(ma_period={}, rsi_period={})",
|
||||
self.inner.ma_period(),
|
||||
self.inner.rsi_period()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SpearmanCorrelation ==============================
|
||||
|
||||
#[pyclass(
|
||||
@@ -11056,8 +11435,9 @@ impl PyOpeningRange {
|
||||
// ============================== Candlestick Patterns ==============================
|
||||
//
|
||||
// All 15 patterns take Candles and emit a signed f64 signal per bar:
|
||||
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less, so it
|
||||
// uses +1.0 / 0.0 only.
|
||||
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less by
|
||||
// default (+1.0 / 0.0); construct it with `signed=True` for the
|
||||
// dragonfly/gravestone signed +-1 encoding.
|
||||
|
||||
macro_rules! candle_pattern_no_param {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
@@ -11128,7 +11508,86 @@ macro_rules! candle_pattern_no_param {
|
||||
};
|
||||
}
|
||||
|
||||
candle_pattern_no_param!(PyDoji, wc::Doji, "Doji");
|
||||
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
|
||||
// rather than generated by `candle_pattern_no_param!`.
|
||||
#[pyclass(name = "Doji", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyDoji {
|
||||
inner: wc::Doji,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDoji {
|
||||
#[new]
|
||||
#[pyo3(signature = (signed = false))]
|
||||
fn new(signed: bool) -> Self {
|
||||
let inner = if signed {
|
||||
wc::Doji::new().signed()
|
||||
} else {
|
||||
wc::Doji::new()
|
||||
};
|
||||
Self { inner }
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let o = open
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"open, high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(o.len());
|
||||
for i in 0..o.len() {
|
||||
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn is_signed(&self) -> bool {
|
||||
self.inner.is_signed()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Doji(signed={})",
|
||||
if self.inner.is_signed() {
|
||||
"True"
|
||||
} else {
|
||||
"False"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
candle_pattern_no_param!(PyHammer, wc::Hammer, "Hammer");
|
||||
candle_pattern_no_param!(PyInvertedHammer, wc::InvertedHammer, "InvertedHammer");
|
||||
candle_pattern_no_param!(PyHangingMan, wc::HangingMan, "HangingMan");
|
||||
@@ -11156,6 +11615,293 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
|
||||
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
|
||||
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
|
||||
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
|
||||
// `update(bid_px, bid_sz, ask_px, ask_sz)` takes four equal-length sequences
|
||||
// describing one snapshot (bids best-first = descending price, asks best-first
|
||||
// = ascending price); `batch` takes a list of such `(bid_px, bid_sz, ask_px,
|
||||
// ask_sz)` tuples and returns one value per snapshot.
|
||||
|
||||
fn build_order_book(
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> PyResult<wc::OrderBook> {
|
||||
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"bid/ask price and size arrays must be equal length",
|
||||
));
|
||||
}
|
||||
let bids = bid_px
|
||||
.iter()
|
||||
.zip(bid_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
let asks = ask_px
|
||||
.iter()
|
||||
.zip(ask_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
wc::OrderBook::new(bids, asks).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! py_ob_indicator {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct $name {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl $name {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{}()", $repr)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
py_ob_indicator!(
|
||||
PyOrderBookImbalanceTop1,
|
||||
wc::OrderBookImbalanceTop1,
|
||||
"OrderBookImbalanceTop1"
|
||||
);
|
||||
py_ob_indicator!(
|
||||
PyOrderBookImbalanceFull,
|
||||
wc::OrderBookImbalanceFull,
|
||||
"OrderBookImbalanceFull"
|
||||
);
|
||||
py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice");
|
||||
py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread");
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[pyclass(
|
||||
name = "OrderBookImbalanceTopN",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyOrderBookImbalanceTopN {
|
||||
inner: wc::OrderBookImbalanceTopN,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOrderBookImbalanceTopN {
|
||||
#[new]
|
||||
fn new(levels: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
bid_px: Vec<f64>,
|
||||
bid_sz: Vec<f64>,
|
||||
ask_px: Vec<f64>,
|
||||
ask_sz: Vec<f64>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let mut out = Vec::with_capacity(snapshots.len());
|
||||
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
out.push(self.inner.update(book).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("OrderBookImbalanceTopN(levels={})", self.inner.levels())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Trade Flow ==============================
|
||||
//
|
||||
// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming
|
||||
// `update(price, size, is_buy)` takes one trade (`is_buy=True` for a
|
||||
// buyer-initiated trade); `batch` takes three equal-length arrays.
|
||||
|
||||
fn build_trade(price: f64, size: f64, is_buy: bool) -> PyResult<wc::Trade> {
|
||||
let side = if is_buy {
|
||||
wc::Side::Buy
|
||||
} else {
|
||||
wc::Side::Sell
|
||||
};
|
||||
wc::Trade::new(price, size, side, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! py_trade_indicator {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct $name {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl $name {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult<Option<f64>> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let trade = build_trade(price[i], size[i], is_buy[i])?;
|
||||
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{}()", $repr)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
py_trade_indicator!(PySignedVolume, wc::SignedVolume, "SignedVolume");
|
||||
py_trade_indicator!(
|
||||
PyCumulativeVolumeDelta,
|
||||
wc::CumulativeVolumeDelta,
|
||||
"CumulativeVolumeDelta"
|
||||
);
|
||||
|
||||
// Trade imbalance carries a `window` parameter, so it is hand-written.
|
||||
#[pyclass(
|
||||
name = "TradeImbalance",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyTradeImbalance {
|
||||
inner: wc::TradeImbalance,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTradeImbalance {
|
||||
#[new]
|
||||
fn new(window: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::TradeImbalance::new(window).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult<Option<f64>> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let trade = build_trade(price[i], size[i], is_buy[i])?;
|
||||
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("TradeImbalance(window={})", self.inner.window())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -12236,6 +12982,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyHurstExponent>()?;
|
||||
m.add_class::<PyPearsonCorrelation>()?;
|
||||
m.add_class::<PyBeta>()?;
|
||||
m.add_class::<PyPairwiseBeta>()?;
|
||||
m.add_class::<PyPairSpreadZScore>()?;
|
||||
m.add_class::<PyLeadLagCrossCorrelation>()?;
|
||||
m.add_class::<PyCointegration>()?;
|
||||
m.add_class::<PyRelativeStrengthAB>()?;
|
||||
m.add_class::<PySpearmanCorrelation>()?;
|
||||
m.add_class::<PyValueArea>()?;
|
||||
m.add_class::<PyInitialBalance>()?;
|
||||
@@ -12256,6 +13007,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PySpinningTop>()?;
|
||||
m.add_class::<PyThreeInside>()?;
|
||||
m.add_class::<PyThreeOutside>()?;
|
||||
// Microstructure: order book.
|
||||
m.add_class::<PyOrderBookImbalanceTop1>()?;
|
||||
m.add_class::<PyOrderBookImbalanceTopN>()?;
|
||||
m.add_class::<PyOrderBookImbalanceFull>()?;
|
||||
m.add_class::<PyMicroprice>()?;
|
||||
m.add_class::<PyQuotedSpread>()?;
|
||||
// Microstructure: trade flow.
|
||||
m.add_class::<PySignedVolume>()?;
|
||||
m.add_class::<PyCumulativeVolumeDelta>()?;
|
||||
m.add_class::<PyTradeImbalance>()?;
|
||||
// Family 15: Risk / Performance metrics.
|
||||
m.add_class::<PySharpeRatio>()?;
|
||||
m.add_class::<PySortinoRatio>()?;
|
||||
|
||||
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
|
||||
ta.Aroon(14).batch(high, short)
|
||||
|
||||
|
||||
def test_pairwise_beta_rejects_bad_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(1)
|
||||
|
||||
|
||||
def test_unequal_length_pair_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(20).batch(a, b)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(20, 20).batch(a, b)
|
||||
|
||||
|
||||
def test_pair_spread_zscore_rejects_bad_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(1, 20)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(20, 1)
|
||||
|
||||
|
||||
def test_lead_lag_rejects_bad_params():
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(1, 5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(10, 0)
|
||||
|
||||
|
||||
def test_lead_lag_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
|
||||
|
||||
|
||||
def test_cointegration_rejects_too_small_period():
|
||||
# period must be >= 2*adf_lags + 4.
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(3, 0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(5, 1)
|
||||
|
||||
|
||||
def test_cointegration_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(20, 1).batch(a, b)
|
||||
|
||||
|
||||
def test_relative_strength_rejects_zero_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(0, 14)
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(20, 0)
|
||||
|
||||
|
||||
def test_relative_strength_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(10, 14).batch(a, b)
|
||||
|
||||
|
||||
def test_roc_and_trix_have_default_periods():
|
||||
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
|
||||
assert ta.ROC().period == 10
|
||||
@@ -100,3 +166,48 @@ def test_family_10_ehlers_rejects_invalid_parameters():
|
||||
ta.MAMA(0.05, 0.5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.EmpiricalModeDecomposition(20, 0.0)
|
||||
|
||||
|
||||
def test_orderbook_topn_zero_levels_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTopN(0)
|
||||
|
||||
|
||||
def test_orderbook_unequal_price_size_lengths_raise():
|
||||
# bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0])
|
||||
with pytest.raises(ValueError):
|
||||
ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_crossed_book_raises():
|
||||
# best_bid (102) >= best_ask (101) is a crossed book -> rejected.
|
||||
with pytest.raises(ValueError):
|
||||
ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_misordered_levels_raise():
|
||||
# Bids must be strictly descending in price.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0])
|
||||
|
||||
|
||||
def test_trade_imbalance_zero_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.TradeImbalance(0)
|
||||
|
||||
|
||||
def test_trade_negative_size_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SignedVolume().update(100.0, -1.0, True)
|
||||
|
||||
|
||||
def test_trade_non_positive_price_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.CumulativeVolumeDelta().update(0.0, 1.0, True)
|
||||
|
||||
|
||||
def test_trade_batch_unequal_lengths_raise():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False])
|
||||
|
||||
@@ -429,6 +429,67 @@ def test_information_ratio_known_window():
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_pairwise_beta_squared_price_is_two():
|
||||
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
|
||||
# b must have *varying* returns (a constant-return path has zero variance
|
||||
# and an undefined slope, which the indicator reports as 0).
|
||||
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
|
||||
a = b**2
|
||||
out = ta.PairwiseBeta(5).batch(a, b)
|
||||
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_pairwise_beta_inverse_price_is_minus_one():
|
||||
# a = 1/b ⇒ a's log-returns are −1× b's ⇒ pairwise beta = −1.
|
||||
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
|
||||
a = 1.0 / b
|
||||
out = ta.PairwiseBeta(5).batch(a, b)
|
||||
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_pair_spread_zscore_flat_benchmark_sign():
|
||||
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
|
||||
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ −1.
|
||||
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
|
||||
b = np.full_like(a, 100.0)
|
||||
out = ta.PairSpreadZScore(2, 2).batch(a, b)
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_lead_lag_cross_correlation_negative_lead():
|
||||
# a is a delayed copy of b ⇒ b leads a ⇒ lag = −2, correlation ≈ 1.
|
||||
def sig(t):
|
||||
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
|
||||
|
||||
n = 60
|
||||
a = np.array([sig(t - 2) for t in range(n)])
|
||||
b = np.array([sig(t) for t in range(n)])
|
||||
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
|
||||
assert int(out[-1, 0]) == -2
|
||||
assert out[-1, 1] > 0.99
|
||||
|
||||
|
||||
def test_cointegration_perfect_pair():
|
||||
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
|
||||
b = np.array([100.0 + t for t in range(40)])
|
||||
a = 2.0 * b + 5.0
|
||||
out = ta.Cointegration(20, 1).batch(a, b)
|
||||
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
|
||||
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
|
||||
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_relative_strength_rising_ratio_is_overbought():
|
||||
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
|
||||
n = 20
|
||||
a = np.array([100.0 + 2.0 * t for t in range(n)])
|
||||
b = np.full(n, 100.0)
|
||||
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
|
||||
assert out[-1, 0] > 1.0
|
||||
assert math.isclose(out[-1, 2], 100.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_value_at_risk_known_window():
|
||||
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
|
||||
returns = np.array([i * 0.01 for i in range(-5, 5)])
|
||||
@@ -762,3 +823,69 @@ def test_yang_zhang_zero_movement_yields_zero():
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_doji_default_is_directionless_flag():
|
||||
# Default Doji is a direction-less detection flag: +1 on a doji, 0 else.
|
||||
d = ta.Doji()
|
||||
assert d.is_signed() is False
|
||||
# body 0, range 2 -> doji.
|
||||
assert d.update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
|
||||
# body 2 == range -> not a doji.
|
||||
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_doji_signed_dragonfly_gravestone_neutral():
|
||||
# Signed Doji classifies by body position within the range.
|
||||
d = ta.Doji(signed=True)
|
||||
assert d.is_signed() is True
|
||||
# Dragonfly: body at the top, long lower shadow -> bullish +1.
|
||||
assert d.update((10.0, 10.05, 6.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
|
||||
# Gravestone: body at the bottom, long upper shadow -> bearish -1.
|
||||
assert d.update((10.0, 14.0, 9.95, 10.0, 1.0, 1)) == pytest.approx(-1.0)
|
||||
# Long-legged: body centred, symmetric shadows -> neutral 0.
|
||||
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
# A large body is not a doji at all -> 0 regardless of position.
|
||||
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_orderbook_imbalance_reference_values():
|
||||
# Top-1: (3 - 1) / (3 + 1) = 0.5.
|
||||
assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5)
|
||||
# Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
|
||||
topn = ta.OrderBookImbalanceTopN(2)
|
||||
assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2)
|
||||
# Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5.
|
||||
full = ta.OrderBookImbalanceFull()
|
||||
assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_microprice_reference_value():
|
||||
# (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid.
|
||||
mp = ta.Microprice()
|
||||
assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25)
|
||||
|
||||
|
||||
def test_quoted_spread_reference_value():
|
||||
# spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
|
||||
qs = ta.QuotedSpread()
|
||||
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
|
||||
|
||||
|
||||
def test_signed_volume_reference_values():
|
||||
assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0)
|
||||
assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0)
|
||||
|
||||
|
||||
def test_cumulative_volume_delta_reference_values():
|
||||
cvd = ta.CumulativeVolumeDelta()
|
||||
assert cvd.update(100.0, 5.0, True) == pytest.approx(5.0)
|
||||
assert cvd.update(100.0, 2.0, False) == pytest.approx(3.0)
|
||||
assert cvd.update(100.0, 4.0, False) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_trade_imbalance_reference_value():
|
||||
ti = ta.TradeImbalance(2)
|
||||
assert ti.update(100.0, 3.0, True) is None # warming up
|
||||
# Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
|
||||
assert ti.update(100.0, 1.0, False) == pytest.approx(0.5)
|
||||
|
||||
@@ -129,3 +129,46 @@ def test_ehlers_indicators_lifecycle():
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_orderbook_lifecycle():
|
||||
snapshot = ([100.0], [1.0], [101.0], [1.0])
|
||||
for ind in [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(3),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
ind.update(*snapshot)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_orderbook_topn_repr():
|
||||
assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)"
|
||||
|
||||
|
||||
def test_tradeflow_lifecycle():
|
||||
for ind in [ta.SignedVolume(), ta.CumulativeVolumeDelta()]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
ind.update(100.0, 1.0, True)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_trade_imbalance_lifecycle_and_repr():
|
||||
ti = ta.TradeImbalance(3)
|
||||
assert ti.warmup_period() == 3
|
||||
assert not ti.is_ready()
|
||||
for _ in range(3):
|
||||
ti.update(100.0, 1.0, True)
|
||||
assert ti.is_ready()
|
||||
ti.reset()
|
||||
assert not ti.is_ready()
|
||||
assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)"
|
||||
|
||||
@@ -159,6 +159,8 @@ PAIR = [
|
||||
(ta.TreynorRatio, (20, 0.0)),
|
||||
(ta.InformationRatio, (20,)),
|
||||
(ta.Alpha, (20, 0.0)),
|
||||
(ta.PairwiseBeta, (20,)),
|
||||
(ta.PairSpreadZScore, (20, 20)),
|
||||
]
|
||||
|
||||
|
||||
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
|
||||
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
|
||||
|
||||
|
||||
def _ll_signal(t):
|
||||
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
|
||||
|
||||
|
||||
def test_lead_lag_detects_lead():
|
||||
n = 60
|
||||
a = np.array([_ll_signal(t) for t in range(n)])
|
||||
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
|
||||
b = np.array([_ll_signal(t - 3) for t in range(n)])
|
||||
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
|
||||
assert out.shape == (n, 2)
|
||||
assert int(out[-1, 0]) == 3
|
||||
assert out[-1, 1] > 0.99
|
||||
|
||||
|
||||
def test_lead_lag_streaming_matches_batch():
|
||||
n = 60
|
||||
a = np.array([_ll_signal(t) for t in range(n)])
|
||||
b = np.array([_ll_signal(t - 2) for t in range(n)])
|
||||
ind = ta.LeadLagCrossCorrelation(12, 5)
|
||||
batch = ind.batch(a, b)
|
||||
streamer = ta.LeadLagCrossCorrelation(12, 5)
|
||||
for i in range(n):
|
||||
v = streamer.update(float(a[i]), float(b[i]))
|
||||
if v is None:
|
||||
assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
|
||||
else:
|
||||
lag, corr = v
|
||||
assert int(batch[i, 0]) == lag
|
||||
assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_cointegration_detects_mean_reverting_pair():
|
||||
n = 80
|
||||
b = np.array([50.0 + 0.5 * t for t in range(n)])
|
||||
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
|
||||
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
|
||||
out = ta.Cointegration(40, 1).batch(a, b)
|
||||
assert out.shape == (n, 3)
|
||||
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
|
||||
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
|
||||
|
||||
|
||||
def test_cointegration_streaming_matches_batch():
|
||||
n = 70
|
||||
b = np.array([30.0 + 0.7 * t for t in range(n)])
|
||||
a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
|
||||
batch = ta.Cointegration(25, 2).batch(a, b)
|
||||
streamer = ta.Cointegration(25, 2)
|
||||
for i in range(n):
|
||||
v = streamer.update(float(a[i]), float(b[i]))
|
||||
if v is None:
|
||||
assert np.all(np.isnan(batch[i]))
|
||||
else:
|
||||
hr, sp, adf = v
|
||||
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_relative_strength_constant_ratio():
|
||||
n = 30
|
||||
a = np.full(n, 200.0)
|
||||
b = np.full(n, 100.0) # ratio is a constant 2
|
||||
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
|
||||
assert out.shape == (n, 3)
|
||||
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
|
||||
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
|
||||
assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
|
||||
|
||||
|
||||
def test_relative_strength_streaming_matches_batch():
|
||||
n = 60
|
||||
tt = np.arange(n)
|
||||
a = 100.0 + 5.0 * np.sin(tt * 0.3)
|
||||
b = 100.0 + 2.0 * np.cos(tt * 0.2)
|
||||
batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
|
||||
streamer = ta.RelativeStrengthAB(10, 14)
|
||||
for i in range(n):
|
||||
v = streamer.update(float(a[i]), float(b[i]))
|
||||
if v is None:
|
||||
assert np.all(np.isnan(batch[i]))
|
||||
else:
|
||||
ratio, ma, rsi = v
|
||||
assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
|
||||
|
||||
|
||||
# --- Candle-input, single-output indicators -------------------------------
|
||||
#
|
||||
# Each entry is (factory, batch-call). Streaming always feeds the full
|
||||
@@ -1772,3 +1863,58 @@ def test_new_indicators_expose_lifecycle():
|
||||
assert ind.warmup_period() >= 1
|
||||
ind.reset()
|
||||
assert ind.is_ready() is False
|
||||
|
||||
|
||||
def _orderbook_snapshots(n: int) -> list:
|
||||
"""A deterministic varying sequence of order-book snapshots."""
|
||||
snaps = []
|
||||
for i in range(n):
|
||||
bid_sz = 1.0 + (i % 5)
|
||||
ask_sz = 1.0 + ((i + 2) % 4)
|
||||
snaps.append(
|
||||
(
|
||||
[100.0, 99.0],
|
||||
[bid_sz, 1.0],
|
||||
[101.0, 102.0],
|
||||
[ask_sz, 1.0],
|
||||
)
|
||||
)
|
||||
return snaps
|
||||
|
||||
|
||||
def test_orderbook_indicators_streaming_equals_batch():
|
||||
snaps = _orderbook_snapshots(40)
|
||||
for make in (
|
||||
ta.OrderBookImbalanceTop1,
|
||||
lambda: ta.OrderBookImbalanceTopN(2),
|
||||
ta.OrderBookImbalanceFull,
|
||||
ta.Microprice,
|
||||
ta.QuotedSpread,
|
||||
):
|
||||
batch = make().batch(snaps)
|
||||
streamer = make()
|
||||
streamed = np.array(
|
||||
[streamer.update(*snap) for snap in snaps], dtype=np.float64
|
||||
)
|
||||
assert batch.shape == (len(snaps),)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_tradeflow_indicators_streaming_equals_batch():
|
||||
n = 40
|
||||
price = np.full(n, 100.0)
|
||||
size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 2 == 0 for i in range(n)]
|
||||
for make in (
|
||||
ta.SignedVolume,
|
||||
ta.CumulativeVolumeDelta,
|
||||
lambda: ta.TradeImbalance(5),
|
||||
):
|
||||
batch = make().batch(price, size, is_buy)
|
||||
streamer = make()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert batch.shape == (n,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
@@ -97,3 +97,41 @@ def test_ehlers_super_smoother_batch_shape(sine_prices):
|
||||
def test_mama_batch_shape(sine_prices):
|
||||
out = ta.MAMA().batch(sine_prices)
|
||||
assert out.shape == (sine_prices.size, 2)
|
||||
|
||||
|
||||
def test_orderbook_indicators_construct_and_emit():
|
||||
# All five order-book indicators accept a four-array snapshot and emit a float.
|
||||
snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0])
|
||||
indicators = [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(2),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
]
|
||||
for ind in indicators:
|
||||
out = ind.update(*snapshot)
|
||||
assert isinstance(out, float)
|
||||
|
||||
|
||||
def test_orderbook_batch_returns_one_value_per_snapshot():
|
||||
snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5
|
||||
out = ta.OrderBookImbalanceTop1().batch(snapshots)
|
||||
assert out.shape == (5,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_tradeflow_indicators_construct_and_emit():
|
||||
# SignedVolume and CVD emit from the first trade; TradeImbalance(1) too.
|
||||
assert isinstance(ta.SignedVolume().update(100.0, 2.0, True), float)
|
||||
assert isinstance(ta.CumulativeVolumeDelta().update(100.0, 2.0, True), float)
|
||||
assert isinstance(ta.TradeImbalance(1).update(100.0, 2.0, True), float)
|
||||
|
||||
|
||||
def test_tradeflow_batch_returns_one_value_per_trade():
|
||||
price = np.full(6, 100.0)
|
||||
size = np.array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0])
|
||||
is_buy = [True, False, True, False, True, False]
|
||||
out = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
|
||||
assert out.shape == (6,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
@@ -201,3 +201,33 @@ def test_opening_range_streaming_matches_batch(ohlc_series):
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_orderbook_streaming_matches_batch():
|
||||
snaps = [
|
||||
(
|
||||
[100.0, 99.0],
|
||||
[1.0 + (i % 5), 1.0],
|
||||
[101.0, 102.0],
|
||||
[1.0 + ((i + 1) % 3), 1.0],
|
||||
)
|
||||
for i in range(30)
|
||||
]
|
||||
batch = ta.Microprice().batch(snaps)
|
||||
streamer = ta.Microprice()
|
||||
streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_tradeflow_streaming_matches_batch():
|
||||
n = 30
|
||||
price = np.full(n, 100.0)
|
||||
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 3 != 0 for i in range(n)]
|
||||
batch = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
|
||||
streamer = ta.CumulativeVolumeDelta()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
+45
-287
@@ -1,314 +1,72 @@
|
||||
# Wickra
|
||||
# Wickra — WebAssembly
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](https://www.npmjs.com/package/wickra-wasm)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
**Streaming-first technical indicators in the browser. `npm install
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading dashboards and historical backtests
|
||||
share the exact same implementation. This package is the WebAssembly binding
|
||||
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||
indicators across sixteen families.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
||||
> cleanly on Windows without C build tooling — which is precisely the install
|
||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||
> them in your environment to see those rows light up too.
|
||||
|
||||
Run the suite yourself:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
npm install wickra-wasm
|
||||
```
|
||||
|
||||
## Indicators
|
||||
## Quick start
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
The module ships a default `init` export that loads the `.wasm` payload; await
|
||||
it once before constructing indicators.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
```js
|
||||
import init, { RSI } from 'wickra-wasm';
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
await init(); // load the WebAssembly module once
|
||||
|
||||
## Languages
|
||||
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||
|
||||
// Streaming or batch — same trait, same code.
|
||||
let mut sma = Sma::new(14)?;
|
||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
for price in live_feed {
|
||||
if let Some(v) = rsi.update(price) {
|
||||
println!("RSI = {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||
chain.update(price);
|
||||
```
|
||||
|
||||
## Live data sources
|
||||
|
||||
`wickra-data` (separate crate, opt-in) ships:
|
||||
|
||||
- A streaming OHLCV **CSV reader**.
|
||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, Rsi};
|
||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||
|
||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
while let Some(event) = stream.next_event().await? {
|
||||
if event.is_closed {
|
||||
if let Some(v) = rsi.update(event.candle.close) {
|
||||
println!("RSI = {v:.2}");
|
||||
}
|
||||
}
|
||||
// Streaming: feed prices tick by tick in O(1).
|
||||
const rsi = new RSI(14);
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // null during warmup
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Python live-trading example using the public `websockets` package lives at
|
||||
`examples/python/live_trading.py`.
|
||||
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
|
||||
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
|
||||
`null` while the indicator is still warming up.
|
||||
|
||||
## Project layout
|
||||
## Documentation
|
||||
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||
|
||||
## Building everything from source
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
```bash
|
||||
# Rust core + tests
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo bench -p wickra
|
||||
## Disclaimer
|
||||
|
||||
# Python binding (requires Rust toolchain + maturin)
|
||||
cd bindings/python
|
||||
maturin develop --release
|
||||
pytest
|
||||
|
||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||
proptest. The four bindings inherit your indicator automatically once
|
||||
you expose it in the language wrappers.
|
||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||
most regressions; please don't disable them.
|
||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||
its own tests; please keep the `batch == streaming` invariant.
|
||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
are CI gates; running them locally before pushing keeps reviews short.
|
||||
|
||||
For larger architectural changes, open an issue first so we can sketch the
|
||||
shape together before you invest the time.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
+658
-2
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
|
||||
wc::PearsonCorrelation
|
||||
);
|
||||
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
|
||||
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
|
||||
wasm_pair_indicator!(
|
||||
WasmSpearmanCorrelation,
|
||||
"SpearmanCorrelation",
|
||||
wc::SpearmanCorrelation
|
||||
);
|
||||
|
||||
// ---------- PairSpreadZScore (two params) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "PairSpreadZScore")]
|
||||
pub struct WasmPairSpreadZScore {
|
||||
inner: wc::PairSpreadZScore,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "PairSpreadZScore")]
|
||||
impl WasmPairSpreadZScore {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(beta_period: usize, z_period: usize) -> Result<WasmPairSpreadZScore, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
|
||||
/// input position (`NaN` during warmup).
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- LeadLagCrossCorrelation (two params, object output) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "LeadLagCrossCorrelation")]
|
||||
pub struct WasmLeadLagCrossCorrelation {
|
||||
inner: wc::LeadLagCrossCorrelation,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "LeadLagCrossCorrelation")]
|
||||
impl WasmLeadLagCrossCorrelation {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(window: usize, max_lag: usize) -> Result<WasmLeadLagCrossCorrelation, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ lag, correlation }`, or `null` during warmup. Positive lag
|
||||
/// means `a` leads `b`.
|
||||
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
|
||||
match self.inner.update((a, b)) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"lag".into(), &(o.lag as f64).into()).ok();
|
||||
Reflect::set(&obj, &"correlation".into(), &o.correlation.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
/// Flat `Float64Array` of length `2 * n`: `[lag0, corr0, lag1, corr1, ...]`.
|
||||
/// Warmup positions are NaN.
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let n = a.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 2] = o.lag as f64;
|
||||
out[i * 2 + 1] = o.correlation;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cointegration (two params, object output) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "Cointegration")]
|
||||
pub struct WasmCointegration {
|
||||
inner: wc::Cointegration,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "Cointegration")]
|
||||
impl WasmCointegration {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, adf_lags: usize) -> Result<WasmCointegration, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ hedgeRatio, spread, adfStat }`, or `null` during warmup.
|
||||
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
|
||||
match self.inner.update((a, b)) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
|
||||
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
|
||||
Reflect::set(&obj, &"adfStat".into(), &o.adf_stat.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
/// Flat `Float64Array` of length `3 * n`:
|
||||
/// `[hedgeRatio0, spread0, adfStat0, hedgeRatio1, ...]`. Warmup rows are NaN.
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let n = a.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.spread;
|
||||
out[i * 3 + 2] = o.adf_stat;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- RelativeStrengthAB (two params, object output) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "RelativeStrengthAB")]
|
||||
pub struct WasmRelativeStrengthAb {
|
||||
inner: wc::RelativeStrengthAB,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "RelativeStrengthAB")]
|
||||
impl WasmRelativeStrengthAb {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(ma_period: usize, rsi_period: usize) -> Result<WasmRelativeStrengthAb, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ ratio, ratioMa, ratioRsi }`, or `null` during warmup.
|
||||
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
|
||||
match self.inner.update((a, b)) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"ratio".into(), &o.ratio.into()).ok();
|
||||
Reflect::set(&obj, &"ratioMa".into(), &o.ratio_ma.into()).ok();
|
||||
Reflect::set(&obj, &"ratioRsi".into(), &o.ratio_rsi.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
/// Flat `Float64Array` of length `3 * n`:
|
||||
/// `[ratio0, ratioMa0, ratioRsi0, ratio1, ...]`. Warmup rows are NaN.
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let n = a.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.ratio;
|
||||
out[i * 3 + 1] = o.ratio_ma;
|
||||
out[i * 3 + 2] = o.ratio_rsi;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- KAMA (three params) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = KAMA)]
|
||||
@@ -5950,7 +6167,8 @@ impl WasmOpeningRange {
|
||||
//
|
||||
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
|
||||
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
|
||||
// direction-less and emits 0/+1 only.
|
||||
// direction-less by default (0/+1); pass `signed = true` to its constructor for
|
||||
// the dragonfly/gravestone signed +-1 encoding.
|
||||
|
||||
macro_rules! wasm_candle_pattern {
|
||||
($wasm:ident, $inner:ty, $js:ident) => {
|
||||
@@ -6017,7 +6235,75 @@ macro_rules! wasm_candle_pattern {
|
||||
};
|
||||
}
|
||||
|
||||
wasm_candle_pattern!(WasmDoji, wc::Doji, Doji);
|
||||
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
|
||||
// rather than generated by `wasm_candle_pattern!`.
|
||||
#[wasm_bindgen(js_name = Doji)]
|
||||
pub struct WasmDoji {
|
||||
inner: wc::Doji,
|
||||
}
|
||||
|
||||
impl Default for WasmDoji {
|
||||
fn default() -> Self {
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Doji)]
|
||||
impl WasmDoji {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(signed: Option<bool>) -> WasmDoji {
|
||||
let inner = if signed.unwrap_or(false) {
|
||||
wc::Doji::new().signed()
|
||||
} else {
|
||||
wc::Doji::new()
|
||||
};
|
||||
Self { inner }
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if high.len() != n || low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("open, high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
#[wasm_bindgen(js_name = isSigned)]
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.inner.is_signed()
|
||||
}
|
||||
}
|
||||
|
||||
wasm_candle_pattern!(WasmHammer, wc::Hammer, Hammer);
|
||||
wasm_candle_pattern!(WasmInvertedHammer, wc::InvertedHammer, InvertedHammer);
|
||||
wasm_candle_pattern!(WasmHangingMan, wc::HangingMan, HangingMan);
|
||||
@@ -6045,6 +6331,230 @@ wasm_candle_pattern!(WasmSpinningTop, wc::SpinningTop, SpinningTop);
|
||||
wasm_candle_pattern!(WasmThreeInside, wc::ThreeInside, ThreeInside);
|
||||
wasm_candle_pattern!(WasmThreeOutside, wc::ThreeOutside, ThreeOutside);
|
||||
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
// Order-book indicators consume a depth snapshot rather than OHLCV. Each
|
||||
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length typed arrays for
|
||||
// one snapshot (bids best-first = descending price, asks best-first = ascending
|
||||
// price) — the streaming model that fits a live browser book feed. Batch over a
|
||||
// ragged depth history is provided by the Python and Node bindings.
|
||||
|
||||
fn build_order_book(
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> Result<wc::OrderBook, JsError> {
|
||||
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
|
||||
return Err(JsError::new(
|
||||
"bid/ask price and size arrays must be equal length",
|
||||
));
|
||||
}
|
||||
let bids = bid_px
|
||||
.iter()
|
||||
.zip(bid_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
let asks = ask_px
|
||||
.iter()
|
||||
.zip(ask_sz)
|
||||
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
|
||||
.collect();
|
||||
wc::OrderBook::new(bids, asks).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! wasm_ob_indicator {
|
||||
($wasm:ident, $inner:ty, $js:ident) => {
|
||||
#[wasm_bindgen(js_name = $js)]
|
||||
pub struct $wasm {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $wasm {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = $js)]
|
||||
impl $wasm {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> $wasm {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wasm_ob_indicator!(
|
||||
WasmOrderBookImbalanceTop1,
|
||||
wc::OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTop1
|
||||
);
|
||||
wasm_ob_indicator!(
|
||||
WasmOrderBookImbalanceFull,
|
||||
wc::OrderBookImbalanceFull,
|
||||
OrderBookImbalanceFull
|
||||
);
|
||||
wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice);
|
||||
wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread);
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[wasm_bindgen(js_name = OrderBookImbalanceTopN)]
|
||||
pub struct WasmOrderBookImbalanceTopN {
|
||||
inner: wc::OrderBookImbalanceTopN,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = OrderBookImbalanceTopN)]
|
||||
impl WasmOrderBookImbalanceTopN {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(levels: usize) -> Result<WasmOrderBookImbalanceTopN, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
bid_px: &[f64],
|
||||
bid_sz: &[f64],
|
||||
ask_px: &[f64],
|
||||
ask_sz: &[f64],
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
|
||||
Ok(self.inner.update(book))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Trade Flow ==============================
|
||||
//
|
||||
// Trade-flow indicators consume a trade tape rather than OHLCV. Each
|
||||
// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a
|
||||
// buyer-initiated trade) — the streaming model for a live browser trade feed.
|
||||
|
||||
fn build_trade(price: f64, size: f64, is_buy: bool) -> Result<wc::Trade, JsError> {
|
||||
let side = if is_buy {
|
||||
wc::Side::Buy
|
||||
} else {
|
||||
wc::Side::Sell
|
||||
};
|
||||
wc::Trade::new(price, size, side, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! wasm_trade_indicator {
|
||||
($wasm:ident, $inner:ty, $js:ident) => {
|
||||
#[wasm_bindgen(js_name = $js)]
|
||||
pub struct $wasm {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $wasm {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = $js)]
|
||||
impl $wasm {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> $wasm {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wasm_trade_indicator!(WasmSignedVolume, wc::SignedVolume, SignedVolume);
|
||||
wasm_trade_indicator!(
|
||||
WasmCumulativeVolumeDelta,
|
||||
wc::CumulativeVolumeDelta,
|
||||
CumulativeVolumeDelta
|
||||
);
|
||||
|
||||
// Trade imbalance carries a `window` parameter, so it is hand-written.
|
||||
#[wasm_bindgen(js_name = TradeImbalance)]
|
||||
pub struct WasmTradeImbalance {
|
||||
inner: wc::TradeImbalance,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TradeImbalance)]
|
||||
impl WasmTradeImbalance {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(window: usize) -> Result<WasmTradeImbalance, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TradeImbalance::new(window).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(build_trade(price, size, is_buy)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -6139,6 +6649,12 @@ mod tests {
|
||||
fn close_enough(a: f64, b: f64) -> bool {
|
||||
if a.is_nan() {
|
||||
b.is_nan()
|
||||
} else if a == b {
|
||||
// Exact equality, including matching infinities (e.g. ProfitFactor
|
||||
// with no losing trades is +inf in both the streaming and batch
|
||||
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
|
||||
// would otherwise reject two equal infinities.
|
||||
true
|
||||
} else {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
@@ -6630,6 +7146,146 @@ mod tests {
|
||||
"ready after 5 finite inputs even with prior NaNs"
|
||||
);
|
||||
}
|
||||
|
||||
// Streaming `update` must reproduce `batch` value-for-value for every scalar
|
||||
// indicator — the core O(1) state-machine invariant. Each entry builds a
|
||||
// fresh instance for the batch pass and another for the streaming pass. The
|
||||
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
|
||||
// factories, so they are known-valid.
|
||||
macro_rules! assert_scalar_stream_eq {
|
||||
($ctor:expr, $prices:expr) => {{
|
||||
let prices: &[f64] = $prices;
|
||||
let batch = { $ctor }.batch(prices);
|
||||
let mut streaming = { $ctor };
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let b = batch.get_index(i as u32);
|
||||
match streaming.update(p) {
|
||||
Some(v) => assert!(
|
||||
close_enough(v, b),
|
||||
"{} streaming != batch at {i}: {v} vs {b}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
None => assert!(
|
||||
b.is_nan(),
|
||||
"{} expected NaN warmup at {i}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[wasm_bindgen_test]
|
||||
fn scalar_streaming_matches_batch_broad() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
|
||||
})
|
||||
.collect();
|
||||
let p = prices.as_slice();
|
||||
|
||||
// Moving averages.
|
||||
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
|
||||
|
||||
// Momentum / oscillators.
|
||||
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
|
||||
|
||||
// Volatility / statistics / regression.
|
||||
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
|
||||
|
||||
// Ehlers / cycle.
|
||||
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(
|
||||
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
|
||||
p
|
||||
);
|
||||
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
|
||||
|
||||
// Risk / performance (scalar f64 input).
|
||||
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
}
|
||||
|
||||
// Additional invalid-constructor coverage. These wrap the same fallible core
|
||||
// `new` as the Python / Node bindings, where the equivalent calls are
|
||||
// confirmed to error.
|
||||
#[wasm_bindgen_test]
|
||||
fn additional_invalid_constructors_are_rejected() {
|
||||
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
|
||||
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
|
||||
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
|
||||
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
|
||||
}
|
||||
}
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -31,6 +31,18 @@ pub enum Error {
|
||||
/// A multiplier or factor must be strictly positive.
|
||||
#[error("multiplier must be greater than zero")]
|
||||
NonPositiveMultiplier,
|
||||
|
||||
/// An order-book snapshot whose levels do not satisfy the book invariants
|
||||
/// (e.g. a crossed book, non-finite price, negative size, or mis-sorted
|
||||
/// levels) was provided. Order books are a microstructure input distinct
|
||||
/// from candles and ticks, so they surface as their own variant.
|
||||
#[error("invalid order book: {message}")]
|
||||
InvalidOrderBook { message: &'static str },
|
||||
|
||||
/// A trade whose components do not satisfy the trade invariants (e.g.
|
||||
/// non-finite price or negative size) was provided.
|
||||
#[error("invalid trade: {message}")]
|
||||
InvalidTrade { message: &'static str },
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, wickra_core::Error>`.
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Cointegration — rolling Engle–Granger hedge ratio plus an ADF stationarity test.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Cointegration`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CointegrationOutput {
|
||||
/// Engle–Granger hedge ratio `β`: the rolling OLS slope of `a` on `b`.
|
||||
pub hedge_ratio: f64,
|
||||
/// The current spread (regression residual) `a − (α + β·b)`.
|
||||
pub spread: f64,
|
||||
/// Augmented Dickey–Fuller `t`-statistic on the spread. **More negative**
|
||||
/// means more strongly mean-reverting (cointegrated); compare against the
|
||||
/// usual ADF/MacKinnon critical values (e.g. roughly `−2.9` at 5%). `0`
|
||||
/// when the test is undefined (a degenerate, zero-variance spread).
|
||||
pub adf_stat: f64,
|
||||
}
|
||||
|
||||
/// Rolling cointegration test for a pair of assets (Engle–Granger two-step).
|
||||
///
|
||||
/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you
|
||||
/// prefer). Over the trailing window of `period` pairs the indicator:
|
||||
///
|
||||
/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least
|
||||
/// squares of `a` on `b`, and forms the **spread** `eₜ = aₜ − (α + β·bₜ)`;
|
||||
/// 2. runs an **augmented Dickey–Fuller** test (no constant, no trend, with
|
||||
/// `adf_lags` lagged differences) on the spread series and reports its
|
||||
/// `t`-statistic.
|
||||
///
|
||||
/// A strongly negative ADF statistic means the spread reverts to its mean — the
|
||||
/// pair is cointegrated and the spread is tradeable. A statistic near zero
|
||||
/// means the spread wanders like a random walk (no cointegration). This is the
|
||||
/// classic pairs-trading screen: `β` tells you the hedge size, the spread is
|
||||
/// what you trade, and the ADF statistic tells you whether it is worth trading.
|
||||
///
|
||||
/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from
|
||||
/// running sums, while the spread series and the small ADF regression are
|
||||
/// recomputed over the window — both bounded by the fixed parameters, not the
|
||||
/// series length.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Cointegration, Indicator};
|
||||
///
|
||||
/// let mut c = Cointegration::new(30, 1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..60 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated.
|
||||
/// let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin();
|
||||
/// last = c.update((a, b));
|
||||
/// }
|
||||
/// let out = last.unwrap();
|
||||
/// assert!((out.hedge_ratio - 2.0).abs() < 0.1);
|
||||
/// assert!(out.adf_stat < 0.0); // mean-reverting spread
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cointegration {
|
||||
period: usize,
|
||||
adf_lags: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl Cointegration {
|
||||
/// Construct a new rolling cointegration test.
|
||||
///
|
||||
/// `period` is the look-back window; `adf_lags` is the number of lagged
|
||||
/// differences in the augmented Dickey–Fuller regression (`0` is the plain
|
||||
/// Dickey–Fuller test).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is
|
||||
/// the smallest window that leaves the ADF regression at least one degree
|
||||
/// of freedom.
|
||||
pub fn new(period: usize, adf_lags: usize) -> Result<Self> {
|
||||
let min_period = 2 * adf_lags + 4;
|
||||
if period < min_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "cointegration needs period >= 2*adf_lags + 4",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
adf_lags,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look-back window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Number of lagged differences in the ADF regression.
|
||||
pub const fn adf_lags(&self) -> usize {
|
||||
self.adf_lags
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Cointegration {
|
||||
/// `(a, b)` price pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = CointegrationOutput;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_a -= oa;
|
||||
self.sum_b -= ob;
|
||||
self.sum_bb -= ob * ob;
|
||||
self.sum_ab -= oa * ob;
|
||||
}
|
||||
self.window.push_back((a, b));
|
||||
self.sum_a += a;
|
||||
self.sum_b += b;
|
||||
self.sum_bb += b * b;
|
||||
self.sum_ab += a * b;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_a = self.sum_a / n;
|
||||
let mean_b = self.sum_b / n;
|
||||
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
|
||||
let (hedge_ratio, intercept) = if var_b == 0.0 {
|
||||
// A flat `b` window has no defined slope; fall back to a level shift.
|
||||
(0.0, mean_a)
|
||||
} else {
|
||||
let cov = self.sum_ab / n - mean_a * mean_b;
|
||||
let beta = cov / var_b;
|
||||
(beta, mean_a - beta * mean_b)
|
||||
};
|
||||
// Build the spread (residual) series over the window, oldest → newest.
|
||||
let spreads: Vec<f64> = self
|
||||
.window
|
||||
.iter()
|
||||
.map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi))
|
||||
.collect();
|
||||
let spread = *spreads.last().expect("window is full");
|
||||
let adf_stat = adf_no_constant(&spreads, self.adf_lags);
|
||||
Some(CointegrationOutput {
|
||||
hedge_ratio,
|
||||
spread,
|
||||
adf_stat,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_a = 0.0;
|
||||
self.sum_b = 0.0;
|
||||
self.sum_bb = 0.0;
|
||||
self.sum_ab = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Cointegration"
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian
|
||||
/// elimination, returning `None` if the matrix is (numerically) singular.
|
||||
///
|
||||
/// `mat` is row-major and consumed; `rhs` is the right-hand side.
|
||||
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let dim = rhs.len();
|
||||
for col in 0..dim {
|
||||
let pivot = mat[col][col];
|
||||
if pivot.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let pivot_row = mat[col].clone();
|
||||
for row in (col + 1)..dim {
|
||||
let factor = mat[row][col] / pivot;
|
||||
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
|
||||
*cell -= factor * above;
|
||||
}
|
||||
rhs[row] -= factor * rhs[col];
|
||||
}
|
||||
}
|
||||
let mut sol = vec![0.0; dim];
|
||||
for row in (0..dim).rev() {
|
||||
let known: f64 = mat[row]
|
||||
.iter()
|
||||
.zip(&sol)
|
||||
.skip(row + 1)
|
||||
.map(|(coeff, value)| coeff * value)
|
||||
.sum();
|
||||
sol[row] = (rhs[row] - known) / mat[row][row];
|
||||
}
|
||||
Some(sol)
|
||||
}
|
||||
|
||||
/// Augmented Dickey–Fuller `t`-statistic on `series`, with `lags` lagged
|
||||
/// differences and **no** constant or trend term (the Engle–Granger residual
|
||||
/// form). Returns `0.0` when the regression is degenerate.
|
||||
///
|
||||
/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic
|
||||
/// is `ρ̂ / se(ρ̂)`.
|
||||
fn adf_no_constant(series: &[f64], lags: usize) -> f64 {
|
||||
let len = series.len();
|
||||
let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences
|
||||
let first = lags + 1; // first usable observation index
|
||||
if len <= first {
|
||||
return 0.0;
|
||||
}
|
||||
let num_obs = len - first;
|
||||
if num_obs <= num_reg {
|
||||
return 0.0; // need at least one residual degree of freedom
|
||||
}
|
||||
let regressors = |idx: usize| -> Vec<f64> {
|
||||
let mut row = vec![0.0; num_reg];
|
||||
row[0] = series[idx - 1];
|
||||
for lag in 1..=lags {
|
||||
row[lag] = series[idx - lag] - series[idx - lag - 1];
|
||||
}
|
||||
row
|
||||
};
|
||||
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
|
||||
let mut xty = vec![0.0; num_reg];
|
||||
for idx in first..len {
|
||||
let diff = series[idx] - series[idx - 1];
|
||||
let row = regressors(idx);
|
||||
for (ri, &left) in row.iter().enumerate() {
|
||||
xty[ri] += left * diff;
|
||||
for (ci, &right) in row.iter().enumerate() {
|
||||
xtx[ri][ci] += left * right;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(theta) = solve(xtx.clone(), xty) else {
|
||||
return 0.0;
|
||||
};
|
||||
let rho = theta[0];
|
||||
let mut rss = 0.0;
|
||||
for idx in first..len {
|
||||
let diff = series[idx] - series[idx - 1];
|
||||
let pred: f64 = regressors(idx)
|
||||
.iter()
|
||||
.zip(&theta)
|
||||
.map(|(coeff, value)| coeff * value)
|
||||
.sum();
|
||||
let resid = diff - pred;
|
||||
rss += resid * resid;
|
||||
}
|
||||
let dof = (num_obs - num_reg) as f64;
|
||||
let sigma2 = rss / dof;
|
||||
// (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first
|
||||
// solve already factored successfully, so this one cannot be singular.
|
||||
let mut unit = vec![0.0; num_reg];
|
||||
unit[0] = 1.0;
|
||||
let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded");
|
||||
let var_rho = sigma2 * inverse[0];
|
||||
if var_rho <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
rho / var_rho.sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_too_small_period() {
|
||||
// period must be >= 2*lags + 4.
|
||||
assert!(Cointegration::new(3, 0).is_err()); // needs >= 4
|
||||
assert!(Cointegration::new(4, 0).is_ok());
|
||||
assert!(Cointegration::new(5, 1).is_err()); // needs >= 6
|
||||
assert!(Cointegration::new(6, 1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = Cointegration::new(30, 2).unwrap();
|
||||
assert_eq!(c.period(), 30);
|
||||
assert_eq!(c.adf_lags(), 2);
|
||||
assert_eq!(c.warmup_period(), 30);
|
||||
assert_eq!(c.name(), "Cointegration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adf_guards_and_degenerate_spread() {
|
||||
// Series too short for any observation ⇒ 0.
|
||||
assert_eq!(adf_no_constant(&[1.0], 1), 0.0);
|
||||
// Long enough but too few degrees of freedom ⇒ 0.
|
||||
assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0);
|
||||
// A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly,
|
||||
// so the residual variance — and hence the t-statistic — is 0.
|
||||
let geom: Vec<f64> = (0..8).map(|t| 0.5_f64.powi(t)).collect();
|
||||
assert_eq!(adf_no_constant(&geom, 0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_hedge_ratio() {
|
||||
// a = 2·b + 5 + small wobble ⇒ β ≈ 2.
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let out = Cointegration::new(30, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(out.hedge_ratio - 2.0).abs() < 0.1,
|
||||
"beta {}",
|
||||
out.hedge_ratio
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_spread_is_strongly_negative() {
|
||||
// A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF.
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|t| {
|
||||
let b = 50.0 + 0.5 * f64::from(t);
|
||||
let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let out = Cointegration::new(40, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_cointegration_has_zero_spread_and_defined_ratio() {
|
||||
// a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(2.0 * b + 5.0, b)
|
||||
})
|
||||
.collect();
|
||||
let out = Cointegration::new(20, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6);
|
||||
assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_b_falls_back_to_level() {
|
||||
// Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a − mean(a).
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0))
|
||||
.collect();
|
||||
let out = Cointegration::new(10, 0)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_dickey_fuller_lags_zero() {
|
||||
// Exercise the lags = 0 path (1×1 ADF system).
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| {
|
||||
let b = 20.0 + 0.4 * f64::from(t);
|
||||
let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let out = Cointegration::new(20, 0)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!((out.hedge_ratio - 1.5).abs() < 0.1);
|
||||
assert!(out.adf_stat < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = Cointegration::new(10, 1).unwrap();
|
||||
for t in 0..20 {
|
||||
let b = 100.0 + f64::from(t);
|
||||
c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b));
|
||||
}
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = Cointegration::new(25, 2).unwrap().batch(&pairs);
|
||||
let mut c = Cointegration::new(25, 2).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Cumulative Volume Delta — running sum of signed trade volume.
|
||||
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cumulative Volume Delta (CVD) — the running sum of [signed volume].
|
||||
///
|
||||
/// ```text
|
||||
/// CVDₜ = CVDₜ₋₁ + sizeₜ · (+1 if buy, −1 if sell)
|
||||
/// ```
|
||||
///
|
||||
/// CVD is an unbounded running total: a rising line signals net buying pressure
|
||||
/// over the session, a falling line net selling. Divergence between CVD and
|
||||
/// price is a classic absorption / exhaustion signal. Call [`reset`] at the
|
||||
/// start of each session to re-anchor the cumulative total at zero.
|
||||
///
|
||||
/// `Input = Trade`, `Output = f64`. Ready after the first trade.
|
||||
///
|
||||
/// [signed volume]: crate::SignedVolume
|
||||
/// [`reset`]: crate::Indicator::reset
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CumulativeVolumeDelta, Indicator, Side, Trade};
|
||||
///
|
||||
/// let mut cvd = CumulativeVolumeDelta::new();
|
||||
/// assert_eq!(cvd.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), Some(5.0));
|
||||
/// assert_eq!(cvd.update(Trade::new(100.0, 2.0, Side::Sell, 1).unwrap()), Some(3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CumulativeVolumeDelta {
|
||||
cumulative: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl CumulativeVolumeDelta {
|
||||
/// Construct a new CVD indicator with a zero running total.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
cumulative: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CumulativeVolumeDelta {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
self.cumulative += trade.size * trade.side.sign();
|
||||
Some(self.cumulative)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cumulative = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CumulativeVolumeDelta"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn trade(size: f64, side: Side, ts: i64) -> Trade {
|
||||
Trade::new(100.0, size, side, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cvd = CumulativeVolumeDelta::new();
|
||||
assert_eq!(cvd.name(), "CumulativeVolumeDelta");
|
||||
assert_eq!(cvd.warmup_period(), 1);
|
||||
assert!(!cvd.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_signed_volume() {
|
||||
let mut cvd = CumulativeVolumeDelta::new();
|
||||
assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0));
|
||||
assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0));
|
||||
assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0));
|
||||
assert!(cvd.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| {
|
||||
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
|
||||
trade(1.0 + (i % 4) as f64, side, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = CumulativeVolumeDelta::new();
|
||||
let mut b = CumulativeVolumeDelta::new();
|
||||
assert_eq!(
|
||||
a.batch(&trades),
|
||||
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_re_anchors_at_zero() {
|
||||
let mut cvd = CumulativeVolumeDelta::new();
|
||||
cvd.update(trade(5.0, Side::Buy, 0));
|
||||
cvd.reset();
|
||||
assert!(!cvd.is_ready());
|
||||
// After reset the running total starts again from zero.
|
||||
assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0));
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,44 @@ use crate::traits::Indicator;
|
||||
/// doji = body <= body_threshold * range
|
||||
/// ```
|
||||
///
|
||||
/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is
|
||||
/// directionless — no `−1.0` is emitted. Pattern-shape check only — no trend
|
||||
/// filter is applied; combine with a trend indicator for actionable signals.
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// By default the output is `+1.0` when a Doji is detected and `0.0`
|
||||
/// otherwise — a direction-less detection flag. For a drop-in machine-learning
|
||||
/// feature where every candlestick pattern shares the same sign convention
|
||||
/// (`+1.0` bullish, `−1.0` bearish, `0.0` none), switch the detector into
|
||||
/// signed mode with [`Doji::signed`]. A detected Doji is then classified by
|
||||
/// where its (negligible) body sits within the bar's range:
|
||||
///
|
||||
/// ```text
|
||||
/// pos = (0.5 * (open + close) − low) / (high − low)
|
||||
/// pos > 2/3 -> +1.0 dragonfly (long lower shadow, bullish)
|
||||
/// pos < 1/3 -> −1.0 gravestone (long upper shadow, bearish)
|
||||
/// else -> 0.0 long-legged / standard (neutral)
|
||||
/// ```
|
||||
///
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Doji, Indicator};
|
||||
///
|
||||
/// // Default: direction-less detection flag.
|
||||
/// let mut indicator = Doji::default();
|
||||
/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
///
|
||||
/// // Signed: a dragonfly Doji (body at the top, long lower shadow) is bullish.
|
||||
/// let mut signed = Doji::new().signed();
|
||||
/// let dragonfly = Candle::new(10.0, 10.05, 6.0, 10.0, 1.0, 0).unwrap();
|
||||
/// assert_eq!(signed.update(dragonfly), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Doji {
|
||||
body_threshold: f64,
|
||||
signed: bool,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
@@ -46,6 +68,7 @@ impl Doji {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
body_threshold: 0.1,
|
||||
signed: false,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
@@ -61,14 +84,32 @@ impl Doji {
|
||||
}
|
||||
Ok(Self {
|
||||
body_threshold,
|
||||
signed: false,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch to the signed dragonfly / gravestone encoding (consuming builder).
|
||||
///
|
||||
/// In signed mode a detected Doji emits `+1.0` (dragonfly, bullish),
|
||||
/// `−1.0` (gravestone, bearish) or `0.0` (long-legged / neutral) instead of
|
||||
/// the default direction-less `+1.0` detection flag. See the type-level
|
||||
/// docs for the exact classification rule.
|
||||
#[must_use]
|
||||
pub fn signed(mut self) -> Self {
|
||||
self.signed = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configured body / range threshold.
|
||||
pub fn body_threshold(&self) -> f64 {
|
||||
self.body_threshold
|
||||
}
|
||||
|
||||
/// Whether this detector emits the signed dragonfly / gravestone encoding.
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.signed
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Doji {
|
||||
@@ -82,11 +123,23 @@ impl Indicator for Doji {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body = (candle.close - candle.open).abs();
|
||||
Some(if body <= self.body_threshold * range {
|
||||
1.0
|
||||
if body > self.body_threshold * range {
|
||||
return Some(0.0);
|
||||
}
|
||||
if !self.signed {
|
||||
return Some(1.0);
|
||||
}
|
||||
// Signed mode: classify the Doji by where its (negligible) body sits
|
||||
// within the high–low range.
|
||||
let body_mid = 0.5 * (candle.open + candle.close);
|
||||
let pos = (body_mid - candle.low) / range;
|
||||
if pos > 2.0 / 3.0 {
|
||||
Some(1.0)
|
||||
} else if pos < 1.0 / 3.0 {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
@@ -134,6 +187,7 @@ mod tests {
|
||||
assert_eq!(d.name(), "Doji");
|
||||
assert_eq!(d.warmup_period(), 1);
|
||||
assert!(!d.is_ready());
|
||||
assert!(!d.is_signed());
|
||||
assert!((d.body_threshold() - 0.1).abs() < 1e-12);
|
||||
}
|
||||
|
||||
@@ -182,4 +236,81 @@ mod tests {
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_accessor_and_builder() {
|
||||
let d = Doji::new().signed();
|
||||
assert!(d.is_signed());
|
||||
// The consuming builder composes with `with_threshold`.
|
||||
let t = Doji::with_threshold(0.05).unwrap().signed();
|
||||
assert!(t.is_signed());
|
||||
assert!((t.body_threshold() - 0.05).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_dragonfly_is_plus_one() {
|
||||
// Body at the top of the range, long lower shadow -> bullish.
|
||||
let mut d = Doji::new().signed();
|
||||
assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_gravestone_is_minus_one() {
|
||||
// Body at the bottom of the range, long upper shadow -> bearish.
|
||||
let mut d = Doji::new().signed();
|
||||
assert_eq!(d.update(c(10.0, 14.0, 9.95, 10.0, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_long_legged_is_zero() {
|
||||
// Body centred, symmetric shadows -> neutral.
|
||||
let mut d = Doji::new().signed();
|
||||
assert_eq!(d.update(c(10.0, 12.0, 8.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_non_doji_is_zero() {
|
||||
// A large body is not a Doji at all -> 0 regardless of position.
|
||||
let mut d = Doji::new().signed();
|
||||
assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_zero_range_is_zero() {
|
||||
let mut d = Doji::new().signed();
|
||||
assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
// Alternate dragonfly / gravestone / centred Doji shapes.
|
||||
match i % 3 {
|
||||
0 => c(base, base + 0.05, base - 4.0, base, i),
|
||||
1 => c(base, base + 4.0, base - 0.05, base, i),
|
||||
_ => c(base, base + 2.0, base - 2.0, base, i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = Doji::new().signed();
|
||||
let mut b = Doji::new().signed();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_survives_reset() {
|
||||
let mut d = Doji::new().signed();
|
||||
d.update(c(10.0, 10.05, 6.0, 10.0, 0));
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
// `reset` clears only the streaming state, not the signed configuration.
|
||||
assert!(d.is_signed());
|
||||
assert_eq!(d.update(c(10.0, 10.05, 6.0, 10.0, 1)), Some(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
|
||||
/// body exists to engulf. Pattern-shape check only — no trend filter is
|
||||
/// applied; combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// A Hammer is bullish by definition, so under the uniform candlestick sign
|
||||
/// convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits `+1.0`
|
||||
/// when the shape matches and `0.0` otherwise — it never emits `−1.0`. The
|
||||
/// same geometry read at the top of an uptrend is the bearish `HangingMan`,
|
||||
/// which carries the opposite sign.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// A Hanging Man is bearish by definition, so under the uniform candlestick
|
||||
/// sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits
|
||||
/// `−1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
|
||||
/// The same geometry read at the bottom of a downtrend is the bullish
|
||||
/// `Hammer`, which carries the opposite sign.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -23,6 +23,13 @@ use crate::traits::Indicator;
|
||||
/// no trend filter is applied; combine with a trend indicator for actionable
|
||||
/// signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// An Inverted Hammer is bullish by definition, so under the uniform
|
||||
/// candlestick sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it
|
||||
/// emits `+1.0` when the shape matches and `0.0` otherwise — it never emits
|
||||
/// `−1.0`. The same geometry read at the top of an uptrend is the bearish
|
||||
/// `ShootingStar`, which carries the opposite sign.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Lead–Lag Cross-Correlation — which of two assets leads the other, and by how much.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`LeadLagCrossCorrelation`]: the lead/lag offset and its correlation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LeadLagCrossCorrelationOutput {
|
||||
/// The offset `k ∈ [−max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`.
|
||||
///
|
||||
/// A **positive** lag means `a` leads `b` by `lag` samples (a's pattern
|
||||
/// shows up in `b` that many steps later); a **negative** lag means `b`
|
||||
/// leads `a`; `0` means the two are most correlated contemporaneously.
|
||||
pub lag: i64,
|
||||
/// The (signed) Pearson correlation at that lag, in `[−1, +1]`.
|
||||
pub correlation: f64,
|
||||
}
|
||||
|
||||
/// Rolling lead–lag cross-correlation between two synchronised series.
|
||||
///
|
||||
/// Each `update` receives one `(a, b)` pair. The indicator keeps the most
|
||||
/// recent `window + 2·max_lag` samples of each series and, once full, reports
|
||||
/// the integer offset `k ∈ [−max_lag, +max_lag]` that maximises the absolute
|
||||
/// Pearson correlation between `a` and a copy of `b` shifted by `k`:
|
||||
///
|
||||
/// ```text
|
||||
/// lag = argmax_k | corr( a[t], b[t+k] ) |
|
||||
/// ```
|
||||
///
|
||||
/// This answers "does BTC lead ETH on this timescale, and by how many bars?".
|
||||
/// A positive lag means `a` leads `b`; a negative lag means `b` leads `a`. The
|
||||
/// reported `correlation` is the signed correlation at that lag, so its sign
|
||||
/// tells you whether the lead relationship is positive or inverse.
|
||||
///
|
||||
/// The comparison is fully causal: `a`'s window is held fixed in the centre of
|
||||
/// the buffer and `b`'s window slides across it, so every lag — positive and
|
||||
/// negative — is evaluated only against data already seen. The candidate lags
|
||||
/// are scanned in order of increasing `|k|`, so ties resolve to the smallest
|
||||
/// absolute offset (lag `0` wins an exact tie).
|
||||
///
|
||||
/// Each `update` is `O(window · max_lag)` — proportional to the fixed
|
||||
/// parameters, not the series length. A flat window in either channel makes a
|
||||
/// correlation undefined; it is reported as `0` rather than `NaN`.
|
||||
///
|
||||
/// Feed raw prices or returns depending on your convention; lead–lag on
|
||||
/// returns is the more common choice for relating two assets.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, LeadLagCrossCorrelation};
|
||||
///
|
||||
/// let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..60 {
|
||||
/// let a = (f64::from(t) * 0.4).sin() + 0.4 * (f64::from(t) * 1.1).sin();
|
||||
/// // `b` is `a` delayed by 3 samples, so `a` leads `b` by 3.
|
||||
/// let b = (f64::from(t - 3) * 0.4).sin() + 0.4 * (f64::from(t - 3) * 1.1).sin();
|
||||
/// last = ll.update((a, b));
|
||||
/// }
|
||||
/// let out = last.unwrap();
|
||||
/// assert_eq!(out.lag, 3);
|
||||
/// assert!(out.correlation > 0.99);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LeadLagCrossCorrelation {
|
||||
window: usize,
|
||||
max_lag: usize,
|
||||
len: usize,
|
||||
a_buf: VecDeque<f64>,
|
||||
b_buf: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl LeadLagCrossCorrelation {
|
||||
/// Construct a new lead–lag cross-correlation.
|
||||
///
|
||||
/// `window` is the number of overlapping points each correlation is
|
||||
/// computed over; `max_lag` is the largest offset (in either direction)
|
||||
/// that is searched.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `window < 2` or `max_lag == 0`.
|
||||
pub fn new(window: usize, max_lag: usize) -> Result<Self> {
|
||||
if window < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "lead-lag cross-correlation needs window >= 2",
|
||||
});
|
||||
}
|
||||
if max_lag == 0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "lead-lag cross-correlation needs max_lag >= 1",
|
||||
});
|
||||
}
|
||||
let len = window + 2 * max_lag;
|
||||
Ok(Self {
|
||||
window,
|
||||
max_lag,
|
||||
len,
|
||||
a_buf: VecDeque::with_capacity(len),
|
||||
b_buf: VecDeque::with_capacity(len),
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of overlapping points per correlation.
|
||||
pub const fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Largest offset searched in either direction.
|
||||
pub const fn max_lag(&self) -> usize {
|
||||
self.max_lag
|
||||
}
|
||||
|
||||
/// Pearson correlation between `a[a_start .. a_start+window]` and
|
||||
/// `b[b_start .. b_start+window]`, clamped to `[−1, 1]`. Returns `0` when
|
||||
/// either window has zero variance.
|
||||
fn corr_at(&self, a_start: usize, b_start: usize) -> f64 {
|
||||
let n = self.window as f64;
|
||||
let mut sa = 0.0;
|
||||
let mut sb = 0.0;
|
||||
let mut saa = 0.0;
|
||||
let mut sbb = 0.0;
|
||||
let mut sab = 0.0;
|
||||
for j in 0..self.window {
|
||||
let x = self.a_buf[a_start + j];
|
||||
let y = self.b_buf[b_start + j];
|
||||
sa += x;
|
||||
sb += y;
|
||||
saa += x * x;
|
||||
sbb += y * y;
|
||||
sab += x * y;
|
||||
}
|
||||
let mean_a = sa / n;
|
||||
let mean_b = sb / n;
|
||||
let var_a = (saa / n - mean_a * mean_a).max(0.0);
|
||||
let var_b = (sbb / n - mean_b * mean_b).max(0.0);
|
||||
let denom = (var_a * var_b).sqrt();
|
||||
if denom == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let cov = sab / n - mean_a * mean_b;
|
||||
(cov / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for LeadLagCrossCorrelation {
|
||||
/// `(a, b)` pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = LeadLagCrossCorrelationOutput;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<LeadLagCrossCorrelationOutput> {
|
||||
let (a, b) = input;
|
||||
if self.a_buf.len() == self.len {
|
||||
self.a_buf.pop_front();
|
||||
self.b_buf.pop_front();
|
||||
}
|
||||
self.a_buf.push_back(a);
|
||||
self.b_buf.push_back(b);
|
||||
if self.a_buf.len() < self.len {
|
||||
return None;
|
||||
}
|
||||
// `a`'s window sits in the centre; `b`'s window slides ±max_lag.
|
||||
let a_start = self.max_lag;
|
||||
// Start at lag 0, then widen outward so ties prefer the smallest |lag|.
|
||||
// The lag is tracked as a signed counter incremented by ±1, so no
|
||||
// unsigned index is ever cast to a signed type.
|
||||
let mut best_lag: i64 = 0;
|
||||
let mut best_corr = self.corr_at(a_start, a_start);
|
||||
let mut best_abs = best_corr.abs();
|
||||
let mut lag_neg: i64 = 0;
|
||||
let mut lag_pos: i64 = 0;
|
||||
for d in 1..=self.max_lag {
|
||||
lag_neg -= 1;
|
||||
lag_pos += 1;
|
||||
// Negative lag: b shifted earlier (b leads a).
|
||||
let c_neg = self.corr_at(a_start, a_start - d);
|
||||
if c_neg.abs() > best_abs {
|
||||
best_abs = c_neg.abs();
|
||||
best_corr = c_neg;
|
||||
best_lag = lag_neg;
|
||||
}
|
||||
// Positive lag: b shifted later (a leads b).
|
||||
let c_pos = self.corr_at(a_start, a_start + d);
|
||||
if c_pos.abs() > best_abs {
|
||||
best_abs = c_pos.abs();
|
||||
best_corr = c_pos;
|
||||
best_lag = lag_pos;
|
||||
}
|
||||
}
|
||||
Some(LeadLagCrossCorrelationOutput {
|
||||
lag: best_lag,
|
||||
correlation: best_corr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.a_buf.clear();
|
||||
self.b_buf.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.a_buf.len() == self.len
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"LeadLagCrossCorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn signal(t: i64) -> f64 {
|
||||
let t = t as f64;
|
||||
(t * 0.4).sin() + 0.4 * (t * 1.1).sin() + 0.2 * (t * 0.27).cos()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(LeadLagCrossCorrelation::new(1, 5).is_err());
|
||||
assert!(LeadLagCrossCorrelation::new(10, 0).is_err());
|
||||
assert!(LeadLagCrossCorrelation::new(10, 5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
|
||||
assert_eq!(ll.window(), 10);
|
||||
assert_eq!(ll.max_lag(), 4);
|
||||
// len = window + 2*max_lag = 10 + 8 = 18.
|
||||
assert_eq!(ll.warmup_period(), 18);
|
||||
assert_eq!(ll.name(), "LeadLagCrossCorrelation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_positive_lead() {
|
||||
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
|
||||
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t - 3))).collect();
|
||||
let out = LeadLagCrossCorrelation::new(12, 5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.lag, 3);
|
||||
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_negative_lead() {
|
||||
// a is a delayed copy of b ⇒ b leads a ⇒ lag = −2.
|
||||
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t - 2), signal(t))).collect();
|
||||
let out = LeadLagCrossCorrelation::new(12, 5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.lag, -2);
|
||||
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contemporaneous_is_lag_zero() {
|
||||
// Identical streams correlate best at lag 0 with correlation 1.
|
||||
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t))).collect();
|
||||
let out = LeadLagCrossCorrelation::new(12, 5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.lag, 0);
|
||||
assert_relative_eq!(out.correlation, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_channel_yields_zero_correlation() {
|
||||
// A constant `a` has no variance ⇒ every correlation is 0 ⇒ lag 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, signal(t))).collect();
|
||||
let out = LeadLagCrossCorrelation::new(10, 4)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.lag, 0);
|
||||
assert_relative_eq!(out.correlation, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
|
||||
for t in 0..40 {
|
||||
ll.update((signal(t), signal(t - 2)));
|
||||
}
|
||||
assert!(ll.is_ready());
|
||||
ll.reset();
|
||||
assert!(!ll.is_ready());
|
||||
assert_eq!(ll.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80).map(|t| (signal(t), signal(t - 1))).collect();
|
||||
let batch = LeadLagCrossCorrelation::new(12, 5).unwrap().batch(&pairs);
|
||||
let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
|
||||
/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each
|
||||
/// side) and must lie in `[0, 1)`.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Microprice — size-weighted fair value of the top of book.
|
||||
|
||||
use crate::microstructure::OrderBook;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Microprice — the size-weighted mid of the top of book.
|
||||
///
|
||||
/// The microprice tilts the mid toward the side that is *more likely to be
|
||||
/// hit*: it weights each touch price by the size resting on the **opposite**
|
||||
/// side, so a heavy ask (sell pressure) pulls the fair value down toward the
|
||||
/// bid, and vice versa:
|
||||
///
|
||||
/// ```text
|
||||
/// microprice = (bidPrice₁·askSize₁ + askPrice₁·bidSize₁) / (bidSize₁ + askSize₁)
|
||||
/// ```
|
||||
///
|
||||
/// When both top sizes are zero the weighting is undefined and the plain mid
|
||||
/// `(bidPrice₁ + askPrice₁) / 2` is returned. An empty book yields `0`.
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
|
||||
/// snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Level, Microprice, OrderBook};
|
||||
///
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(100.0, 1.0).unwrap()],
|
||||
/// vec![Level::new(101.0, 3.0).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut mp = Microprice::new();
|
||||
/// // (100·3 + 101·1) / (1 + 3) = 401 / 4 = 100.25 — pulled toward the bid.
|
||||
/// assert_eq!(mp.update(book), Some(100.25));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Microprice {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Microprice {
|
||||
/// Construct a new microprice indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Microprice {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let total = bid.size + ask.size;
|
||||
if total <= 0.0 {
|
||||
return Some(f64::midpoint(bid.price, ask.price));
|
||||
}
|
||||
Some((bid.price * ask.size + ask.price * bid.size) / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Microprice"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Level;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mp = Microprice::new();
|
||||
assert_eq!(mp.name(), "Microprice");
|
||||
assert_eq!(mp.warmup_period(), 1);
|
||||
assert!(!mp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_toward_thin_side() {
|
||||
let mut mp = Microprice::new();
|
||||
// Heavy ask -> microprice pulled toward bid.
|
||||
assert_eq!(
|
||||
mp.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
|
||||
Some(100.25)
|
||||
);
|
||||
assert!(mp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_top_equals_mid() {
|
||||
let mut mp = Microprice::new();
|
||||
assert_eq!(
|
||||
mp.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
|
||||
Some(100.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_falls_back_to_mid() {
|
||||
let mut mp = Microprice::new();
|
||||
assert_eq!(
|
||||
mp.update(book(&[(100.0, 0.0)], &[(102.0, 0.0)])),
|
||||
Some(101.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_book_is_zero() {
|
||||
let mut mp = Microprice::new();
|
||||
assert_eq!(
|
||||
mp.update(OrderBook::new_unchecked(vec![], vec![])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let ask = 1.0 + f64::from(i % 4);
|
||||
book(&[(100.0, 2.0)], &[(101.0, ask)])
|
||||
})
|
||||
.collect();
|
||||
let mut a = Microprice::new();
|
||||
let mut b = Microprice::new();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mp = Microprice::new();
|
||||
mp.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
|
||||
assert!(mp.is_ready());
|
||||
mp.reset();
|
||||
assert!(!mp.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,11 @@ mod classic_pivots;
|
||||
mod cmf;
|
||||
mod cmo;
|
||||
mod coefficient_of_variation;
|
||||
mod cointegration;
|
||||
mod conditional_value_at_risk;
|
||||
mod connors_rsi;
|
||||
mod coppock;
|
||||
mod cvd;
|
||||
mod cybernetic_cycle;
|
||||
mod decycler;
|
||||
mod decycler_oscillator;
|
||||
@@ -99,6 +101,7 @@ mod kst;
|
||||
mod kurtosis;
|
||||
mod kvo;
|
||||
mod laguerre_rsi;
|
||||
mod lead_lag_cross_correlation;
|
||||
mod linreg;
|
||||
mod linreg_angle;
|
||||
mod linreg_channel;
|
||||
@@ -114,14 +117,20 @@ mod mcginley_dynamic;
|
||||
mod median_absolute_deviation;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
mod microprice;
|
||||
mod mom;
|
||||
mod morning_evening_star;
|
||||
mod natr;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
mod ob_imbalance_top1;
|
||||
mod ob_imbalance_topn;
|
||||
mod obv;
|
||||
mod omega_ratio;
|
||||
mod opening_range;
|
||||
mod pain_index;
|
||||
mod pair_spread_zscore;
|
||||
mod pairwise_beta;
|
||||
mod parkinson;
|
||||
mod pearson_correlation;
|
||||
mod percent_b;
|
||||
@@ -133,8 +142,10 @@ mod ppo;
|
||||
mod profit_factor;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod recovery_factor;
|
||||
mod relative_strength_ab;
|
||||
mod renko_trailing_stop;
|
||||
mod roc;
|
||||
mod rogers_satchell;
|
||||
@@ -145,6 +156,7 @@ mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod sharpe_ratio;
|
||||
mod shooting_star;
|
||||
mod signed_volume;
|
||||
mod sine_wave;
|
||||
mod skewness;
|
||||
mod sma;
|
||||
@@ -181,6 +193,7 @@ mod three_inside;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
mod tii;
|
||||
mod trade_imbalance;
|
||||
mod treynor_ratio;
|
||||
mod trima;
|
||||
mod trix;
|
||||
@@ -257,9 +270,11 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
|
||||
pub use cmf::ChaikinMoneyFlow;
|
||||
pub use cmo::Cmo;
|
||||
pub use coefficient_of_variation::CoefficientOfVariation;
|
||||
pub use cointegration::{Cointegration, CointegrationOutput};
|
||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use cvd::CumulativeVolumeDelta;
|
||||
pub use cybernetic_cycle::CyberneticCycle;
|
||||
pub use decycler::Decycler;
|
||||
pub use decycler_oscillator::DecyclerOscillator;
|
||||
@@ -313,6 +328,7 @@ pub use kst::{Kst, KstOutput};
|
||||
pub use kurtosis::Kurtosis;
|
||||
pub use kvo::Kvo;
|
||||
pub use laguerre_rsi::LaguerreRsi;
|
||||
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
|
||||
pub use linreg::LinearRegression;
|
||||
pub use linreg_angle::LinRegAngle;
|
||||
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
|
||||
@@ -328,14 +344,20 @@ pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_absolute_deviation::MedianAbsoluteDeviation;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
pub use microprice::Microprice;
|
||||
pub use mom::Mom;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use natr::Natr;
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
|
||||
pub use ob_imbalance_topn::OrderBookImbalanceTopN;
|
||||
pub use obv::Obv;
|
||||
pub use omega_ratio::OmegaRatio;
|
||||
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
||||
pub use pain_index::PainIndex;
|
||||
pub use pair_spread_zscore::PairSpreadZScore;
|
||||
pub use pairwise_beta::PairwiseBeta;
|
||||
pub use parkinson::ParkinsonVolatility;
|
||||
pub use pearson_correlation::PearsonCorrelation;
|
||||
pub use percent_b::PercentB;
|
||||
@@ -347,8 +369,10 @@ pub use ppo::Ppo;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
pub use roc::Roc;
|
||||
pub use rogers_satchell::RogersSatchellVolatility;
|
||||
@@ -359,6 +383,7 @@ pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use sharpe_ratio::SharpeRatio;
|
||||
pub use shooting_star::ShootingStar;
|
||||
pub use signed_volume::SignedVolume;
|
||||
pub use sine_wave::SineWave;
|
||||
pub use skewness::Skewness;
|
||||
pub use sma::Sma;
|
||||
@@ -395,6 +420,7 @@ pub use three_inside::ThreeInside;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
pub use tii::Tii;
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use trima::Trima;
|
||||
pub use trix::Trix;
|
||||
@@ -697,6 +723,19 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"ThreeOutside",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Microstructure",
|
||||
&[
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Market Profile",
|
||||
&["ValueArea", "InitialBalance", "OpeningRange"],
|
||||
@@ -751,6 +790,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 214, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 222, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
|
||||
/// trend filter is applied; combine with a trend indicator for actionable
|
||||
/// signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Order-Book Imbalance over the full visible depth.
|
||||
|
||||
use crate::microstructure::OrderBook;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Order-Book Imbalance aggregated over the full visible depth of each side.
|
||||
///
|
||||
/// Sums the resting size of every bid level and every ask level in the
|
||||
/// snapshot and compares them:
|
||||
///
|
||||
/// ```text
|
||||
/// bidDepth = Σ size of all bids
|
||||
/// askDepth = Σ size of all asks
|
||||
/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth)
|
||||
/// ```
|
||||
///
|
||||
/// The output lies in `[−1, +1]`. A book with zero total size yields `0`. Use
|
||||
/// [`crate::OrderBookImbalanceTopN`] to bound the depth to the most relevant
|
||||
/// near-touch levels instead of the full visible book.
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
|
||||
/// snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceFull};
|
||||
///
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
|
||||
/// vec![Level::new(101.0, 0.5).unwrap(), Level::new(102.0, 0.5).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut obi = OrderBookImbalanceFull::new();
|
||||
/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1)
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OrderBookImbalanceFull {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl OrderBookImbalanceFull {
|
||||
/// Construct a new full-depth imbalance indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OrderBookImbalanceFull {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum();
|
||||
let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum();
|
||||
let total = bid_depth + ask_depth;
|
||||
if total <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((bid_depth - ask_depth) / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OrderBookImbalanceFull"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Level;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let obi = OrderBookImbalanceFull::new();
|
||||
assert_eq!(obi.name(), "OrderBookImbalanceFull");
|
||||
assert_eq!(obi.warmup_period(), 1);
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_full_depth() {
|
||||
let mut obi = OrderBookImbalanceFull::new();
|
||||
let b = book(&[(100.0, 2.0), (99.0, 2.0)], &[(101.0, 1.0), (102.0, 1.0)]);
|
||||
// bidDepth 4, askDepth 2 -> (4 - 2) / 6 = 1/3.
|
||||
assert_eq!(obi.update(b), Some(1.0 / 3.0));
|
||||
assert!(obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_heavy_full_depth_is_negative() {
|
||||
let mut obi = OrderBookImbalanceFull::new();
|
||||
let b = book(&[(100.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]);
|
||||
// (1 - 3) / 4 = -0.5.
|
||||
assert_eq!(obi.update(b), Some(-0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_is_zero() {
|
||||
let mut obi = OrderBookImbalanceFull::new();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let bid = 1.0 + f64::from(i % 3);
|
||||
book(&[(100.0, bid), (99.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)])
|
||||
})
|
||||
.collect();
|
||||
let mut a = OrderBookImbalanceFull::new();
|
||||
let mut b = OrderBookImbalanceFull::new();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut obi = OrderBookImbalanceFull::new();
|
||||
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
|
||||
assert!(obi.is_ready());
|
||||
obi.reset();
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Order-Book Imbalance at the top of book.
|
||||
|
||||
use crate::microstructure::OrderBook;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Order-Book Imbalance (top-of-book).
|
||||
///
|
||||
/// Measures the pressure between the best bid and best ask by comparing their
|
||||
/// resting sizes:
|
||||
///
|
||||
/// ```text
|
||||
/// imbalance = (bidSize₁ − askSize₁) / (bidSize₁ + askSize₁)
|
||||
/// ```
|
||||
///
|
||||
/// The output lies in `[−1, +1]`: `+1` means all size sits on the bid (buy
|
||||
/// pressure), `−1` means all size sits on the ask (sell pressure), `0` means a
|
||||
/// balanced top of book. A book with zero size on both top levels yields `0`.
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. The indicator is stateless and ready
|
||||
/// after the first snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTop1};
|
||||
///
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(100.0, 3.0).unwrap()],
|
||||
/// vec![Level::new(101.0, 1.0).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut obi = OrderBookImbalanceTop1::new();
|
||||
/// assert_eq!(obi.update(book), Some(0.5)); // (3 − 1) / (3 + 1)
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OrderBookImbalanceTop1 {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl OrderBookImbalanceTop1 {
|
||||
/// Construct a new top-of-book imbalance indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OrderBookImbalanceTop1 {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let total = bid.size + ask.size;
|
||||
if total <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((bid.size - ask.size) / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OrderBookImbalanceTop1"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Level;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(obi.name(), "OrderBookImbalanceTop1");
|
||||
assert_eq!(obi.warmup_period(), 1);
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_top_is_zero() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
|
||||
Some(0.0)
|
||||
);
|
||||
assert!(obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bid_heavy_is_positive() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
|
||||
Some(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_heavy_is_negative() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
|
||||
Some(-0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_top_is_zero() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_book_is_zero() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
obi.update(OrderBook::new_unchecked(vec![], vec![])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let bid = 1.0 + f64::from(i % 5);
|
||||
book(&[(100.0, bid)], &[(101.0, 2.0)])
|
||||
})
|
||||
.collect();
|
||||
let mut a = OrderBookImbalanceTop1::new();
|
||||
let mut b = OrderBookImbalanceTop1::new();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut obi = OrderBookImbalanceTop1::new();
|
||||
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
|
||||
assert!(obi.is_ready());
|
||||
obi.reset();
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Order-Book Imbalance over the top-N levels.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::OrderBook;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Order-Book Imbalance aggregated over the top-N levels of each side.
|
||||
///
|
||||
/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it
|
||||
/// sums the resting size of the best `levels` bids and the best `levels` asks
|
||||
/// and compares them:
|
||||
///
|
||||
/// ```text
|
||||
/// bidDepth = Σ size of the best `levels` bids
|
||||
/// askDepth = Σ size of the best `levels` asks
|
||||
/// imbalance = (bidDepth − askDepth) / (bidDepth + askDepth)
|
||||
/// ```
|
||||
///
|
||||
/// If a side has fewer than `levels` levels, all available levels are summed.
|
||||
/// The output lies in `[−1, +1]`; a book with zero size across the summed
|
||||
/// levels yields `0`.
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
|
||||
/// snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN};
|
||||
///
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
|
||||
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
/// assert_eq!(obi.update(book), Some(0.2)); // (3 − 2) / (3 + 2)
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderBookImbalanceTopN {
|
||||
levels: usize,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl OrderBookImbalanceTopN {
|
||||
/// Construct a top-N imbalance indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `levels` is zero.
|
||||
pub fn new(levels: usize) -> Result<Self> {
|
||||
if levels == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
levels,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The configured number of levels summed per side.
|
||||
pub fn levels(&self) -> usize {
|
||||
self.levels
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OrderBookImbalanceTopN {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum();
|
||||
let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum();
|
||||
let total = bid_depth + ask_depth;
|
||||
if total <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((bid_depth - ask_depth) / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OrderBookImbalanceTopN"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Level;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_levels() {
|
||||
assert!(matches!(
|
||||
OrderBookImbalanceTopN::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let obi = OrderBookImbalanceTopN::new(3).unwrap();
|
||||
assert_eq!(obi.name(), "OrderBookImbalanceTopN");
|
||||
assert_eq!(obi.warmup_period(), 1);
|
||||
assert_eq!(obi.levels(), 3);
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_top_two_levels() {
|
||||
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]);
|
||||
// bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
|
||||
assert_eq!(obi.update(b), Some(0.2));
|
||||
assert!(obi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_at_available_depth() {
|
||||
// Only one level per side, N = 5 -> uses what exists.
|
||||
let mut obi = OrderBookImbalanceTopN::new(5).unwrap();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
|
||||
Some(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_is_zero() {
|
||||
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
assert_eq!(
|
||||
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let ask = 1.0 + f64::from(i % 4);
|
||||
book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)])
|
||||
})
|
||||
.collect();
|
||||
let mut a = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
let mut b = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
|
||||
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
|
||||
assert!(obi.is_ready());
|
||||
obi.reset();
|
||||
assert!(!obi.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Z-score of the log-spread `ln(a) − β·ln(b)` between two assets.
|
||||
///
|
||||
/// This is the canonical mean-reversion / statistical-arbitrage signal for a
|
||||
/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the
|
||||
/// indicator does two things:
|
||||
///
|
||||
/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of
|
||||
/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the
|
||||
/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the
|
||||
/// residual against the origin, `s = ln(a) − β·ln(b)`.
|
||||
/// 2. **Standardisation.** The spread is then z-scored over the trailing
|
||||
/// `z_period` spreads: `z = (s − mean_s) / std_s`.
|
||||
///
|
||||
/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a
|
||||
/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means
|
||||
/// the pair is at its typical relationship. The two windows are independent:
|
||||
/// `beta_period` controls how much history the hedge ratio adapts over, and
|
||||
/// `z_period` controls the look-back for the mean and dispersion of the spread.
|
||||
///
|
||||
/// Each `update` is O(1): five running sums maintain the rolling OLS and two
|
||||
/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has
|
||||
/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`,
|
||||
/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion)
|
||||
/// yields a z-score of `0` rather than `NaN`.
|
||||
///
|
||||
/// Prices must be strictly positive and finite for the logarithm to be
|
||||
/// defined; a non-positive or non-finite price is skipped (it does not enter
|
||||
/// either window), exactly as a real feed would discard a bad tick.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PairSpreadZScore};
|
||||
///
|
||||
/// let mut zs = PairSpreadZScore::new(2, 2).unwrap();
|
||||
/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with
|
||||
/// // a 2-sample z-window the z-score collapses to the sign of the last move.
|
||||
/// let mut last = None;
|
||||
/// for a in [100.0, 100.0, 110.0, 120.0] {
|
||||
/// last = zs.update((a, 100.0));
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairSpreadZScore {
|
||||
beta_period: usize,
|
||||
z_period: usize,
|
||||
// Rolling OLS of y = ln(a) on x = ln(b).
|
||||
reg: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xx: f64,
|
||||
sum_xy: f64,
|
||||
// Rolling mean/variance of the spread.
|
||||
spreads: VecDeque<f64>,
|
||||
sum_s: f64,
|
||||
sum_ss: f64,
|
||||
}
|
||||
|
||||
impl PairSpreadZScore {
|
||||
/// Construct a new pair spread z-score.
|
||||
///
|
||||
/// `beta_period` is the look-back for the rolling hedge ratio; `z_period`
|
||||
/// is the look-back for standardising the spread.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if either period is below `2`
|
||||
/// (variance needs at least two points).
|
||||
pub fn new(beta_period: usize, z_period: usize) -> Result<Self> {
|
||||
if beta_period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "pair spread z-score needs beta_period >= 2",
|
||||
});
|
||||
}
|
||||
if z_period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "pair spread z-score needs z_period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
beta_period,
|
||||
z_period,
|
||||
reg: VecDeque::with_capacity(beta_period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xx: 0.0,
|
||||
sum_xy: 0.0,
|
||||
spreads: VecDeque::with_capacity(z_period),
|
||||
sum_s: 0.0,
|
||||
sum_ss: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look-back of the rolling hedge-ratio regression.
|
||||
pub const fn beta_period(&self) -> usize {
|
||||
self.beta_period
|
||||
}
|
||||
|
||||
/// Look-back of the rolling spread standardisation.
|
||||
pub const fn z_period(&self) -> usize {
|
||||
self.z_period
|
||||
}
|
||||
|
||||
/// The current hedge ratio `β`, or `None` while the regression is warming
|
||||
/// up. A flat `ln(b)` window reports `0`.
|
||||
fn hedge_ratio(&self) -> Option<f64> {
|
||||
if self.reg.len() < self.beta_period {
|
||||
return None;
|
||||
}
|
||||
let n = self.beta_period as f64;
|
||||
let mean_x = self.sum_x / n;
|
||||
let mean_y = self.sum_y / n;
|
||||
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
|
||||
if var_x == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let cov = self.sum_xy / n - mean_x * mean_y;
|
||||
Some(cov / var_x)
|
||||
}
|
||||
|
||||
fn push_spread(&mut self, s: f64) -> Option<f64> {
|
||||
if self.spreads.len() == self.z_period {
|
||||
let old = self.spreads.pop_front().expect("non-empty");
|
||||
self.sum_s -= old;
|
||||
self.sum_ss -= old * old;
|
||||
}
|
||||
self.spreads.push_back(s);
|
||||
self.sum_s += s;
|
||||
self.sum_ss += s * s;
|
||||
if self.spreads.len() < self.z_period {
|
||||
return None;
|
||||
}
|
||||
let m = self.z_period as f64;
|
||||
let mean_s = self.sum_s / m;
|
||||
let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0);
|
||||
let std_s = var_s.sqrt();
|
||||
if std_s == 0.0 {
|
||||
// A flat spread window has no dispersion to standardise against.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((s - mean_s) / std_s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PairSpreadZScore {
|
||||
/// `(a, b)` price pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
|
||||
// Bad tick: skip it without disturbing either window.
|
||||
return None;
|
||||
}
|
||||
let x = b.ln();
|
||||
let y = a.ln();
|
||||
if self.reg.len() == self.beta_period {
|
||||
let (ox, oy) = self.reg.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xx -= ox * ox;
|
||||
self.sum_xy -= ox * oy;
|
||||
}
|
||||
self.reg.push_back((x, y));
|
||||
self.sum_x += x;
|
||||
self.sum_y += y;
|
||||
self.sum_xx += x * x;
|
||||
self.sum_xy += x * y;
|
||||
let beta = self.hedge_ratio()?;
|
||||
let spread = y - beta * x;
|
||||
self.push_spread(spread)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.reg.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xx = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
self.spreads.clear();
|
||||
self.sum_s = 0.0;
|
||||
self.sum_ss = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// `beta_period` samples to define the hedge ratio (and the first
|
||||
// spread), then `z_period − 1` more to fill the spread window.
|
||||
self.beta_period + self.z_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.spreads.len() == self.z_period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PairSpreadZScore"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_periods_below_two() {
|
||||
assert!(PairSpreadZScore::new(1, 5).is_err());
|
||||
assert!(PairSpreadZScore::new(5, 1).is_err());
|
||||
assert!(PairSpreadZScore::new(2, 2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let z = PairSpreadZScore::new(10, 20).unwrap();
|
||||
assert_eq!(z.beta_period(), 10);
|
||||
assert_eq!(z.z_period(), 20);
|
||||
assert_eq!(z.warmup_period(), 29);
|
||||
assert_eq!(z.name(), "PairSpreadZScore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_benchmark_two_sample_window_is_sign_of_move() {
|
||||
// Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move.
|
||||
let mut z = PairSpreadZScore::new(2, 2).unwrap();
|
||||
assert_eq!(z.update((100.0, 100.0)), None);
|
||||
assert_eq!(z.update((100.0, 100.0)), None);
|
||||
// The ±1 result is exact in real arithmetic; the variance is computed
|
||||
// via Σs²−mean² so a few ulps of cancellation error remain.
|
||||
assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_spread_yields_zero() {
|
||||
// Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect();
|
||||
let last = PairSpreadZScore::new(3, 4)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tick_is_skipped() {
|
||||
let mut z = PairSpreadZScore::new(2, 2).unwrap();
|
||||
// A non-positive or non-finite price never enters the windows.
|
||||
assert_eq!(z.update((0.0, 100.0)), None);
|
||||
assert_eq!(z.update((100.0, f64::NAN)), None);
|
||||
assert!(!z.is_ready());
|
||||
// Valid ticks then warm the indicator normally.
|
||||
z.update((100.0, 100.0));
|
||||
z.update((100.0, 100.0));
|
||||
z.update((110.0, 100.0));
|
||||
assert!(z.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut z = PairSpreadZScore::new(3, 3).unwrap();
|
||||
for i in 0..10 {
|
||||
let b = 100.0 + 5.0 * f64::from(i).sin();
|
||||
z.update((b * 1.5, b));
|
||||
}
|
||||
assert!(z.is_ready());
|
||||
z.reset();
|
||||
assert!(!z.is_ready());
|
||||
assert_eq!(z.update((100.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
let b = 100.0 + 10.0 * (t * 0.2).sin();
|
||||
let a = b * (1.0 + 0.05 * (t * 0.5).cos());
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs);
|
||||
let mut z = PairSpreadZScore::new(14, 10).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
|
||||
///
|
||||
/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
|
||||
/// indicator differences consecutive prices into log-returns
|
||||
/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
|
||||
/// `a`'s returns on `b`'s returns over the trailing window of `period` return
|
||||
/// pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// cov_ab = (1/n) · Σ rₐ·r_b − r̄ₐ·r̄_b
|
||||
/// var_b = (1/n) · Σ r_b² − r̄_b²
|
||||
/// Beta = cov_ab / var_b
|
||||
/// ```
|
||||
///
|
||||
/// This is the slope of the OLS line and measures how much asset `a` moves, in
|
||||
/// return space, for a unit return of asset `b`. A reading of `1.0` means the
|
||||
/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
|
||||
/// moves; negative readings signal an inverse relationship and the basis for a
|
||||
/// hedge.
|
||||
///
|
||||
/// This differs from [`crate::Beta`], which regresses the raw inputs it is
|
||||
/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
|
||||
/// and it computes the returns for you, which is the conventional way to
|
||||
/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
|
||||
/// shared trend and rarely what you want).
|
||||
///
|
||||
/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
|
||||
/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
|
||||
/// window has zero return variance and Beta is undefined; the indicator
|
||||
/// returns `0` in that case rather than producing `NaN`.
|
||||
///
|
||||
/// Prices must be strictly positive and finite for the log-return to be
|
||||
/// defined. A non-positive or non-finite price breaks the return chain: that
|
||||
/// sample is dropped and the next valid price re-seeds the previous-price
|
||||
/// reference, exactly as a real feed would resume after a bad tick.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PairwiseBeta};
|
||||
///
|
||||
/// let mut indicator = PairwiseBeta::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// // A varying (non-constant-return) positive price path.
|
||||
/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
|
||||
/// // `a = b²`, so a's log-returns are exactly twice b's.
|
||||
/// last = indicator.update((b * b, b));
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairwiseBeta {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl PairwiseBeta {
|
||||
/// Construct a new rolling pairwise Beta over `period` return pairs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
|
||||
/// least two returns).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "pairwise beta needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period (number of return pairs in the rolling window).
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn push_return(&mut self, ra: f64, rb: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_a -= oa;
|
||||
self.sum_b -= ob;
|
||||
self.sum_bb -= ob * ob;
|
||||
self.sum_ab -= oa * ob;
|
||||
}
|
||||
self.window.push_back((ra, rb));
|
||||
self.sum_a += ra;
|
||||
self.sum_b += rb;
|
||||
self.sum_bb += rb * rb;
|
||||
self.sum_ab += ra * rb;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_a = self.sum_a / n;
|
||||
let mean_b = self.sum_b / n;
|
||||
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
|
||||
let cov = self.sum_ab / n - mean_a * mean_b;
|
||||
if var_b == 0.0 {
|
||||
// A flat benchmark-return window has no defined beta.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(cov / var_b)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PairwiseBeta {
|
||||
/// `(a, b)` price pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
|
||||
// Bad tick: drop it and restart the return chain.
|
||||
self.prev = None;
|
||||
return None;
|
||||
}
|
||||
let Some((pa, pb)) = self.prev else {
|
||||
self.prev = Some((a, b));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((a, b));
|
||||
let ra = (a / pa).ln();
|
||||
let rb = (b / pb).ln();
|
||||
self.push_return(ra, rb)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_a = 0.0;
|
||||
self.sum_b = 0.0;
|
||||
self.sum_bb = 0.0;
|
||||
self.sum_ab = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One prior price to seed, then `period` return pairs.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PairwiseBeta"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(PairwiseBeta::new(0).is_err());
|
||||
assert!(PairwiseBeta::new(1).is_err());
|
||||
assert!(PairwiseBeta::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let b = PairwiseBeta::new(14).unwrap();
|
||||
assert_eq!(b.period(), 14);
|
||||
assert_eq!(b.warmup_period(), 15);
|
||||
assert_eq!(b.name(), "PairwiseBeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squared_price_gives_beta_two() {
|
||||
// a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
|
||||
(b * b, b)
|
||||
})
|
||||
.collect();
|
||||
let last = PairwiseBeta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_price_gives_beta_minus_one() {
|
||||
// a = 1/b ⇒ a's log-returns are −1× b's ⇒ beta = −1.
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
|
||||
(1.0 / b, b)
|
||||
})
|
||||
.collect();
|
||||
let last = PairwiseBeta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_benchmark_returns_zero() {
|
||||
// b constant ⇒ zero return variance ⇒ beta defined as 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
|
||||
let last = PairwiseBeta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tick_breaks_return_chain() {
|
||||
let mut b = PairwiseBeta::new(3).unwrap();
|
||||
// Seed, one good return, then a non-positive price drops the chain.
|
||||
assert_eq!(b.update((100.0, 100.0)), None);
|
||||
assert_eq!(b.update((101.0, 101.0)), None);
|
||||
assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
|
||||
assert!(!b.is_ready());
|
||||
// A non-finite price is rejected the same way.
|
||||
assert_eq!(b.update((f64::NAN, 50.0)), None);
|
||||
assert!(!b.is_ready());
|
||||
// Recovery: subsequent valid prices rebuild the window cleanly.
|
||||
for i in 0..5 {
|
||||
let p = 100.0 * 1.01_f64.powi(i);
|
||||
b.update((p * p, p));
|
||||
}
|
||||
assert!(b.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut b = PairwiseBeta::new(3).unwrap();
|
||||
for i in 0..6 {
|
||||
let p = 100.0 * 1.01_f64.powi(i);
|
||||
b.update((p * p, p));
|
||||
}
|
||||
assert!(b.is_ready());
|
||||
b.reset();
|
||||
assert!(!b.is_ready());
|
||||
assert_eq!(b.update((100.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
let b = 100.0 + 5.0 * t.sin();
|
||||
let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
|
||||
let mut b = PairwiseBeta::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ use crate::traits::Indicator;
|
||||
/// only — no trend filter is applied; combine with a trend indicator for
|
||||
/// actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Quoted Spread — top-of-book spread in basis points.
|
||||
|
||||
use crate::microstructure::OrderBook;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of
|
||||
/// the mid price.
|
||||
///
|
||||
/// ```text
|
||||
/// mid = (bidPrice₁ + askPrice₁) / 2
|
||||
/// quotedSpread = (askPrice₁ − bidPrice₁) / mid · 10_000 (bps)
|
||||
/// ```
|
||||
///
|
||||
/// This is the round-trip cost of crossing the spread at the touch, normalised
|
||||
/// by price so it is comparable across instruments. For a valid (uncrossed)
|
||||
/// book the result is non-negative. An empty book yields `0`.
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
|
||||
/// snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread};
|
||||
///
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(100.0, 1.0).unwrap()],
|
||||
/// vec![Level::new(100.5, 1.0).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut qs = QuotedSpread::new();
|
||||
/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps.
|
||||
/// let bps = qs.update(book).unwrap();
|
||||
/// assert!((bps - 49.875_311_72).abs() < 1e-6);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QuotedSpread {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl QuotedSpread {
|
||||
/// Construct a new quoted-spread indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for QuotedSpread {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let mid = f64::midpoint(bid.price, ask.price);
|
||||
Some((ask.price - bid.price) / mid * 10_000.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QuotedSpread"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Level;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qs = QuotedSpread::new();
|
||||
assert_eq!(qs.name(), "QuotedSpread");
|
||||
assert_eq!(qs.warmup_period(), 1);
|
||||
assert!(!qs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_in_bps() {
|
||||
let mut qs = QuotedSpread::new();
|
||||
// spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
|
||||
let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap();
|
||||
assert!((bps - 99.502_487_56).abs() < 1e-6);
|
||||
assert!(qs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_book_is_small() {
|
||||
let mut qs = QuotedSpread::new();
|
||||
let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap();
|
||||
assert!(bps > 0.0 && bps < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_book_is_zero() {
|
||||
let mut qs = QuotedSpread::new();
|
||||
assert_eq!(
|
||||
qs.update(OrderBook::new_unchecked(vec![], vec![])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let ask = 100.5 + f64::from(i % 4) * 0.1;
|
||||
book(&[(100.0, 1.0)], &[(ask, 1.0)])
|
||||
})
|
||||
.collect();
|
||||
let mut a = QuotedSpread::new();
|
||||
let mut b = QuotedSpread::new();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qs = QuotedSpread::new();
|
||||
qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
|
||||
assert!(qs.is_ready());
|
||||
qs.reset();
|
||||
assert!(!qs.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::{Rsi, Sma};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`RelativeStrengthAB`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct RelativeStrengthOutput {
|
||||
/// The raw relative-strength ratio `a / b`.
|
||||
pub ratio: f64,
|
||||
/// Simple moving average of the ratio over `ma_period`.
|
||||
pub ratio_ma: f64,
|
||||
/// Relative Strength Index of the ratio over `rsi_period`.
|
||||
pub ratio_rsi: f64,
|
||||
}
|
||||
|
||||
/// Comparative relative strength of asset `a` against asset `b`.
|
||||
///
|
||||
/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
|
||||
/// `a / b`. The ratio is then smoothed with a simple moving average and run
|
||||
/// through an RSI, so a single indicator gives you the relative-strength level,
|
||||
/// its trend, and whether that trend is overbought or oversold:
|
||||
///
|
||||
/// ```text
|
||||
/// ratio = a / b
|
||||
/// ratio_ma = SMA(ratio, ma_period)
|
||||
/// ratio_rsi = RSI(ratio, rsi_period)
|
||||
/// ```
|
||||
///
|
||||
/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
|
||||
/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
|
||||
/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
|
||||
/// "asset-vs-index" rotation screen.
|
||||
///
|
||||
/// The first output appears once both the moving average and the RSI have
|
||||
/// warmed up; the ratio itself is computed from the first valid pair. A
|
||||
/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
|
||||
/// and is skipped, leaving the internal averages untouched.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RelativeStrengthAB};
|
||||
///
|
||||
/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for _ in 0..20 {
|
||||
/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
|
||||
/// }
|
||||
/// let out = last.unwrap();
|
||||
/// assert!((out.ratio - 2.0).abs() < 1e-12);
|
||||
/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
|
||||
/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
|
||||
/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RelativeStrengthAB {
|
||||
ma_period: usize,
|
||||
rsi_period: usize,
|
||||
ma: Sma,
|
||||
rsi: Rsi,
|
||||
}
|
||||
|
||||
impl RelativeStrengthAB {
|
||||
/// Construct a new comparative relative-strength indicator.
|
||||
///
|
||||
/// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
|
||||
/// is the RSI look-back of the ratio.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
|
||||
/// is zero.
|
||||
pub fn new(ma_period: usize, rsi_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ma_period,
|
||||
rsi_period,
|
||||
ma: Sma::new(ma_period)?,
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Moving-average look-back of the ratio.
|
||||
pub const fn ma_period(&self) -> usize {
|
||||
self.ma_period
|
||||
}
|
||||
|
||||
/// RSI look-back of the ratio.
|
||||
pub const fn rsi_period(&self) -> usize {
|
||||
self.rsi_period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RelativeStrengthAB {
|
||||
/// `(a, b)` price pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = RelativeStrengthOutput;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
|
||||
let (a, b) = input;
|
||||
if b == 0.0 || !a.is_finite() || !b.is_finite() {
|
||||
// Undefined ratio: skip without disturbing the internal averages.
|
||||
return None;
|
||||
}
|
||||
let ratio = a / b;
|
||||
let ma = self.ma.update(ratio);
|
||||
let rsi = self.rsi.update(ratio);
|
||||
match (ma, rsi) {
|
||||
(Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
|
||||
ratio,
|
||||
ratio_ma,
|
||||
ratio_rsi,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ma.reset();
|
||||
self.rsi.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.ma.warmup_period().max(self.rsi.warmup_period())
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ma.is_ready() && self.rsi.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RelativeStrengthAB"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(RelativeStrengthAB::new(0, 5).is_err());
|
||||
assert!(RelativeStrengthAB::new(5, 0).is_err());
|
||||
assert!(RelativeStrengthAB::new(5, 5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rs = RelativeStrengthAB::new(10, 14).unwrap();
|
||||
assert_eq!(rs.ma_period(), 10);
|
||||
assert_eq!(rs.rsi_period(), 14);
|
||||
// SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
|
||||
assert_eq!(rs.warmup_period(), 15);
|
||||
assert_eq!(rs.name(), "RelativeStrengthAB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_ratio_is_flat() {
|
||||
// a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
|
||||
let out = RelativeStrengthAB::new(5, 5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_ratio_is_overbought() {
|
||||
// a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
|
||||
.collect();
|
||||
let out = RelativeStrengthAB::new(5, 5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(out.ratio > 1.0);
|
||||
assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_denominator_is_skipped() {
|
||||
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
|
||||
// b == 0 and non-finite inputs never reach the internal averages.
|
||||
assert_eq!(rs.update((100.0, 0.0)), None);
|
||||
assert_eq!(rs.update((f64::NAN, 100.0)), None);
|
||||
assert!(!rs.is_ready());
|
||||
for _ in 0..8 {
|
||||
rs.update((150.0, 100.0));
|
||||
}
|
||||
assert!(rs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
|
||||
for t in 0..10 {
|
||||
rs.update((100.0 + f64::from(t), 100.0));
|
||||
}
|
||||
assert!(rs.is_ready());
|
||||
rs.reset();
|
||||
assert!(!rs.is_ready());
|
||||
assert_eq!(rs.update((100.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|t| {
|
||||
let tt = f64::from(t);
|
||||
(
|
||||
100.0 + 5.0 * (tt * 0.3).sin(),
|
||||
100.0 + 2.0 * (tt * 0.2).cos(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
|
||||
let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// A Shooting Star is bearish by definition, so under the uniform candlestick
|
||||
/// sign convention (`+1.0` bullish, `−1.0` bearish, `0.0` none) it emits
|
||||
/// `−1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
|
||||
/// The same geometry read at the bottom of a downtrend is the bullish
|
||||
/// `InvertedHammer`, which carries the opposite sign.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Signed Volume — per-trade volume signed by aggressor side.
|
||||
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Signed Volume — the size of each trade signed by its aggressor side.
|
||||
///
|
||||
/// ```text
|
||||
/// signedVolume = size · (+1 if buy, −1 if sell)
|
||||
/// ```
|
||||
///
|
||||
/// A positive value is buyer-initiated flow, a negative value seller-initiated.
|
||||
/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and
|
||||
/// trade-flow imbalance.
|
||||
///
|
||||
/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SignedVolume, Side, Trade};
|
||||
///
|
||||
/// let mut sv = SignedVolume::new();
|
||||
/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap();
|
||||
/// assert_eq!(sv.update(buy), Some(2.0));
|
||||
/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap();
|
||||
/// assert_eq!(sv.update(sell), Some(-3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SignedVolume {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl SignedVolume {
|
||||
/// Construct a new signed-volume indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SignedVolume {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
Some(trade.size * trade.side.sign())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SignedVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn trade(size: f64, side: Side, ts: i64) -> Trade {
|
||||
Trade::new(100.0, size, side, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let sv = SignedVolume::new();
|
||||
assert_eq!(sv.name(), "SignedVolume");
|
||||
assert_eq!(sv.warmup_period(), 1);
|
||||
assert!(!sv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_is_positive() {
|
||||
let mut sv = SignedVolume::new();
|
||||
assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0));
|
||||
assert!(sv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_is_negative() {
|
||||
let mut sv = SignedVolume::new();
|
||||
assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_is_zero() {
|
||||
let mut sv = SignedVolume::new();
|
||||
assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| {
|
||||
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
trade(1.0 + (i % 4) as f64, side, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = SignedVolume::new();
|
||||
let mut b = SignedVolume::new();
|
||||
assert_eq!(
|
||||
a.batch(&trades),
|
||||
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sv = SignedVolume::new();
|
||||
sv.update(trade(1.0, Side::Buy, 0));
|
||||
assert!(sv.is_ready());
|
||||
sv.reset();
|
||||
assert!(!sv.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -17,6 +17,13 @@ use crate::traits::Indicator;
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -20,6 +20,13 @@ use crate::traits::Indicator;
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing
|
||||
/// window of `window` trades.
|
||||
///
|
||||
/// ```text
|
||||
/// buyVol = Σ size of buyer-initiated trades in the window
|
||||
/// sellVol = Σ size of seller-initiated trades in the window
|
||||
/// imbalance = (buyVol − sellVol) / (buyVol + sellVol)
|
||||
/// ```
|
||||
///
|
||||
/// The output lies in `[−1, +1]`: `+1` means the window was all aggressive
|
||||
/// buying, `−1` all aggressive selling, `0` balanced (or no volume). The
|
||||
/// indicator warms up for `window` trades — `update` returns `None` until the
|
||||
/// window is full — then emits the rolling imbalance, maintained in O(1) per
|
||||
/// trade.
|
||||
///
|
||||
/// `Input = Trade`, `Output = f64`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Side, Trade, TradeImbalance};
|
||||
///
|
||||
/// let mut ti = TradeImbalance::new(2).unwrap();
|
||||
/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None);
|
||||
/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
|
||||
/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap());
|
||||
/// assert_eq!(out, Some(0.5));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeImbalance {
|
||||
window: usize,
|
||||
history: VecDeque<(f64, f64)>,
|
||||
buy_sum: f64,
|
||||
sell_sum: f64,
|
||||
}
|
||||
|
||||
impl TradeImbalance {
|
||||
/// Construct a trade-imbalance indicator over a window of `window` trades.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `window` is zero.
|
||||
pub fn new(window: usize) -> Result<Self> {
|
||||
if window == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
window,
|
||||
history: VecDeque::with_capacity(window),
|
||||
buy_sum: 0.0,
|
||||
sell_sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The configured window length, in trades.
|
||||
pub fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TradeImbalance {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
let (buy, sell) = if trade.side.sign() > 0.0 {
|
||||
(trade.size, 0.0)
|
||||
} else {
|
||||
(0.0, trade.size)
|
||||
};
|
||||
self.history.push_back((buy, sell));
|
||||
self.buy_sum += buy;
|
||||
self.sell_sum += sell;
|
||||
if self.history.len() > self.window {
|
||||
let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
|
||||
self.buy_sum -= old_buy;
|
||||
self.sell_sum -= old_sell;
|
||||
}
|
||||
if self.history.len() < self.window {
|
||||
return None;
|
||||
}
|
||||
let total = self.buy_sum + self.sell_sum;
|
||||
if total <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((self.buy_sum - self.sell_sum) / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.history.clear();
|
||||
self.buy_sum = 0.0;
|
||||
self.sell_sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.history.len() >= self.window
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TradeImbalance"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn trade(size: f64, side: Side, ts: i64) -> Trade {
|
||||
Trade::new(100.0, size, side, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_window() {
|
||||
assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ti = TradeImbalance::new(5).unwrap();
|
||||
assert_eq!(ti.name(), "TradeImbalance");
|
||||
assert_eq!(ti.warmup_period(), 5);
|
||||
assert_eq!(ti.window(), 5);
|
||||
assert!(!ti.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_then_emits() {
|
||||
let mut ti = TradeImbalance::new(2).unwrap();
|
||||
assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
|
||||
assert!(!ti.is_ready());
|
||||
// Window full: buyVol 3, sellVol 1 -> 0.5.
|
||||
assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
|
||||
assert!(ti.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_off_old_trades() {
|
||||
let mut ti = TradeImbalance::new(2).unwrap();
|
||||
ti.update(trade(3.0, Side::Buy, 0));
|
||||
ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
|
||||
// Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
|
||||
let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
|
||||
assert!((out - (4.0 / 6.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_is_zero() {
|
||||
let mut ti = TradeImbalance::new(2).unwrap();
|
||||
ti.update(trade(0.0, Side::Buy, 0));
|
||||
assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..30)
|
||||
.map(|i| {
|
||||
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
trade(1.0 + (i % 5) as f64, side, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = TradeImbalance::new(5).unwrap();
|
||||
let mut b = TradeImbalance::new(5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&trades),
|
||||
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ti = TradeImbalance::new(2).unwrap();
|
||||
ti.update(trade(3.0, Side::Buy, 0));
|
||||
ti.update(trade(1.0, Side::Sell, 1));
|
||||
assert!(ti.is_ready());
|
||||
ti.reset();
|
||||
assert!(!ti.is_ready());
|
||||
assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
|
||||
/// Pattern-shape check only — no trend filter is applied; combine with a trend
|
||||
/// indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector already emits the uniform candlestick sign convention shared
|
||||
/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
|
||||
/// pattern — so it drops straight into a machine-learning feature matrix where
|
||||
/// the bullish and bearish variants of the pattern occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
mod error;
|
||||
mod microstructure;
|
||||
mod ohlcv;
|
||||
mod traits;
|
||||
|
||||
@@ -52,38 +53,43 @@ pub use indicators::{
|
||||
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
|
||||
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
|
||||
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
|
||||
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
|
||||
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
|
||||
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
|
||||
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
|
||||
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
|
||||
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
|
||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
|
||||
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
|
||||
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
|
||||
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
|
||||
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
|
||||
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
|
||||
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
|
||||
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
|
||||
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
|
||||
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
|
||||
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
|
||||
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
|
||||
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
|
||||
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
|
||||
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
|
||||
Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
|
||||
DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput,
|
||||
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo,
|
||||
DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema,
|
||||
EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
|
||||
FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio,
|
||||
GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, InitialBalance,
|
||||
InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma,
|
||||
Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi,
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput,
|
||||
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
|
||||
MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom,
|
||||
MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, OpeningRangeOutput,
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi,
|
||||
QuotedSpread, RSquared, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness,
|
||||
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError,
|
||||
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
|
||||
StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
|
||||
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
||||
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv,
|
||||
TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
|
||||
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
|
||||
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||
pub use ohlcv::{Candle, Tick};
|
||||
pub use traits::{BatchExt, Chain, Indicator};
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Microstructure value types: order-book snapshots and trades.
|
||||
//!
|
||||
//! These are the non-OHLCV inputs consumed by the order-book / trade-flow
|
||||
//! indicator family. An [`OrderBook`] is a depth snapshot (sorted bid and ask
|
||||
//! levels); a [`Trade`] is a single executed trade with an aggressor [`Side`];
|
||||
//! a [`TradeQuote`] pairs a trade with the mid-price prevailing at execution,
|
||||
//! the input for spread- and price-impact measures.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A single order-book price level: a resting quantity at a price.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Level {
|
||||
/// Price of the level (strictly positive).
|
||||
pub price: f64,
|
||||
/// Resting size / quantity at this price (non-negative).
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// Construct a level, validating that `price` is finite and strictly
|
||||
/// positive and `size` is finite and non-negative.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidOrderBook`] if the price is not a finite
|
||||
/// positive number, or the size is not a finite non-negative number.
|
||||
pub fn new(price: f64, size: f64) -> Result<Self> {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !size.is_finite() || size < 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self { price, size })
|
||||
}
|
||||
|
||||
/// Construct a level without validation. The caller asserts that `price`
|
||||
/// is finite and positive and `size` is finite and non-negative.
|
||||
pub const fn new_unchecked(price: f64, size: f64) -> Self {
|
||||
Self { price, size }
|
||||
}
|
||||
}
|
||||
|
||||
/// An order-book depth snapshot.
|
||||
///
|
||||
/// Bids are stored best-first (strictly descending price); asks are stored
|
||||
/// best-first (strictly ascending price). A valid book is non-empty on both
|
||||
/// sides and uncrossed (`best_bid < best_ask`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrderBook {
|
||||
/// Bid levels, best (highest price) first.
|
||||
pub bids: Vec<Level>,
|
||||
/// Ask levels, best (lowest price) first.
|
||||
pub asks: Vec<Level>,
|
||||
}
|
||||
|
||||
impl OrderBook {
|
||||
/// Construct an order book, validating the level and ordering invariants.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidOrderBook`] if either side is empty, any level
|
||||
/// has a non-finite/non-positive price or non-finite/negative size, the
|
||||
/// bids are not strictly descending in price, the asks are not strictly
|
||||
/// ascending in price, or the book is crossed/locked (`best_bid >=
|
||||
/// best_ask`).
|
||||
pub fn new(bids: Vec<Level>, asks: Vec<Level>) -> Result<Self> {
|
||||
if bids.is_empty() || asks.is_empty() {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "order book must have at least one bid and one ask",
|
||||
});
|
||||
}
|
||||
for level in bids.iter().chain(asks.iter()) {
|
||||
if !level.price.is_finite() || level.price <= 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !level.size.is_finite() || level.size < 0.0 {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "level size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
for pair in bids.windows(2) {
|
||||
if pair[0].price <= pair[1].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "bids must be strictly descending in price",
|
||||
});
|
||||
}
|
||||
}
|
||||
for pair in asks.windows(2) {
|
||||
if pair[0].price >= pair[1].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "asks must be strictly ascending in price",
|
||||
});
|
||||
}
|
||||
}
|
||||
if bids[0].price >= asks[0].price {
|
||||
return Err(Error::InvalidOrderBook {
|
||||
message: "order book must be uncrossed (best_bid < best_ask)",
|
||||
});
|
||||
}
|
||||
Ok(Self { bids, asks })
|
||||
}
|
||||
|
||||
/// Construct an order book without validation. The caller asserts that all
|
||||
/// level and ordering invariants hold.
|
||||
pub const fn new_unchecked(bids: Vec<Level>, asks: Vec<Level>) -> Self {
|
||||
Self { bids, asks }
|
||||
}
|
||||
|
||||
/// The best (highest-price) bid level, or `None` if the bid side is empty.
|
||||
pub fn best_bid(&self) -> Option<Level> {
|
||||
self.bids.first().copied()
|
||||
}
|
||||
|
||||
/// The best (lowest-price) ask level, or `None` if the ask side is empty.
|
||||
pub fn best_ask(&self) -> Option<Level> {
|
||||
self.asks.first().copied()
|
||||
}
|
||||
|
||||
/// The mid price `(best_bid + best_ask) / 2`, or `None` if either side is
|
||||
/// empty.
|
||||
pub fn mid(&self) -> Option<f64> {
|
||||
match (self.best_bid(), self.best_ask()) {
|
||||
(Some(bid), Some(ask)) => Some(f64::midpoint(bid.price, ask.price)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The aggressor side of a trade: the side that crossed the spread.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
/// A buyer-initiated (aggressive buy) trade.
|
||||
Buy,
|
||||
/// A seller-initiated (aggressive sell) trade.
|
||||
Sell,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The signed multiplier for this side: `+1.0` for a buy, `−1.0` for a
|
||||
/// sell.
|
||||
pub const fn sign(self) -> f64 {
|
||||
match self {
|
||||
Side::Buy => 1.0,
|
||||
Side::Sell => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single executed trade with an aggressor side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Trade {
|
||||
/// Execution price (strictly positive).
|
||||
pub price: f64,
|
||||
/// Executed size / quantity (non-negative).
|
||||
pub size: f64,
|
||||
/// Aggressor side.
|
||||
pub side: Side,
|
||||
/// Trade timestamp (caller-defined epoch / resolution).
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl Trade {
|
||||
/// Construct a trade, validating that `price` is finite and strictly
|
||||
/// positive and `size` is finite and non-negative.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidTrade`] if the price is not a finite positive
|
||||
/// number, or the size is not a finite non-negative number.
|
||||
pub fn new(price: f64, size: f64, side: Side, timestamp: i64) -> Result<Self> {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade price must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !size.is_finite() || size < 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade size must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
price,
|
||||
size,
|
||||
side,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a trade without validation. The caller asserts that `price`
|
||||
/// is finite and positive and `size` is finite and non-negative.
|
||||
pub const fn new_unchecked(price: f64, size: f64, side: Side, timestamp: i64) -> Self {
|
||||
Self {
|
||||
price,
|
||||
size,
|
||||
side,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trade paired with the mid-price prevailing at execution.
|
||||
///
|
||||
/// This is the input for spread- and price-impact measures (effective spread,
|
||||
/// realized spread, Kyle's lambda), which relate an executed trade to the
|
||||
/// quote it traded against.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TradeQuote {
|
||||
/// The executed trade.
|
||||
pub trade: Trade,
|
||||
/// The mid-price prevailing at execution (strictly positive).
|
||||
pub mid: f64,
|
||||
}
|
||||
|
||||
impl TradeQuote {
|
||||
/// Construct a trade-quote, validating that `mid` is finite and strictly
|
||||
/// positive. The `trade` is assumed already valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidTrade`] if `mid` is not a finite positive
|
||||
/// number.
|
||||
pub fn new(trade: Trade, mid: f64) -> Result<Self> {
|
||||
if !mid.is_finite() || mid <= 0.0 {
|
||||
return Err(Error::InvalidTrade {
|
||||
message: "trade-quote mid must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self { trade, mid })
|
||||
}
|
||||
|
||||
/// Construct a trade-quote without validation. The caller asserts that
|
||||
/// `mid` is finite and positive.
|
||||
pub const fn new_unchecked(trade: Trade, mid: f64) -> Self {
|
||||
Self { trade, mid }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_new_accepts_valid() {
|
||||
let level = Level::new(100.5, 2.0).unwrap();
|
||||
assert_eq!(level.price, 100.5);
|
||||
assert_eq!(level.size, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_accepts_zero_size() {
|
||||
assert!(Level::new(100.0, 0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_non_finite_price() {
|
||||
assert!(matches!(
|
||||
Level::new(f64::NAN, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(f64::INFINITY, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_non_positive_price() {
|
||||
assert!(matches!(
|
||||
Level::new(0.0, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(-1.0, 1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_rejects_bad_size() {
|
||||
assert!(matches!(
|
||||
Level::new(100.0, -1.0),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Level::new(100.0, f64::NAN),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_new_unchecked_preserves_fields() {
|
||||
let level = Level::new_unchecked(-5.0, -2.0);
|
||||
assert_eq!(level.price, -5.0);
|
||||
assert_eq!(level.size, -2.0);
|
||||
}
|
||||
|
||||
fn lvl(price: f64, size: f64) -> Level {
|
||||
Level::new(price, size).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_accepts_valid() {
|
||||
let book = OrderBook::new(
|
||||
vec![lvl(100.0, 2.0), lvl(99.0, 3.0)],
|
||||
vec![lvl(101.0, 1.0), lvl(102.0, 4.0)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(book.best_bid(), Some(lvl(100.0, 2.0)));
|
||||
assert_eq!(book.best_ask(), Some(lvl(101.0, 1.0)));
|
||||
assert_eq!(book.mid(), Some(100.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_empty_side() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(100.0, 1.0)], vec![]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_bad_level() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![Level::new_unchecked(100.0, -1.0)],
|
||||
vec![lvl(101.0, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![lvl(100.0, 1.0)],
|
||||
vec![Level::new_unchecked(f64::NAN, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_misordered_bids() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(99.0, 1.0), lvl(100.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_misordered_asks() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(
|
||||
vec![lvl(100.0, 1.0)],
|
||||
vec![lvl(102.0, 1.0), lvl(101.0, 1.0)]
|
||||
),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_rejects_crossed() {
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(101.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
OrderBook::new(vec![lvl(102.0, 1.0)], vec![lvl(101.0, 1.0)]),
|
||||
Err(Error::InvalidOrderBook { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_new_unchecked_allows_empty() {
|
||||
let book = OrderBook::new_unchecked(vec![], vec![]);
|
||||
assert_eq!(book.best_bid(), None);
|
||||
assert_eq!(book.best_ask(), None);
|
||||
assert_eq!(book.mid(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_sign() {
|
||||
assert_eq!(Side::Buy.sign(), 1.0);
|
||||
assert_eq!(Side::Sell.sign(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_accepts_valid() {
|
||||
let trade = Trade::new(100.0, 1.5, Side::Buy, 42).unwrap();
|
||||
assert_eq!(trade.price, 100.0);
|
||||
assert_eq!(trade.size, 1.5);
|
||||
assert_eq!(trade.side, Side::Buy);
|
||||
assert_eq!(trade.timestamp, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_rejects_bad_price() {
|
||||
assert!(matches!(
|
||||
Trade::new(0.0, 1.0, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Trade::new(f64::NAN, 1.0, Side::Sell, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_rejects_bad_size() {
|
||||
assert!(matches!(
|
||||
Trade::new(100.0, -1.0, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Trade::new(100.0, f64::INFINITY, Side::Buy, 0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_new_unchecked_preserves_fields() {
|
||||
let trade = Trade::new_unchecked(-1.0, -2.0, Side::Sell, 7);
|
||||
assert_eq!(trade.price, -1.0);
|
||||
assert_eq!(trade.size, -2.0);
|
||||
assert_eq!(trade.side, Side::Sell);
|
||||
assert_eq!(trade.timestamp, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_accepts_valid() {
|
||||
let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
|
||||
let tq = TradeQuote::new(trade, 99.5).unwrap();
|
||||
assert_eq!(tq.trade, trade);
|
||||
assert_eq!(tq.mid, 99.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_rejects_bad_mid() {
|
||||
let trade = Trade::new(100.0, 1.0, Side::Buy, 0).unwrap();
|
||||
assert!(matches!(
|
||||
TradeQuote::new(trade, 0.0),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TradeQuote::new(trade, f64::NAN),
|
||||
Err(Error::InvalidTrade { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_quote_new_unchecked_preserves_fields() {
|
||||
let trade = Trade::new_unchecked(100.0, 1.0, Side::Buy, 0);
|
||||
let tq = TradeQuote::new_unchecked(trade, -1.0);
|
||||
assert_eq!(tq.mid, -1.0);
|
||||
assert_eq!(tq.trade, trade);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -34,12 +34,13 @@ use std::hint::black_box;
|
||||
use wickra::{
|
||||
Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci,
|
||||
ClassicPivots, ConnorsRsi, Ema, EmpiricalModeDecomposition, Engulfing, Frama,
|
||||
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma,
|
||||
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Obv,
|
||||
ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend,
|
||||
SuperTrendOutput, TdSequential, TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend,
|
||||
YangZhangVolatility, T3,
|
||||
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level,
|
||||
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv,
|
||||
OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar,
|
||||
RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput,
|
||||
TdSequential, TdSequentialOutput, Trade, TradeImbalance, TtmSqueeze, TtmSqueezeOutput,
|
||||
ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
WaveTrend, YangZhangVolatility, T3,
|
||||
};
|
||||
use wickra_data::csv::CandleReader;
|
||||
|
||||
@@ -114,6 +115,50 @@ where
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_orderbook_input<I, F, O>(c: &mut Criterion, name: &str, books: &[OrderBook], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
I: Indicator<Input = OrderBook, Output = O>,
|
||||
{
|
||||
let mut group = c.benchmark_group(name);
|
||||
for &n in SIZES {
|
||||
let n = n.min(books.len());
|
||||
let series = &books[..n];
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| {
|
||||
b.iter(|| {
|
||||
let mut ind = make();
|
||||
for book in books {
|
||||
black_box(ind.update(book.clone()));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_trade_input<I, F, O>(c: &mut Criterion, name: &str, trades: &[Trade], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
I: Indicator<Input = Trade, Output = O>,
|
||||
{
|
||||
let mut group = c.benchmark_group(name);
|
||||
for &n in SIZES {
|
||||
let n = n.min(trades.len());
|
||||
let series = &trades[..n];
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, trades| {
|
||||
b.iter(|| {
|
||||
let mut ind = make();
|
||||
for t in trades {
|
||||
black_box(ind.update(*t));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
@@ -265,6 +310,47 @@ fn benches(c: &mut Criterion) {
|
||||
bench_scalar(c, "value_at_risk", &closes, || {
|
||||
ValueAtRisk::new(50, 0.95).unwrap()
|
||||
});
|
||||
|
||||
// === Family — Microstructure ===
|
||||
// No order-book dataset ships with the repo, so synthesise a five-level
|
||||
// book around each candle close. Benches the cheapest (top-of-book) and the
|
||||
// most-expensive (full-depth sum) representatives of the family.
|
||||
let books: Vec<OrderBook> = candles
|
||||
.iter()
|
||||
.map(|candle| {
|
||||
let mid = candle.close;
|
||||
let tick = (mid * 0.0001).max(0.01);
|
||||
let bids = (0..5u32)
|
||||
.map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i)))
|
||||
.collect();
|
||||
let asks = (0..5u32)
|
||||
.map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i)))
|
||||
.collect();
|
||||
OrderBook::new_unchecked(bids, asks)
|
||||
})
|
||||
.collect();
|
||||
bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new);
|
||||
bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new);
|
||||
bench_orderbook_input(c, "microprice", &books, Microprice::new);
|
||||
|
||||
// Synthesise a trade tape from candles: one trade per bar, sided by the
|
||||
// candle's direction. SignedVolume is the cheapest; TradeImbalance carries
|
||||
// a rolling window and is the most expensive.
|
||||
let trades: Vec<Trade> = candles
|
||||
.iter()
|
||||
.map(|candle| {
|
||||
let side = if candle.close >= candle.open {
|
||||
Side::Buy
|
||||
} else {
|
||||
Side::Sell
|
||||
};
|
||||
Trade::new_unchecked(candle.close, candle.volume, side, candle.timestamp)
|
||||
})
|
||||
.collect();
|
||||
bench_trade_input(c, "signed_volume", &trades, SignedVolume::new);
|
||||
bench_trade_input(c, "trade_imbalance", &trades, || {
|
||||
TradeImbalance::new(50).unwrap()
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
|
||||
|
||||
+18
-17
@@ -1,30 +1,31 @@
|
||||
# Documentation
|
||||
|
||||
Wickra's full documentation lives in the **[GitHub Wiki](https://github.com/wickra-lib/wickra/wiki)**.
|
||||
Wickra's full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**.
|
||||
|
||||
That includes:
|
||||
|
||||
- **Quickstarts** for [Rust](https://github.com/wickra-lib/wickra/wiki/Quickstart-Rust.md),
|
||||
[Python](https://github.com/wickra-lib/wickra/wiki/Quickstart-Python.md),
|
||||
[Node](https://github.com/wickra-lib/wickra/wiki/Quickstart-Node.md), and
|
||||
[WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md).
|
||||
- **Quickstarts** for [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- A per-indicator deep dive for every one of the **214 indicators** across
|
||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
|
||||
Risk / Performance) — see the
|
||||
[indicators overview](https://github.com/wickra-lib/wickra/wiki/Indicators-Overview.md).
|
||||
- **Reference pages**: [warmup periods](https://github.com/wickra-lib/wickra/wiki/Warmup-Periods.md),
|
||||
[streaming vs batch](https://github.com/wickra-lib/wickra/wiki/Streaming-vs-Batch.md),
|
||||
[indicator chaining](https://github.com/wickra-lib/wickra/wiki/Indicator-Chaining.md), and the
|
||||
[data layer](https://github.com/wickra-lib/wickra/wiki/Data-Layer.md).
|
||||
- **Guides**: [Cookbook](https://github.com/wickra-lib/wickra/wiki/Cookbook.md),
|
||||
[TA-Lib migration](https://github.com/wickra-lib/wickra/wiki/TA-Lib-Migration.md),
|
||||
[FAQ](https://github.com/wickra-lib/wickra/wiki/FAQ.md).
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference pages**: [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), and the
|
||||
[data layer](https://docs.wickra.org/Data-Layer).
|
||||
- **Guides**: [Cookbook](https://docs.wickra.org/Cookbook),
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Editing the wiki
|
||||
## Editing the docs
|
||||
|
||||
The wiki is a separate git repository at `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
Clone it locally if you want to bulk-edit; otherwise the GitHub web UI's "Edit" button on any
|
||||
wiki page is fine for one-off changes.
|
||||
The documentation site is a separate git repository at
|
||||
`https://github.com/wickra-lib/wickra-docs`. Open a pull request there to
|
||||
propose changes; the site is built with VitePress and deploys to
|
||||
`docs.wickra.org`.
|
||||
|
||||
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
|
||||
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
|
||||
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
|
||||
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
|
||||
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
|
||||
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
|
||||
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
|
||||
|
||||
## WebAssembly — `examples/wasm/`
|
||||
|
||||
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
|
||||
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
|
||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
|
||||
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
|
||||
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long < 30, exit > 70); prints a PnL / Sharpe / max-DD summary table. |
|
||||
| `strategy_macd_adx.html` | Hourly BTCUSDT MACD crossover gated by ADX(14) > 20, with the same summary table. |
|
||||
| `strategy_bollinger_squeeze.html` | Daily BTCUSDT Bollinger-squeeze breakout with a 2×ATR(14) stop and summary table. |
|
||||
|
||||
## Example datasets
|
||||
|
||||
|
||||
Generated
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "wickra-examples-node",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra-examples-node",
|
||||
"version": "0.0.0",
|
||||
"license": "SEE LICENSE IN ../../LICENSE",
|
||||
"dependencies": {
|
||||
"wickra": "file:../../bindings/node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.2",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-linux-x64-gnu": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
"resolved": "../../bindings/node",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Strategy example: Bollinger-Squeeze breakout with ATR-based stop.
|
||||
//
|
||||
// Enters long when the Bollinger Bandwidth has just printed a fresh 6-month low
|
||||
// (the squeeze) and price closes above the upper band (the release). Exits when
|
||||
// price closes below entry minus 2 * ATR(14), or when the upper band rolls back
|
||||
// under the entry price. 0.1% fees per trade.
|
||||
//
|
||||
// Educational example. NOT a live trading recommendation. The Node counterpart
|
||||
// of `examples/python/strategy_bollinger_squeeze.py` and the Rust
|
||||
// `examples/rust/src/bin/strategy_bollinger_squeeze.rs`, printing the same
|
||||
// summary.
|
||||
//
|
||||
// Build the native binding once, then run it:
|
||||
//
|
||||
// cd bindings/node && npm install && npx napi build --platform --release
|
||||
// cd ../../examples/node && npm install
|
||||
// node strategy_bollinger_squeeze.js
|
||||
//
|
||||
// Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because daily bars
|
||||
// give an interpretable 6-month-low lookback (~180 bars).
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const wickra = require('wickra');
|
||||
|
||||
const FEE = 0.001;
|
||||
const BB_PERIOD = 20;
|
||||
const BB_K = 2.0;
|
||||
const ATR_PERIOD = 14;
|
||||
const ATR_STOP_MULT = 2.0;
|
||||
const SQUEEZE_LOOKBACK = 180;
|
||||
|
||||
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
|
||||
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
|
||||
|
||||
function loadCandles(csvPath) {
|
||||
const text = fs.readFileSync(csvPath, 'utf8');
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`${csvPath}: file is empty`);
|
||||
}
|
||||
|
||||
const header = lines[0].split(',').map((cell) => cell.trim());
|
||||
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
|
||||
`found: ${header.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (lines.length === 1) {
|
||||
throw new Error(`${csvPath}: CSV has a header but no data rows`);
|
||||
}
|
||||
|
||||
const idx = {};
|
||||
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
|
||||
|
||||
const candles = [];
|
||||
for (let row = 1; row < lines.length; row++) {
|
||||
const cells = lines[row].split(',');
|
||||
const candle = {};
|
||||
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
|
||||
const raw = cells[idx[col]];
|
||||
const value = raw === undefined ? NaN : Number(raw.trim());
|
||||
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
|
||||
throw new Error(
|
||||
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
candle[col] = value;
|
||||
}
|
||||
candles.push(candle);
|
||||
}
|
||||
return candles;
|
||||
}
|
||||
|
||||
function signed(value, digits) {
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(digits);
|
||||
}
|
||||
|
||||
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||
const buyHold = lastPrice / firstPrice;
|
||||
const stratReturn = finalEquity - 1.0;
|
||||
const bhReturn = buyHold - 1.0;
|
||||
const wins = closedTrades.filter((r) => r > 0).length;
|
||||
const losses = closedTrades.filter((r) => r < 0).length;
|
||||
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||
const n = closedTrades.length;
|
||||
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
|
||||
const varRet =
|
||||
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
|
||||
const stddev = Math.sqrt(varRet);
|
||||
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||
|
||||
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||
let maxDd = 0.0;
|
||||
for (const eq of equityCurve) {
|
||||
if (eq > peak) peak = eq;
|
||||
const dd = (peak - eq) / peak;
|
||||
if (dd > maxDd) maxDd = dd;
|
||||
}
|
||||
|
||||
const label = (s) => s.padEnd(23);
|
||||
console.log(`=== ${name} ===`);
|
||||
console.log(`${label('Bars:')}${bars}`);
|
||||
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
|
||||
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
|
||||
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
|
||||
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
|
||||
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
|
||||
console.log(
|
||||
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
|
||||
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
|
||||
);
|
||||
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
|
||||
console.log();
|
||||
console.log(
|
||||
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
|
||||
'are simplified or omitted. Past performance is not indicative of future results.',
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||||
|
||||
let candles;
|
||||
try {
|
||||
candles = loadCandles(csvPath);
|
||||
} catch (err) {
|
||||
console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (candles.length < SQUEEZE_LOOKBACK + BB_PERIOD) {
|
||||
console.error(
|
||||
`error: dataset has only ${candles.length} bars; need at least ` +
|
||||
`${SQUEEZE_LOOKBACK + BB_PERIOD}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bb = new wickra.BollingerBands(BB_PERIOD, BB_K);
|
||||
const atr = new wickra.ATR(ATR_PERIOD);
|
||||
// Rolling window of the last SQUEEZE_LOOKBACK bandwidths (Python deque(maxlen)).
|
||||
const bwWindow = [];
|
||||
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
let stopLevel = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
|
||||
for (const c of candles) {
|
||||
const bbOut = bb.update(c.close);
|
||||
const atrVal = atr.update(c.high, c.low, c.close);
|
||||
const price = c.close;
|
||||
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||
equityCurve.push(mtm);
|
||||
|
||||
if (bbOut == null || atrVal == null) continue;
|
||||
|
||||
const { upper, middle, lower } = bbOut;
|
||||
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
|
||||
|
||||
if (Number.isNaN(bandwidth)) continue;
|
||||
bwWindow.push(bandwidth);
|
||||
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
|
||||
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
|
||||
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
|
||||
|
||||
if (inPosition) {
|
||||
const stopHit = price < stopLevel;
|
||||
const upperCollapse = upper < entryPrice;
|
||||
if (stopHit || upperCollapse) {
|
||||
const tradeRet = price / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
inPosition = false;
|
||||
}
|
||||
} else {
|
||||
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
|
||||
const breakout = price > upper;
|
||||
if (isNewLow && breakout) {
|
||||
entryPrice = price;
|
||||
stopLevel = price - ATR_STOP_MULT * atrVal;
|
||||
equity *= 1.0 - FEE;
|
||||
inPosition = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inPosition) {
|
||||
const lastPrice = candles[candles.length - 1].close;
|
||||
const tradeRet = lastPrice / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
}
|
||||
|
||||
printSummary(
|
||||
'Bollinger Squeeze Breakout (1d, BTCUSDT)',
|
||||
candles[0].close,
|
||||
candles[candles.length - 1].close,
|
||||
candles.length,
|
||||
closedTrades,
|
||||
equity,
|
||||
equityCurve,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user