Compare commits

..

12 Commits

Author SHA1 Message Date
kingchenc fae60e0d54 release: bump 0.4.2 -> 0.4.3 (#125) 2026-06-01 20:49:11 +02:00
kingchenc 433b06367f ci: bump actions/checkout v4.3.1 -> v6.0.2 in codeql & scorecard (#124) 2026-06-01 20:24:20 +02:00
kingchenc 3dd7010129 feat: footprint microstructure indicator (part 4 of 4) (#123) 2026-06-01 20:00:58 +02:00
kingchenc 4f11df0e33 feat: microstructure price-impact & depth indicators (part 3 of 4) (#122)
* feat: effective spread microstructure indicator (part 3 of 4)

* feat: realized spread microstructure indicator (part 3 of 4)

* feat: kyle's lambda microstructure indicator (part 3 of 4)

* feat: depth slope microstructure indicator (part 3 of 4)
2026-06-01 19:45:38 +02:00
kingchenc b5d9e47a2e release: bump 0.4.1 -> 0.4.2 (#121) 2026-06-01 18:29:32 +02:00
kingchenc 511d3a27f7 ci: self-updating README banner + fix webpage count sync (#119)
* ci: fix webpage count sync crashing on removed public/hero.svg

The webpage indicator-count sync sed'd index.md, .vitepress/config.ts and
public/hero.svg, but hero.svg was removed from wickra-lib/webpage (the count is
now baked into og-banner.webp at build time from index.md). Under 'bash -e' the
missing file made sed exit 2 before the commit/push, so the count never reached
the webpage repo and its OG banner stayed stale — masked green by the step's
continue-on-error. Drops public/hero.svg from the sed and git add; index.md and
.vitepress/config.ts (both still carry the count) remain.

* docs: point README banner at the self-updating org profile image

Switches the top README banner from https://wickra.org/og-banner.webp (baked at
webpage deploy time) to the org profile banner that wickra-lib/.github's
banner.yml regenerates from the indicator count on every sync
(raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp).
The ?v=227 query busts GitHub's Camo image cache; the next commit teaches
sync-about to bump it with the count.

* ci: bump README banner cache-buster alongside the indicator count

Extends the PR-head README counter patch to also rewrite wickra-banner.webp?v=N
to the current count. The banner now points at the org profile image, whose
content changes when .github/banner.yml regenerates it; bumping the ?v query
busts GitHub's Camo cache so the README shows the new banner immediately. Rides
in the existing count-sync commit, so no extra commit lands on main.

* docs: changelog entry for self-updating README banner and sync fix
2026-06-01 18:19:23 +02:00
kingchenc 5b23b36261 ci: pin CI dependency installs by hash (Scorecard PinnedDependencies) (#114)
* ci: use npm ci instead of npm install for reproducible installs

Pins the node binding dependency install to the committed package-lock.json
integrity hashes (OpenSSF Scorecard PinnedDependencies). npm ci installs
strictly from the lockfile; npm install could resolve newer patch versions.
Covers ci.yml and both release.yml node steps.

* ci: hash-pin Python dev tooling in ci.yml (Scorecard #19)

Replaces the unpinned 'pip install maturin pytest numpy hypothesis' with a
hash-locked '--require-hashes -r' install (OpenSSF Scorecard PinnedDependencies).

Two lock files are needed because numpy publishes no single release with wheels
for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39):
  ci-dev-py39.txt  numpy 2.0.2  (Python 3.9, + tomli/exceptiongroup)
  ci-dev-py3.txt   numpy 2.4.6  (Python 3.10+)

The step selects the file by matrix.python-version under shell: bash. Both are
generated from ci-dev.in via uv (scripts/update-lockfiles.sh, added next).

* ci: hash-pin Python deps in bench.yml (Scorecard #16)

Replaces the unpinned 'pip install maturin numpy pandas talipp finta' with a
hash-locked '--require-hashes -r .github/requirements/bench.txt' install.
bench.yml runs on a single Python version (3.11), so one lock file (generated
from bench.in via uv) is sufficient.

* build: add scripts/update-lockfiles.sh to regenerate all lockfiles

One command refreshes every committed lockfile across languages: Cargo.lock and
fuzz/Cargo.lock (cargo update), the Node binding package-lock.json, and the
hash-pinned Python requirements under .github/requirements/ (uv pip compile
--generate-hashes). Uses uv for the Python locks so a target Python version's
hashed transitive closure can be resolved without that interpreter installed
(needed for the numpy cp39/cp313 split); bootstraps uv if absent.

.gitattributes pins *.sh to LF so the script stays runnable on Linux/macOS.

* ci: split ci-dev requirements per Python version + Dependabot rehash

Splits the single ci-dev.in into ci-dev-py39.in (numpy <2.1, the last series
with cp39 wheels) and ci-dev-py3.in (3.10+), giving a 1:1 .in->.txt layout.
The cap keeps Python 3.9 permanently installable and stops Dependabot from
proposing 3.9-breaking numpy bumps.

Adds a Dependabot pip entry on /.github/requirements so the hash-locked tooling
is kept current automatically; the canonical manual refresh stays
scripts/update-lockfiles.sh. Only the '# via -r' provenance lines in the .txt
change; no package versions or hashes move.

* ci: cache pip and npm downloads in the PR-loop jobs

Adds setup-python cache: pip (ci.yml python matrix + bench.yml, keyed on the
hash-locked requirements) and setup-node cache: npm (ci.yml node job, keyed on
bindings/node/package-lock.json), on both the primary and retry setup steps.

Scoped to jobs that actually install dependencies; the examples-smoke and
clippy-bindings jobs install nothing and are left uncached. release.yml is
intentionally left out: it runs only on tag push (not the PR loop) and is the
publish-critical path, so no caching is added there.

* docs: document hash-pinned requirements and update-lockfiles.sh

Updates the lockfile-policy table: the bindings/python row no longer claims CI
installs tooling unpinned, and a new .github/requirements row documents the
hash-locked CI/bench tooling and the per-Python-version ci-dev split. Adds a
paragraph pointing contributors at scripts/update-lockfiles.sh (uv-based,
self-bootstrapping) as the canonical lockfile refresh.

* docs: changelog entry for hash-pinned CI dependency installs
2026-06-01 17:58:31 +02:00
kingchenc 5867f71450 feat: trade-flow microstructure indicators (part 2 of 4) (#113)
* feat(core): add 3 trade-flow microstructure indicators

SignedVolume (per-trade size signed by aggressor), CumulativeVolumeDelta
(running signed-volume total), and TradeImbalance (rolling buy/sell volume
imbalance over a trade window). All consume the Trade type, with full unit
coverage. Extends the Microstructure family.

* feat(bindings): expose trade-flow microstructure indicators

Python, Node and WASM bindings for SignedVolume, CumulativeVolumeDelta and
TradeImbalance. Each takes a trade via update(price, size, is_buy); Python and
Node expose a batch over three parallel arrays, WASM exposes per-trade update.
Regenerates node index.d.ts/.js.

* test(bindings,fuzz,bench): cover trade-flow microstructure indicators

Python and Node: reference values, streaming-vs-batch, lifecycle/repr and input
validation (zero window, negative size, non-positive price, mismatched batch
lengths). New indicator_update_trade fuzz target. Synthetic trade-tape benches
(signed_volume cheapest, trade_imbalance windowed/expensive).

* docs: add trade-flow indicators + bump counter to 227

README Microstructure family row gains signed volume / CVD / trade imbalance and
the counter goes 224 -> 227; CHANGELOG records the trade-flow indicators.
2026-06-01 16:38:48 +02:00
kingchenc 2be21df803 feat: order-book microstructure indicators (part 1 of 4) (#112)
* feat(core): add microstructure input types (OrderBook, Trade, TradeQuote)

New non-OHLCV value types for the order-book / trade-flow indicator family:
Level, OrderBook (sorted, uncrossed depth snapshot), Side, Trade (with
aggressor side), and TradeQuote (trade paired with prevailing mid). Each has a
validating constructor plus a new_unchecked hot-path constructor, with full
unit coverage. Adds InvalidOrderBook / InvalidTrade error variants.

* feat(core): add 5 order-book microstructure indicators

OrderBookImbalanceTop1/TopN/Full (signed depth imbalance), Microprice
(size-weighted fair value), and QuotedSpread (top-of-book spread in bps). All
consume the OrderBook snapshot type, emit f64, are stateless and ready after
the first snapshot, with full unit coverage. Registers a new Microstructure
family in the taxonomy.

* feat(bindings): expose order-book microstructure indicators

Python, Node, and WASM bindings for OrderBookImbalanceTop1/TopN/Full,
Microprice and QuotedSpread. Each takes a depth snapshot via four equal-length
(bid_px, bid_sz, ask_px, ask_sz) arrays. Python and Node expose a batch over a
list of snapshots; WASM exposes per-snapshot update (the streaming model that
fits a browser book feed). Regenerates node index.d.ts/.js and registers the
new InvalidOrderBook/InvalidTrade arms in the Python error mapping.

* test(bindings,fuzz): cover order-book microstructure indicators

Python: smoke, reference values, streaming-vs-batch, lifecycle/repr and input
validation (mismatched lengths, crossed book, misordered levels, zero levels)
for all five order-book indicators. Node: reference values, streaming-vs-batch,
and rejection cases. Adds an indicator_update_orderbook fuzz target driving
every order-book indicator over arbitrary (incl. degenerate) snapshots.

* bench(microstructure): synthetic order-book benchmarks

Add a bench_orderbook_input harness and synthesise a five-level book around
each candle close (no order-book dataset ships with the repo). Benches the
cheapest (top-of-book imbalance) and most-expensive (full-depth imbalance) plus
microprice, matching the curated cheapest/expensive-per-family approach.

* docs: add Microstructure family + bump indicator counter to 224

README gains the Microstructure family row (order-book imbalance, microprice,
quoted spread) and the indicator counter goes 219 -> 224 across seventeen
families; CHANGELOG records the new order-book indicators and value types.
2026-06-01 16:06:22 +02:00
kingchenc 498b74a5ae chore(license): point package metadata at the modified license file
The LICENSE now carries an Additional Permissions section on top of
PolyForm Noncommercial 1.0.0, so the bare SPDX id no longer describes
it exactly. Update the package manifests to reference the actual file
instead of claiming the unmodified standard:

- Cargo (workspace + all crates): license -> license-file = "LICENSE"
- npm (main + 6 platform packages): LicenseRef-Wickra-Noncommercial-1.0.0
- PyPI: license text notes the additional personal-account permissions
2026-06-01 15:28:18 +02:00
kingchenc 921a250715 docs(license): grant personal-account trading use to match README
Append an Additional Permissions section to the PolyForm Noncommercial
License 1.0.0. The base PolyForm text is unmodified; the section only
broadens the grant. It explicitly permits a natural person to use the
software for their own personal account, including running a trading
bot on their own capital for profit, matching the README's plain-English
summary (hobby trading bots: all fine). Commercial sale of the software
or of services built around it still requires a commercial license.
2026-06-01 15:05:03 +02:00
kingchenc 0479191b66 feat: signed candlestick directional ±1 encoding (Doji signed mode) (#111)
* feat(core): add signed dragonfly/gravestone encoding to Doji

Doji gains an opt-in `.signed()` mode that classifies a detected Doji by the
position of its body within the bar range: dragonfly (long lower shadow) emits
+1.0 (bullish), gravestone (long upper shadow) emits -1.0 (bearish), and a
long-legged/standard Doji emits 0.0. The default detection-flag behaviour
(+1.0/0.0) is unchanged, so existing callers are unaffected.

The other 14 candlestick patterns already emit the uniform +1 bull / -1 bear /
0 none convention; document that explicitly with a "Signed +-1 encoding"
section on each so the whole family is a consistent drop-in ML feature.

* feat(bindings): expose Doji signed mode in python, node, wasm

Hand-write the Doji binding in all three language bindings (instead of the
shared candle-pattern macro) so it accepts an opt-in `signed` flag and exposes
an `is_signed`/`isSigned` accessor:

- Python: `Doji(signed=False)` keyword argument
- Node: `new Doji(signed?)` optional constructor argument (index.d.ts/.js
  regenerated via napi build)
- WASM: `new Doji(signed?)` optional constructor argument

The default construction is unchanged, so existing callers keep the
direction-less +1/0 detection flag.

* test(bindings,fuzz): cover Doji signed dragonfly/gravestone encoding

- python: dragonfly(+1)/gravestone(-1)/neutral(0) and default-flag cases in
  test_known_values
- node: equivalent signed/default assertions in indicators.test.js
- fuzz: drive a signed Doji alongside the default in indicator_update_candle

* docs: document signed candlestick convention and Doji signed mode

README gains a candlestick sign-convention note; CHANGELOG records the new
opt-in Doji signed dragonfly/gravestone encoding under [Unreleased].
2026-06-01 14:37:20 +02:00
89 changed files with 7021 additions and 133 deletions
+3
View File
@@ -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
+13
View File
@@ -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
+9
View File
@@ -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
+167
View File
@@ -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
+7
View File
@@ -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
+124
View File
@@ -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
+8
View File
@@ -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
+162
View File
@@ -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
+7 -1
View File
@@ -62,6 +62,8 @@ jobs:
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'
@@ -75,11 +77,15 @@ jobs:
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
+18 -2
View File
@@ -407,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'
@@ -420,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
@@ -514,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'
@@ -527,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
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
build-mode: none
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
+2 -2
View File
@@ -275,7 +275,7 @@ jobs:
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
@@ -328,7 +328,7 @@ jobs:
- 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
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
id-token: write # OIDC token to publish results to the OpenSSF API
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+7 -3
View File
@@ -10,7 +10,7 @@ name: Sync indicator count
# 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 / public/hero.svg) — push to main / v* tag*
# .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.")
@@ -165,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"
@@ -424,14 +428,14 @@ jobs:
exit 0
fi
cd webpage-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts public/hero.svg
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 public/hero.svg
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)."
+83 -1
View File
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.3] - 2026-06-01
### Added
- **Microstructure family — price impact & depth (part 3).** Indicators over a
trade paired with the prevailing mid (`TradeQuote`) and over the order-book
depth profile, exposed in Rust, Python, Node and WASM:
- **Effective Spread** — `2 · D · (tradePrice mid) / mid · 10_000` bps, the
realised round-trip cost of a single trade against the mid.
- **Realized Spread** — `2 · D · (tradePrice mid_{t+horizon}) / mid_t ·
10_000` bps, the share of the effective spread a liquidity provider keeps
once the mid has moved over a configurable horizon.
- **Kyle's Lambda** — the rolling OLS slope of mid changes on signed volume
(`cov(Δmid, q) / var(q)`), the canonical price-impact / market-depth proxy.
- **Depth Slope** — the mean per-side OLS slope of cumulative resting size
against distance from the mid, measuring how fast the book thickens away
from the touch.
- **Microstructure family — footprint (part 4).** **Footprint** decomposes the
volume traded in a bar across price buckets (`round(price / tick_size)`),
splitting each bucket into buy-initiated (ask) and sell-initiated (bid)
volume. A multi-output, variable-length indicator: every `update` returns the
full footprint accumulated since the last `reset`, exposed in Rust, Python
(`(k, 3)` arrays), Node (`{ price, bidVol, askVol }` rows) and WASM.
## [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
@@ -900,7 +980,9 @@ 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.4.1...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.3...HEAD
[0.4.3]: https://github.com/wickra-lib/wickra/compare/v0.4.2...v0.4.3
[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
+9 -1
View File
@@ -75,7 +75,8 @@ wasm-pack test --node bindings/wasm
| 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) | PyO3 convention: the Python package has no Python runtime dependencies of its own, and its native code is already pinned through the workspace `Cargo.lock`. CI installs build/test tooling (`maturin`, `pytest`, `numpy`, `hypothesis`) directly via `pip`. |
| `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. |
@@ -83,6 +84,13 @@ 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
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.1"
version = "0.4.3"
dependencies = [
"console_error_panic_hook",
"js-sys",
+3 -3
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.1"
version = "0.4.3"
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.4.1" }
wickra-core = { path = "crates/wickra-core", version = "0.4.3" }
thiserror = "2"
rayon = "1.10"
+25
View File
@@ -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)
+11 -4
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://wickra.org/og-banner.webp" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=232" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[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 219 indicators; start at the
every one of the 232 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),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
219 streaming-first indicators across sixteen families. Every one passes the
232 streaming-first indicators across seventeen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -156,9 +156,16 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
| 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.
@@ -230,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 219 indicators
│ ├── wickra-core/ core engine + all 232 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
+1 -1
View File
@@ -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
+198
View File
@@ -896,3 +896,201 @@ 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);
// Depth slope: each side distances 1,2 -> cumulative 1,3 -> OLS slope 2.
assert.ok(Math.abs(new wickra.DepthSlope().update([99, 98], [1, 2], [101, 102], [1, 2]) - 2.0) < 1e-9);
// Single level per side -> no slope -> 0.
assert.equal(new wickra.DepthSlope().update([100], [1], [101], [1]), 0.0);
});
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));
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
// Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(99.95, 1, false, 100.0) - 10.0) < 1e-9);
// A buy filled below the mid is price improvement -> negative.
assert.ok(new wickra.EffectiveSpread().update(99.95, 1, true, 100.0) < 0.0);
});
test('price-impact streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.EffectiveSpread().batch(price, size, isBuy, mid);
const streamer = new wickra.EffectiveSpread();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
assert.ok(Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('realized spread resolves against the future mid', () => {
const rs = new wickra.RealizedSpread(1);
assert.equal(rs.update(100.10, 1, true, 100.0), null); // buffered
// 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps.
assert.ok(Math.abs(rs.update(99.90, 1, false, 100.20) - -20.0) < 1e-9);
});
test('realized spread streaming update matches batch', () => {
const n = 30;
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const batch = new wickra.RealizedSpread(4).batch(price, size, isBuy, mid);
const streamer = new wickra.RealizedSpread(4);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
const got = s === null ? NaN : s;
assert.ok(
(Number.isNaN(got) && Number.isNaN(batch[i])) || Math.abs(got - batch[i]) < 1e-9,
`mismatch at ${i}: ${got} vs ${batch[i]}`,
);
}
});
test("kyle's lambda recovers a constant price-impact slope", () => {
// Each trade moves the mid by exactly 0.5 per unit of signed volume.
const impact = 0.5;
let mid = 100;
const price = [];
const size = [];
const isBuy = [];
const mids = [];
for (let i = 0; i < 20; i++) {
const buy = i % 2 === 0;
const sz = 1 + (i % 3);
const signed = buy ? sz : -sz;
mid += impact * signed;
price.push(mid);
size.push(sz);
isBuy.push(buy);
mids.push(mid);
}
const out = new wickra.KylesLambda(6).batch(price, size, isBuy, mids);
assert.ok(Math.abs(out[out.length - 1] - 0.5) < 1e-9);
});
test('price-impact rejects bad input', () => {
assert.throws(() => new wickra.EffectiveSpread().update(100, 1, true, 0));
assert.throws(() => new wickra.RealizedSpread(0));
assert.throws(() => new wickra.KylesLambda(1));
});
test('footprint buckets buy and sell volume per price level', () => {
const fp = new wickra.Footprint(1.0);
fp.update(100.2, 2, true); // bucket 100 -> ask 2
fp.update(100.7, 3, false); // bucket 101 -> bid 3
const out = fp.update(100.1, 1, true); // bucket 100 -> ask 3
assert.equal(out.length, 2);
assert.deepEqual(
{ price: out[0].price, bidVol: out[0].bidVol, askVol: out[0].askVol },
{ price: 100.0, bidVol: 0.0, askVol: 3.0 },
);
assert.deepEqual(
{ price: out[1].price, bidVol: out[1].bidVol, askVol: out[1].askVol },
{ price: 101.0, bidVol: 3.0, askVol: 0.0 },
);
});
test('footprint streaming update matches batch and rejects bad tick', () => {
const n = 12;
const price = Array.from({ length: n }, (_, i) => 100 + (i % 5) * 0.3);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 3));
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
const batch = new wickra.Footprint(1.0).batch(price, size, isBuy);
const streamer = new wickra.Footprint(1.0);
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.deepEqual(s, batch[i], `mismatch at ${i}`);
}
assert.throws(() => new wickra.Footprint(0));
});
+132 -1
View File
@@ -279,6 +279,19 @@ export interface OpeningRangeValue {
low: number
breakoutDistance: number
}
/** One order-book depth snapshot for batch evaluation. */
export interface ObSnapshot {
bidPx: Array<number>
bidSz: Array<number>
askPx: Array<number>
askSz: Array<number>
}
/** One price bucket of a footprint. */
export interface FootprintLevelValue {
price: number
bidVol: number
askVol: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -2055,12 +2068,13 @@ export declare class OpeningRange {
}
export type DojiNode = Doji
export declare class Doji {
constructor()
constructor(signed?: boolean | undefined | null)
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
isSigned(): boolean
}
export type HammerNode = Hammer
export declare class Hammer {
@@ -2188,6 +2202,123 @@ export declare class ThreeOutside {
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
export declare class OrderBookImbalanceTop1 {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceFullNode = OrderBookImbalanceFull
export declare class OrderBookImbalanceFull {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MicropriceNode = Microprice
export declare class Microprice {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type QuotedSpreadNode = QuotedSpread
export declare class QuotedSpread {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DepthSlopeNode = DepthSlope
export declare class DepthSlope {
constructor()
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTopNNode = OrderBookImbalanceTopN
export declare class OrderBookImbalanceTopN {
constructor(levels: number)
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
batch(snapshots: Array<ObSnapshot>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SignedVolumeNode = SignedVolume
export declare class SignedVolume {
constructor()
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CumulativeVolumeDeltaNode = CumulativeVolumeDelta
export declare class CumulativeVolumeDelta {
constructor()
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TradeImbalanceNode = TradeImbalance
export declare class TradeImbalance {
constructor(window: number)
update(price: number, size: number, isBuy: boolean): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EffectiveSpreadNode = EffectiveSpread
export declare class EffectiveSpread {
constructor()
update(price: number, size: number, isBuy: boolean, mid: number): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RealizedSpreadNode = RealizedSpread
export declare class RealizedSpread {
constructor(horizon: number)
update(price: number, size: number, isBuy: boolean, mid: number): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type KylesLambdaNode = KylesLambda
export declare class KylesLambda {
constructor(window: number)
update(price: number, size: number, isBuy: boolean, mid: number): number | null
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FootprintNode = Footprint
export declare class Footprint {
constructor(tickSize: number)
update(price: number, size: number, isBuy: boolean): Array<FootprintLevelValue>
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<Array<FootprintLevelValue>>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SharpeRatioNode = SharpeRatio
export declare class SharpeRatio {
constructor(period: number, riskFree: number)
+14 -1
View File
@@ -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, 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, 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, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, 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
@@ -515,6 +515,19 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.DepthSlope = DepthSlope
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SignedVolume = SignedVolume
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
module.exports.TradeImbalance = TradeImbalance
module.exports.EffectiveSpread = EffectiveSpread
module.exports.RealizedSpread = RealizedSpread
module.exports.KylesLambda = KylesLambda
module.exports.Footprint = Footprint
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.1",
"version": "0.4.3",
"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"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-x64",
"version": "0.4.1",
"version": "0.4.3",
"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.4.1",
"version": "0.4.3",
"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"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.4.1",
"version": "0.4.3",
"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.4.1",
"version": "0.4.3",
"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.4.1",
"version": "0.4.3",
"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"
},
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.4.1",
"version": "0.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.1",
"version": "0.4.3",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-linux-x64-gnu": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1"
"wickra-darwin-arm64": "0.4.3",
"wickra-darwin-x64": "0.4.3",
"wickra-linux-arm64-gnu": "0.4.3",
"wickra-linux-x64-gnu": "0.4.3",
"wickra-win32-arm64-msvc": "0.4.3",
"wickra-win32-x64-msvc": "0.4.3"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.3.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.3.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.3.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.3.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.3.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.1.tgz",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.3.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.4.1",
"version": "0.4.3",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"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.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-darwin-arm64": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1"
"wickra-linux-x64-gnu": "0.4.3",
"wickra-linux-arm64-gnu": "0.4.3",
"wickra-darwin-x64": "0.4.3",
"wickra-darwin-arm64": "0.4.3",
"wickra-win32-x64-msvc": "0.4.3",
"wickra-win32-arm64-msvc": "0.4.3"
},
"scripts": {
"build": "napi build --platform --release",
+674 -2
View File
@@ -8581,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) => {
@@ -8652,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");
@@ -8680,6 +8754,604 @@ 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");
node_ob_indicator!(DepthSlopeNode, wc::DepthSlope, "DepthSlope");
// 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
}
}
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
// execution. Streaming `update(price, size, isBuy, mid)` takes one such
// trade-quote (`isBuy=true` for a buyer-initiated trade); `batch` takes four
// equal-length arrays.
fn build_trade_quote(
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> napi::Result<wc::TradeQuote> {
let trade = build_trade(price, size, is_buy)?;
wc::TradeQuote::new(trade, mid).map_err(map_err)
}
macro_rules! node_trade_quote_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,
mid: f64,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if price.len() != size.len()
|| size.len() != is_buy.len()
|| is_buy.len() != mid.len()
{
return Err(NapiError::from_reason(
"price, size, is_buy, mid must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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_quote_indicator!(EffectiveSpreadNode, wc::EffectiveSpread, "EffectiveSpread");
// Realized spread carries a `horizon` parameter, so it is hand-written.
#[napi(js_name = "RealizedSpread")]
pub struct RealizedSpreadNode {
inner: wc::RealizedSpread,
}
#[napi]
impl RealizedSpreadNode {
#[napi(constructor)]
pub fn new(horizon: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::RealizedSpread::new(horizon as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
return Err(NapiError::from_reason(
"price, size, is_buy, mid must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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
}
}
// Kyle's lambda carries a `window` parameter, so it is hand-written.
#[napi(js_name = "KylesLambda")]
pub struct KylesLambdaNode {
inner: wc::KylesLambda,
}
#[napi]
impl KylesLambdaNode {
#[napi(constructor)]
pub fn new(window: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::KylesLambda::new(window as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
return Err(NapiError::from_reason(
"price, size, is_buy, mid must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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: Footprint ==============================
//
// Footprint is a multi-output, variable-length indicator. Each `update(price,
// size, isBuy)` returns the full bar footprint accumulated since the last
// `reset()` as an array of `{ price, bidVol, askVol }` rows (sorted ascending
// by price); `batch` returns an array of such arrays, one per trade.
/// One price bucket of a footprint.
#[napi(object)]
pub struct FootprintLevelValue {
pub price: f64,
pub bid_vol: f64,
pub ask_vol: f64,
}
fn footprint_levels(out: &wc::FootprintOutput) -> Vec<FootprintLevelValue> {
out.levels
.iter()
.map(|level| FootprintLevelValue {
price: level.price,
bid_vol: level.bid_vol,
ask_vol: level.ask_vol,
})
.collect()
}
#[napi(js_name = "Footprint")]
pub struct FootprintNode {
inner: wc::Footprint,
}
#[napi]
impl FootprintNode {
#[napi(constructor)]
pub fn new(tick_size: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
) -> napi::Result<Vec<FootprintLevelValue>> {
let out = self
.inner
.update(build_trade(price, size, is_buy)?)
.expect("footprint emits on every trade");
Ok(footprint_levels(&out))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> napi::Result<Vec<Vec<FootprintLevelValue>>> {
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 snapshot = self
.inner
.update(build_trade(price[i], size[i], is_buy[i])?)
.expect("footprint emits on every trade");
out.push(footprint_levels(&snapshot));
}
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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -4,10 +4,10 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.1"
version = "0.4.3"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
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"]
+34
View File
@@ -240,6 +240,23 @@ from ._wickra import (
SpinningTop,
ThreeInside,
ThreeOutside,
# Microstructure: order book
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
Microprice,
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
# Microstructure: price impact
EffectiveSpread,
RealizedSpread,
KylesLambda,
# Microstructure: footprint
Footprint,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -477,6 +494,23 @@ __all__ = [
"SpinningTop",
"ThreeInside",
"ThreeOutside",
# Microstructure: order book
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
# Microstructure: price impact
"EffectiveSpread",
"RealizedSpread",
"KylesLambda",
# Microstructure: footprint
"Footprint",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
+670 -4
View File
@@ -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()),
}
}
@@ -11433,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) => {
@@ -11505,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");
@@ -11533,6 +11615,573 @@ 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");
py_ob_indicator!(PyDepthSlope, wc::DepthSlope, "DepthSlope");
// 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())
}
}
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
// execution. Streaming `update(price, size, is_buy, mid)` takes one such
// trade-quote (`is_buy=True` for a buyer-initiated trade); `batch` takes four
// equal-length arrays.
fn build_trade_quote(price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<wc::TradeQuote> {
let trade = build_trade(price, size, is_buy)?;
wc::TradeQuote::new(trade, mid).map_err(map_err)
}
macro_rules! py_trade_quote_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,
mid: f64,
) -> PyResult<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if price.len() != size.len()
|| size.len() != is_buy.len()
|| is_buy.len() != mid.len()
{
return Err(PyValueError::new_err(
"price, size, is_buy, mid must be equal length",
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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_quote_indicator!(PyEffectiveSpread, wc::EffectiveSpread, "EffectiveSpread");
// Realized spread carries a `horizon` parameter, so it is hand-written.
#[pyclass(
name = "RealizedSpread",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRealizedSpread {
inner: wc::RealizedSpread,
}
#[pymethods]
impl PyRealizedSpread {
#[new]
fn new(horizon: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RealizedSpread::new(horizon).map_err(map_err)?,
})
}
fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
return Err(PyValueError::new_err(
"price, size, is_buy, mid must be equal length",
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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!("RealizedSpread(horizon={})", self.inner.horizon())
}
}
// Kyle's lambda carries a `window` parameter, so it is hand-written.
#[pyclass(name = "KylesLambda", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyKylesLambda {
inner: wc::KylesLambda,
}
#[pymethods]
impl PyKylesLambda {
#[new]
fn new(window: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KylesLambda::new(window).map_err(map_err)?,
})
}
fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<Option<f64>> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
mid: Vec<f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
return Err(PyValueError::new_err(
"price, size, is_buy, mid must be equal length",
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
out.push(self.inner.update(quote).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!("KylesLambda(window={})", self.inner.window())
}
}
// ============================== Microstructure: Footprint ==============================
//
// Footprint is a multi-output, variable-length indicator: each `update(price,
// size, is_buy)` returns the full bar footprint accumulated since the last
// `reset()` as a `(k, 3)` array with columns `[price, bid_vol, ask_vol]`, one
// row per touched price bucket (sorted ascending by price). `batch` returns a
// list of such arrays, one per trade.
fn footprint_to_array<'py>(
py: Python<'py>,
out: &wc::FootprintOutput,
) -> Bound<'py, PyArray2<f64>> {
let rows = out.levels.len();
let mut data = Vec::with_capacity(rows * 3);
for level in &out.levels {
data.push(level.price);
data.push(level.bid_vol);
data.push(level.ask_vol);
}
numpy::ndarray::Array2::from_shape_vec((rows, 3), data)
.expect("shape consistent")
.into_pyarray(py)
}
#[pyclass(name = "Footprint", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyFootprint {
inner: wc::Footprint,
}
#[pymethods]
impl PyFootprint {
#[new]
fn new(tick_size: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
})
}
fn update<'py>(
&mut self,
py: Python<'py>,
price: f64,
size: f64,
is_buy: bool,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let out = self
.inner
.update(build_trade(price, size, is_buy)?)
.expect("footprint emits on every trade");
Ok(footprint_to_array(py, &out))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> PyResult<Vec<Bound<'py, PyArray2<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 snapshot = self
.inner
.update(build_trade(price[i], size[i], is_buy[i])?)
.expect("footprint emits on every trade");
out.push(footprint_to_array(py, &snapshot));
}
Ok(out)
}
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!("Footprint(tick_size={})", self.inner.tick_size())
}
}
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
@@ -12638,6 +13287,23 @@ 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>()?;
m.add_class::<PyDepthSlope>()?;
// Microstructure: trade flow.
m.add_class::<PySignedVolume>()?;
m.add_class::<PyCumulativeVolumeDelta>()?;
m.add_class::<PyTradeImbalance>()?;
// Microstructure: price impact.
m.add_class::<PyEffectiveSpread>()?;
m.add_class::<PyRealizedSpread>()?;
m.add_class::<PyKylesLambda>()?;
// Microstructure: footprint.
m.add_class::<PyFootprint>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
@@ -166,3 +166,75 @@ 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])
def test_effective_spread_non_positive_mid_raises():
with pytest.raises(ValueError):
ta.EffectiveSpread().update(100.0, 1.0, True, 0.0)
def test_effective_spread_batch_unequal_lengths_raise():
with pytest.raises(ValueError):
ta.EffectiveSpread().batch([100.0, 100.0], [1.0, 1.0], [True, False], [100.0])
def test_realized_spread_zero_horizon_raises():
with pytest.raises(ValueError):
ta.RealizedSpread(0)
def test_kyles_lambda_window_below_two_raises():
with pytest.raises(ValueError):
ta.KylesLambda(1)
def test_footprint_non_positive_tick_raises():
with pytest.raises(ValueError):
ta.Footprint(0.0)
with pytest.raises(ValueError):
ta.Footprint(-1.0)
+123
View File
@@ -823,3 +823,126 @@ 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_depth_slope_reference_value():
# Symmetric book, each side distances 1, 2 with cumulative sizes 1, 3.
# OLS slope of (1->1, 2->3) = 2; mean of two equal sides = 2.
ds = ta.DepthSlope()
out = ds.update([99.0, 98.0], [1.0, 2.0], [101.0, 102.0], [1.0, 2.0])
assert out == pytest.approx(2.0, abs=1e-9)
# A book with a single level per side has no slope -> 0.
assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0)
def test_footprint_buckets_buy_and_sell_volume():
fp = ta.Footprint(1.0)
fp.update(100.2, 2.0, True) # bucket 100 -> ask 2
fp.update(100.7, 3.0, False) # bucket 101 -> bid 3
out = fp.update(100.1, 1.0, True) # bucket 100 -> ask 3
# Columns are [price, bid_vol, ask_vol], rows sorted ascending by price.
assert out.shape == (2, 3)
assert list(out[0]) == [100.0, 0.0, 3.0]
assert list(out[1]) == [101.0, 3.0, 0.0]
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)
def test_effective_spread_reference_values():
# Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert ta.EffectiveSpread().update(100.05, 1.0, True, 100.0) == pytest.approx(10.0)
# Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
assert ta.EffectiveSpread().update(99.95, 1.0, False, 100.0) == pytest.approx(10.0)
# A buy filled below the mid is price improvement -> negative.
assert ta.EffectiveSpread().update(99.95, 1.0, True, 100.0) < 0.0
def test_realized_spread_reference_value():
rs = ta.RealizedSpread(1)
assert rs.update(100.10, 1.0, True, 100.0) is None # buffered
# Resolved against mid 100.20 one trade later:
# 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps (adverse selection).
assert rs.update(99.90, 1.0, False, 100.20) == pytest.approx(-20.0)
def test_kyles_lambda_recovers_constant_impact():
# Build a tape where each trade moves the mid by exactly 0.5 per unit of
# signed volume -> the rolling OLS slope is 0.5.
impact = 0.5
mid = 100.0
price, size, is_buy, mids = [], [], [], []
for i in range(20):
buy = i % 2 == 0
sz = 1.0 + (i % 3)
signed = sz if buy else -sz
mid += impact * signed
price.append(mid)
size.append(sz)
is_buy.append(buy)
mids.append(mid)
out = ta.KylesLambda(6).batch(price, size, is_buy, mids)
assert out[-1] == pytest.approx(0.5, abs=1e-9)
+89
View File
@@ -129,3 +129,92 @@ 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(),
ta.DepthSlope(),
]:
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)"
def test_effective_spread_lifecycle():
es = ta.EffectiveSpread()
assert es.warmup_period() == 1
assert not es.is_ready()
es.update(100.05, 1.0, True, 100.0)
assert es.is_ready()
es.reset()
assert not es.is_ready()
def test_realized_spread_lifecycle_and_repr():
rs = ta.RealizedSpread(3)
assert rs.warmup_period() == 4
assert not rs.is_ready()
for _ in range(4):
rs.update(100.0, 1.0, True, 100.0)
assert rs.is_ready()
rs.reset()
assert not rs.is_ready()
assert repr(ta.RealizedSpread(5)) == "RealizedSpread(horizon=5)"
def test_kyles_lambda_lifecycle_and_repr():
kl = ta.KylesLambda(3)
assert kl.warmup_period() == 4
assert not kl.is_ready()
for i in range(4):
kl.update(100.0 + i, 1.0 + (i % 2), i % 2 == 0, 100.0 + i)
assert kl.is_ready()
kl.reset()
assert not kl.is_ready()
assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)"
def test_footprint_lifecycle_and_repr():
fp = ta.Footprint(0.5)
assert fp.warmup_period() == 1
assert not fp.is_ready()
fp.update(100.0, 1.0, True)
assert fp.is_ready()
fp.reset()
assert not fp.is_ready()
assert repr(ta.Footprint(0.25)) == "Footprint(tick_size=0.25)"
@@ -1863,3 +1863,92 @@ 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,
ta.DepthSlope,
):
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)
def test_price_impact_indicators_streaming_equals_batch():
n = 40
mid = np.array([100.0 + 0.5 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
is_buy = [i % 2 == 0 for i in range(n)]
# Aggressive trades print across the mid in the aggressor's direction.
price = np.array(
[mid[i] + (0.02 if is_buy[i] else -0.02) for i in range(n)], dtype=np.float64
)
size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64)
for make in (ta.EffectiveSpread, lambda: ta.RealizedSpread(4), lambda: ta.KylesLambda(5)):
batch = make().batch(price, size, is_buy, mid)
streamer = make()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
dtype=np.float64,
)
assert batch.shape == (n,)
assert _eq_nan(batch, streamed)
def test_footprint_streaming_equals_batch():
n = 20
price = [100.0 + (i % 5) * 0.3 for i in range(n)]
size = [1.0 + (i % 3) for i in range(n)]
is_buy = [i % 2 == 0 for i in range(n)]
batch = ta.Footprint(1.0).batch(price, size, is_buy)
streamer = ta.Footprint(1.0)
assert len(batch) == n
for i in range(n):
streamed = streamer.update(price[i], size[i], is_buy[i])
assert np.array_equal(streamed, batch[i])
+70
View File
@@ -97,3 +97,73 @@ 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(),
ta.DepthSlope(),
]
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
def test_price_impact_indicators_construct_and_emit():
# Price-impact indicators take a trade paired with the prevailing mid.
assert isinstance(ta.EffectiveSpread().update(100.05, 1.0, True, 100.0), float)
# RealizedSpread buffers until its horizon elapses.
assert ta.RealizedSpread(1).update(100.05, 1.0, True, 100.0) is None
def test_price_impact_batch_returns_one_value_per_trade():
price = np.array([100.05, 99.95, 100.10, 99.90])
size = np.array([1.0, 2.0, 1.0, 2.0])
is_buy = [True, False, True, False]
mid = np.full(4, 100.0)
for ind in (ta.EffectiveSpread(), ta.RealizedSpread(2), ta.KylesLambda(2)):
out = ind.batch(price, size, is_buy, mid)
assert out.shape == (4,)
assert out.dtype == np.float64
def test_footprint_constructs_and_emits():
out = ta.Footprint(1.0).update(100.2, 2.0, True)
assert out.shape == (1, 3)
assert out.dtype == np.float64
def test_footprint_batch_returns_list_of_arrays():
res = ta.Footprint(1.0).batch([100.2, 100.7], [2.0, 3.0], [True, False])
assert isinstance(res, list)
assert len(res) == 2
assert res[-1].shape[1] == 3
@@ -201,3 +201,50 @@ 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)
def test_price_impact_streaming_matches_batch():
n = 30
mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
is_buy = [i % 3 != 0 for i in range(n)]
price = np.array(
[mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64
)
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
batch = ta.EffectiveSpread().batch(price, size, is_buy, mid)
streamer = ta.EffectiveSpread()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
+1 -1
View File
@@ -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
+488 -3
View File
@@ -9,7 +9,7 @@
#![allow(clippy::needless_pass_by_value)]
#![allow(missing_debug_implementations)] // wasm_bindgen wrappers expose JS objects, no need for Debug
use js_sys::{Float64Array, Object, Reflect};
use js_sys::{Array, Float64Array, Object, Reflect};
use wasm_bindgen::prelude::*;
use wickra_core as wc;
use wickra_core::{BatchExt, Indicator};
@@ -6167,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) => {
@@ -6234,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);
@@ -6262,6 +6331,422 @@ 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);
wasm_ob_indicator!(WasmDepthSlope, wc::DepthSlope, DepthSlope);
// 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()
}
}
// ============================== Microstructure: Price Impact ==============================
//
// Price-impact indicators consume a trade paired with the mid prevailing at
// execution. Each `update(price, size, isBuy, mid)` takes one such trade-quote
// (`isBuy=true` for a buyer-initiated trade) — the streaming model for a live
// browser trade feed. Batch over a tape is provided by the Python and Node
// bindings.
fn build_trade_quote(
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> Result<wc::TradeQuote, JsError> {
let trade = build_trade(price, size, is_buy)?;
wc::TradeQuote::new(trade, mid).map_err(map_err)
}
macro_rules! wasm_trade_quote_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,
mid: f64,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
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_quote_indicator!(WasmEffectiveSpread, wc::EffectiveSpread, EffectiveSpread);
// Realized spread carries a `horizon` parameter, so it is hand-written.
#[wasm_bindgen(js_name = RealizedSpread)]
pub struct WasmRealizedSpread {
inner: wc::RealizedSpread,
}
#[wasm_bindgen(js_class = RealizedSpread)]
impl WasmRealizedSpread {
#[wasm_bindgen(constructor)]
pub fn new(horizon: usize) -> Result<WasmRealizedSpread, JsError> {
Ok(Self {
inner: wc::RealizedSpread::new(horizon).map_err(map_err)?,
})
}
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
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()
}
}
// Kyle's lambda carries a `window` parameter, so it is hand-written.
#[wasm_bindgen(js_name = KylesLambda)]
pub struct WasmKylesLambda {
inner: wc::KylesLambda,
}
#[wasm_bindgen(js_class = KylesLambda)]
impl WasmKylesLambda {
#[wasm_bindgen(constructor)]
pub fn new(window: usize) -> Result<WasmKylesLambda, JsError> {
Ok(Self {
inner: wc::KylesLambda::new(window).map_err(map_err)?,
})
}
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
mid: f64,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(build_trade_quote(price, size, is_buy, mid)?))
}
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: Footprint ==============================
//
// Footprint is a multi-output, variable-length indicator. Each `update(price,
// size, isBuy)` returns the full bar footprint accumulated since the last
// `reset()` as an array of `{ price, bidVol, askVol }` objects (sorted ascending
// by price) — the streaming model for a live browser trade feed.
#[wasm_bindgen(js_name = Footprint)]
pub struct WasmFootprint {
inner: wc::Footprint,
}
#[wasm_bindgen(js_class = Footprint)]
impl WasmFootprint {
#[wasm_bindgen(constructor)]
pub fn new(tick_size: f64) -> Result<WasmFootprint, JsError> {
Ok(Self {
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<JsValue, JsError> {
let out = self
.inner
.update(build_trade(price, size, is_buy)?)
.expect("footprint emits on every trade");
let levels = Array::new();
for level in &out.levels {
let obj = Object::new();
Reflect::set(&obj, &"price".into(), &level.price.into()).ok();
Reflect::set(&obj, &"bidVol".into(), &level.bid_vol.into()).ok();
Reflect::set(&obj, &"askVol".into(), &level.ask_vol.into()).ok();
levels.push(&obj);
}
Ok(levels.into())
}
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::*;
+1 -1
View File
@@ -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
+12
View File
@@ -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>`.
+127
View File
@@ -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));
}
}
@@ -0,0 +1,258 @@
//! Depth Slope — how fast resting liquidity accumulates away from the mid.
use crate::microstructure::{Level, OrderBook};
use crate::traits::Indicator;
/// Ordinary-least-squares slope of cumulative resting size against distance
/// from the mid, over the levels of one book side.
///
/// `signed_distance` is `+1.0` for the ask side (price above the mid) and
/// `1.0` for the bid side (price below the mid), so the regressor `x` —
/// distance from the mid — is non-negative on both sides. The response `y` is
/// the cumulative size walking outward from the touch. Returns `0.0` for a
/// degenerate fit where every level sits at the same distance (zero variance in
/// `x`).
fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 {
let count = levels.len() as f64;
let mut cumulative = 0.0;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
for level in levels {
let x = signed_distance * (level.price - mid);
cumulative += level.size;
sum_x += x;
sum_y += cumulative;
sum_xy += x * cumulative;
sum_xx += x * x;
}
let denom = count * sum_xx - sum_x * sum_x;
if denom == 0.0 {
return 0.0;
}
(count * sum_xy - sum_x * sum_y) / denom
}
/// Depth Slope — the average rate at which cumulative resting size grows with
/// distance from the mid, across the bid and ask sides of the book.
///
/// For each side the indicator runs an ordinary-least-squares regression of
/// cumulative size (walking outward from the touch) on the level's distance
/// from the mid, then reports the mean of the two slopes:
///
/// ```text
/// slope_side = OLS slope of (|priceᵢ mid|, Σ_{j≤i} sizeⱼ)
/// depthSlope = (slope_bid + slope_ask) / 2
/// ```
///
/// Because the response is *cumulative* size it never decreases with distance,
/// so the slope is non-negative: it is a magnitude, not a direction. A large
/// slope means cumulative liquidity builds quickly away from the touch — a deep
/// book that absorbs large orders with little walking; a small slope is a thin,
/// shallow book. A book whose size is concentrated at the touch and thins out
/// behind it (a fragile book) reads a *smaller* slope than one of equal total
/// depth that thickens with distance.
///
/// A side with fewer than two levels carries no slope, so the indicator returns
/// `0.0` whenever either side has fewer than two levels (including an empty
/// book).
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook};
///
/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …).
/// let book = OrderBook::new(
/// vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()],
/// )
/// .unwrap();
/// let mut ds = DepthSlope::new();
/// assert!(ds.update(book).unwrap() > 0.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct DepthSlope {
has_emitted: bool,
}
impl DepthSlope {
/// Construct a new depth-slope indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for DepthSlope {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let Some(mid) = book.mid() else {
return Some(0.0);
};
if book.bids.len() < 2 || book.asks.len() < 2 {
return Some(0.0);
}
let bid_slope = cumulative_slope(&book.bids, mid, -1.0);
let ask_slope = cumulative_slope(&book.asks, mid, 1.0);
Some(f64::midpoint(bid_slope, ask_slope))
}
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 {
"DepthSlope"
}
}
#[cfg(test)]
mod tests {
use super::*;
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 ds = DepthSlope::new();
assert_eq!(ds.name(), "DepthSlope");
assert_eq!(ds.warmup_period(), 1);
assert!(!ds.is_ready());
}
#[test]
fn thickening_book_has_positive_slope() {
let mut ds = DepthSlope::new();
let out = ds
.update(book(
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
))
.unwrap();
assert!(out > 0.0);
assert!(ds.is_ready());
}
#[test]
fn front_loaded_book_has_smaller_slope_than_back_loaded() {
// Same total depth (6 per side), but one book thickens away from the
// touch and the other thins. Cumulative slope is non-negative for both;
// the back-loaded book accumulates faster, so its slope is larger.
let mut back = DepthSlope::new();
let back_slope = back
.update(book(
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
))
.unwrap();
let mut front = DepthSlope::new();
let front_slope = front
.update(book(
&[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)],
&[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)],
))
.unwrap();
assert!(front_slope >= 0.0);
assert!(back_slope > front_slope);
}
#[test]
fn known_slope_value() {
// Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3.
// OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2.
let mut ds = DepthSlope::new();
let out = ds
.update(book(
&[(99.0, 1.0), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0)],
))
.unwrap();
assert!((out - 2.0).abs() < 1e-9);
}
#[test]
fn single_level_side_is_zero() {
let mut ds = DepthSlope::new();
// Bid side has only one level -> no slope -> 0.
assert_eq!(
ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])),
Some(0.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut ds = DepthSlope::new();
assert_eq!(
ds.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn degenerate_distance_slope_is_zero() {
// Two levels at the same distance from mid carry zero x-variance.
let levels = [
Level::new_unchecked(100.0, 1.0),
Level::new_unchecked(100.0, 2.0),
];
assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let extra = f64::from(i % 4);
book(
&[(99.0, 1.0 + extra), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0 + extra)],
)
})
.collect();
let mut a = DepthSlope::new();
let mut b = DepthSlope::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ds = DepthSlope::new();
ds.update(book(
&[(99.0, 1.0), (98.0, 2.0)],
&[(101.0, 1.0), (102.0, 2.0)],
));
assert!(ds.is_ready());
ds.reset();
assert!(!ds.is_ready());
}
}
+138 -7
View File
@@ -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 highlow 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));
}
}
@@ -0,0 +1,157 @@
//! Effective Spread — the realised cost of a single trade in basis points.
use crate::microstructure::TradeQuote;
use crate::traits::Indicator;
/// Effective Spread — twice the signed deviation of an executed trade price
/// from the prevailing mid, expressed in basis points of the mid.
///
/// ```text
/// effectiveSpread = 2 · D · (tradePrice mid) / mid · 10_000 (bps)
/// ```
///
/// where `D` is the aggressor sign (`+1` for a buy, `1` for a sell). The
/// factor of two scales the one-sided deviation up to a full round-trip cost so
/// it is directly comparable to the [quoted spread]: a marketable order that
/// fills exactly at the touch of an otherwise quoted-spread book pays an
/// effective spread equal to the quoted spread. Trades that fill *inside* the
/// spread (price improvement) read below the quoted spread; trades that walk
/// the book read above it.
///
/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it
/// both yield a positive effective spread — the conventional sign, since the
/// aggressor pays in both cases. A trade printed on the wrong side of the mid
/// for its aggressor flag (a buy below the mid) reads negative, the signature of
/// price improvement or a stale/mislabelled quote.
///
/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first
/// trade-quote.
///
/// [quoted spread]: crate::QuotedSpread
///
/// # Example
///
/// ```
/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote};
///
/// let mut es = EffectiveSpread::new();
/// // Buy filled at 100.05 against a mid of 100.0:
/// // 2 · (+1) · (100.05 100.0) / 100.0 · 10_000 = 10 bps.
/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap();
/// let quote = TradeQuote::new(trade, 100.0).unwrap();
/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone, Default)]
pub struct EffectiveSpread {
has_emitted: bool,
}
impl EffectiveSpread {
/// Construct a new effective-spread indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for EffectiveSpread {
type Input = TradeQuote;
type Output = f64;
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
self.has_emitted = true;
let sign = quote.trade.side.sign();
Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.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 {
"EffectiveSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::{Side, Trade};
use crate::traits::BatchExt;
fn quote(price: f64, side: Side, mid: f64) -> TradeQuote {
TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
}
#[test]
fn accessors_and_metadata() {
let es = EffectiveSpread::new();
assert_eq!(es.name(), "EffectiveSpread");
assert_eq!(es.warmup_period(), 1);
assert!(!es.is_ready());
}
#[test]
fn buy_above_mid_is_positive() {
let mut es = EffectiveSpread::new();
// 2 · (+1) · (100.05 100.0) / 100.0 · 10_000 = 10 bps.
let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap();
assert!((out - 10.0).abs() < 1e-9);
assert!(es.is_ready());
}
#[test]
fn sell_below_mid_is_positive() {
let mut es = EffectiveSpread::new();
// 2 · (1) · (99.95 100.0) / 100.0 · 10_000 = 10 bps.
let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap();
assert!((out - 10.0).abs() < 1e-9);
}
#[test]
fn price_improvement_reads_negative() {
let mut es = EffectiveSpread::new();
// A buy filled below the mid: price improvement -> negative.
let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap();
assert!(out < 0.0);
}
#[test]
fn trade_at_mid_is_zero() {
let mut es = EffectiveSpread::new();
assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let quotes: Vec<TradeQuote> = (0..20)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
let price = 100.0 + f64::from(i % 4) * 0.01;
quote(price, side, 100.0)
})
.collect();
let mut a = EffectiveSpread::new();
let mut b = EffectiveSpread::new();
assert_eq!(
a.batch(&quotes),
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut es = EffectiveSpread::new();
es.update(quote(100.05, Side::Buy, 100.0));
assert!(es.is_ready());
es.reset();
assert!(!es.is_ready());
}
}
@@ -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
///
/// ```
@@ -0,0 +1,259 @@
//! Footprint — buy/sell volume profile per price bucket within a bar.
use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that
/// traded there since the last reset.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FootprintLevel {
/// Bucket price (the bucket index times the tick size).
pub price: f64,
/// Sell-initiated (bid-hitting) volume traded at this bucket.
pub bid_vol: f64,
/// Buy-initiated (ask-lifting) volume traded at this bucket.
pub ask_vol: f64,
}
/// The full footprint of a bar: one [`FootprintLevel`] per touched price
/// bucket, sorted ascending by price.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FootprintOutput {
/// Touched price buckets, lowest price first.
pub levels: Vec<FootprintLevel>,
}
/// Footprint — the buy/sell volume profile of a bar, bucketed by price.
///
/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume
/// traded within a bar across the price levels at which it printed, splitting
/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting)
/// volume. It exposes *where* inside a bar the activity happened and which side
/// was the aggressor there — the basis for absorption, imbalance and
/// point-of-control analysis that a single OHLCV bar hides.
///
/// Each trade is assigned to the price bucket `round(price / tick_size)`; its
/// size is added to that bucket's ask volume for a buy and bid volume for a
/// sell. Every [`update`] returns the complete footprint accumulated since the
/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending
/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh
/// footprint.
///
/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade.
///
/// [`update`]: crate::Indicator::update
/// [`reset`]: crate::Indicator::reset
///
/// # Example
///
/// ```
/// use wickra_core::{Footprint, Indicator, Side, Trade};
///
/// let mut fp = Footprint::new(1.0).unwrap();
/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap());
/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap();
/// // Two buckets: 100 (ask 2) and 101 (bid 3).
/// assert_eq!(out.levels.len(), 2);
/// assert_eq!(out.levels[0].price, 100.0);
/// assert_eq!(out.levels[0].ask_vol, 2.0);
/// assert_eq!(out.levels[1].price, 101.0);
/// assert_eq!(out.levels[1].bid_vol, 3.0);
/// ```
#[derive(Debug, Clone)]
pub struct Footprint {
tick_size: f64,
// bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated).
buckets: BTreeMap<i64, (f64, f64)>,
has_emitted: bool,
}
impl Footprint {
/// Construct a footprint with the given price-bucket `tick_size`.
///
/// # Errors
///
/// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly
/// positive number.
pub fn new(tick_size: f64) -> Result<Self> {
if !tick_size.is_finite() || tick_size <= 0.0 {
return Err(Error::InvalidTick {
message: "footprint tick_size must be finite and positive",
});
}
Ok(Self {
tick_size,
buckets: BTreeMap::new(),
has_emitted: false,
})
}
/// The configured price-bucket size.
pub const fn tick_size(&self) -> f64 {
self.tick_size
}
fn bucket_index(&self, price: f64) -> i64 {
// Float-to-int `as` saturates rather than wrapping, so an extreme
// price/tick ratio clamps to i64::MIN/MAX instead of misbehaving;
// realistic ratios fit comfortably.
#[allow(clippy::cast_possible_truncation)]
{
(price / self.tick_size).round() as i64
}
}
fn snapshot(&self) -> FootprintOutput {
let levels = self
.buckets
.iter()
.map(|(&index, &(bid_vol, ask_vol))| FootprintLevel {
price: index as f64 * self.tick_size,
bid_vol,
ask_vol,
})
.collect();
FootprintOutput { levels }
}
}
impl Indicator for Footprint {
type Input = Trade;
type Output = FootprintOutput;
fn update(&mut self, trade: Trade) -> Option<FootprintOutput> {
self.has_emitted = true;
let index = self.bucket_index(trade.price);
let entry = self.buckets.entry(index).or_insert((0.0, 0.0));
if trade.side.sign() > 0.0 {
entry.1 += trade.size;
} else {
entry.0 += trade.size;
}
Some(self.snapshot())
}
fn reset(&mut self) {
self.buckets.clear();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Footprint"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(price: f64, size: f64, side: Side) -> Trade {
Trade::new(price, size, side, 0).unwrap()
}
#[test]
fn rejects_bad_tick_size() {
assert!(matches!(
Footprint::new(0.0),
Err(Error::InvalidTick { .. })
));
assert!(matches!(
Footprint::new(-1.0),
Err(Error::InvalidTick { .. })
));
assert!(matches!(
Footprint::new(f64::NAN),
Err(Error::InvalidTick { .. })
));
assert!(Footprint::new(0.5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let fp = Footprint::new(0.25).unwrap();
assert_eq!(fp.name(), "Footprint");
assert_eq!(fp.warmup_period(), 1);
assert_eq!(fp.tick_size(), 0.25);
assert!(!fp.is_ready());
}
#[test]
fn buckets_buy_and_sell_volume() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(100.2, 2.0, Side::Buy));
fp.update(trade(100.7, 3.0, Side::Sell));
let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap();
assert!(fp.is_ready());
// Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3.
assert_eq!(out.levels.len(), 2);
assert_eq!(out.levels[0].price, 100.0);
assert_eq!(out.levels[0].ask_vol, 3.0);
assert_eq!(out.levels[0].bid_vol, 0.0);
assert_eq!(out.levels[1].price, 101.0);
assert_eq!(out.levels[1].bid_vol, 3.0);
assert_eq!(out.levels[1].ask_vol, 0.0);
}
#[test]
fn levels_sorted_ascending_by_price() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(103.0, 1.0, Side::Buy));
fp.update(trade(100.0, 1.0, Side::Sell));
let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap();
let prices: Vec<f64> = out.levels.iter().map(|l| l.price).collect();
assert_eq!(prices, vec![100.0, 101.0, 103.0]);
}
#[test]
fn sub_tick_prices_share_a_bucket() {
let mut fp = Footprint::new(0.5).unwrap();
// 100.24 and 100.26 both round to bucket 200 (price 100.0)... check:
// 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct.
fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0
let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200
assert_eq!(out.levels.len(), 1);
assert_eq!(out.levels[0].price, 100.0);
assert_eq!(out.levels[0].ask_vol, 3.0);
}
#[test]
fn reset_clears_the_footprint() {
let mut fp = Footprint::new(1.0).unwrap();
fp.update(trade(100.0, 5.0, Side::Buy));
assert!(fp.is_ready());
fp.reset();
assert!(!fp.is_ready());
let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap();
assert_eq!(out.levels.len(), 1);
assert_eq!(out.levels[0].price, 200.0);
assert_eq!(out.levels[0].bid_vol, 1.0);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..30)
.map(|i| {
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side)
})
.collect();
let mut a = Footprint::new(1.0).unwrap();
let mut b = Footprint::new(1.0).unwrap();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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,281 @@
//! Kyle's Lambda — rolling price impact per unit of signed order flow.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::TradeQuote;
use crate::traits::Indicator;
/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes
/// on signed trade volume, the canonical measure of market depth / price
/// impact.
///
/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at
/// execution. Internally the indicator forms, per trade, the mid change since
/// the previous trade (`Δmid = midₜ midₜ₋₁`) and the signed volume
/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS
/// regression of `Δmid` on `q` over the trailing window of `window` trades:
///
/// ```text
/// cov = (1/n) · Σ q·Δmid q̄·Δ̄mid
/// var = (1/n) · Σ q² q̄²
/// λ = cov / var
/// ```
///
/// `λ` is the estimated price move per unit of signed volume: a deep, liquid
/// book absorbs flow with little movement and reads a small `λ`; a thin book
/// moves sharply per unit traded and reads a large `λ`. It is a direct,
/// model-light proxy for the slope of the demand curve in Kyle's microstructure
/// model.
///
/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`)
/// are maintained as the window slides. A window of constant signed volume has
/// zero variance and `λ` is undefined; the indicator returns `0` in that case
/// rather than producing `NaN`.
///
/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1`
/// trade-quotes: one to seed the previous mid, then `window` paired
/// observations.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote};
///
/// // A book where each trade moves the mid by exactly 0.5 per unit of signed
/// // volume gives λ = 0.5.
/// let mut lambda = KylesLambda::new(8).unwrap();
/// let mut mid = 100.0;
/// let mut last = None;
/// for i in 0..20 {
/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
/// let size = 1.0 + f64::from(i % 3);
/// let signed = size * side.sign();
/// mid += 0.5 * signed;
/// let trade = Trade::new(mid, size, side, 0).unwrap();
/// last = lambda.update(TradeQuote::new(trade, mid).unwrap());
/// }
/// assert!((last.unwrap() - 0.5).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct KylesLambda {
window: usize,
prev_mid: Option<f64>,
pairs: VecDeque<(f64, f64)>,
sum_q: f64,
sum_dm: f64,
sum_qq: f64,
sum_qdm: f64,
}
impl KylesLambda {
/// Construct a rolling Kyle's lambda over `window` paired observations.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression
/// variance needs at least two observations).
pub fn new(window: usize) -> Result<Self> {
if window < 2 {
return Err(Error::InvalidPeriod {
message: "kyle's lambda needs window >= 2",
});
}
Ok(Self {
window,
prev_mid: None,
pairs: VecDeque::with_capacity(window),
sum_q: 0.0,
sum_dm: 0.0,
sum_qq: 0.0,
sum_qdm: 0.0,
})
}
/// The configured window length, in paired observations.
pub const fn window(&self) -> usize {
self.window
}
fn push_pair(&mut self, signed_vol: f64, delta_mid: f64) -> Option<f64> {
if self.pairs.len() == self.window {
let (old_q, old_dm) = self.pairs.pop_front().expect("non-empty");
self.sum_q -= old_q;
self.sum_dm -= old_dm;
self.sum_qq -= old_q * old_q;
self.sum_qdm -= old_q * old_dm;
}
self.pairs.push_back((signed_vol, delta_mid));
self.sum_q += signed_vol;
self.sum_dm += delta_mid;
self.sum_qq += signed_vol * signed_vol;
self.sum_qdm += signed_vol * delta_mid;
if self.pairs.len() < self.window {
return None;
}
let n = self.window as f64;
let mean_q = self.sum_q / n;
let mean_dm = self.sum_dm / n;
let var_q = (self.sum_qq / n - mean_q * mean_q).max(0.0);
let cov = self.sum_qdm / n - mean_q * mean_dm;
if var_q == 0.0 {
// Constant signed-volume window has no defined slope.
return Some(0.0);
}
Some(cov / var_q)
}
}
impl Indicator for KylesLambda {
type Input = TradeQuote;
type Output = f64;
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
let mid = quote.mid;
let signed_vol = quote.trade.size * quote.trade.side.sign();
let Some(prev) = self.prev_mid else {
self.prev_mid = Some(mid);
return None;
};
self.prev_mid = Some(mid);
self.push_pair(signed_vol, mid - prev)
}
fn reset(&mut self) {
self.prev_mid = None;
self.pairs.clear();
self.sum_q = 0.0;
self.sum_dm = 0.0;
self.sum_qq = 0.0;
self.sum_qdm = 0.0;
}
fn warmup_period(&self) -> usize {
self.window + 1
}
fn is_ready(&self) -> bool {
self.pairs.len() == self.window
}
fn name(&self) -> &'static str {
"KylesLambda"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::{Side, Trade};
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn quotes_with_impact(n: usize, impact: f64) -> Vec<TradeQuote> {
let mut mid = 100.0;
(0..n)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
let size = 1.0 + (i % 3) as f64;
let signed = size * side.sign();
mid += impact * signed;
let trade = Trade::new(mid, size, side, 0).unwrap();
TradeQuote::new(trade, mid).unwrap()
})
.collect()
}
#[test]
fn rejects_window_below_two() {
assert!(KylesLambda::new(0).is_err());
assert!(KylesLambda::new(1).is_err());
assert!(KylesLambda::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let kl = KylesLambda::new(14).unwrap();
assert_eq!(kl.name(), "KylesLambda");
assert_eq!(kl.window(), 14);
assert_eq!(kl.warmup_period(), 15);
assert!(!kl.is_ready());
}
#[test]
fn recovers_constant_impact_slope() {
// mid moves exactly 0.5 per unit signed volume -> lambda = 0.5.
let last = KylesLambda::new(6)
.unwrap()
.batch(&quotes_with_impact(20, 0.5))
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.5, epsilon = 1e-9);
}
#[test]
fn negative_impact_reads_negative() {
let last = KylesLambda::new(6)
.unwrap()
.batch(&quotes_with_impact(20, -0.3))
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -0.3, epsilon = 1e-9);
}
#[test]
fn constant_signed_volume_is_zero() {
// Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0.
let mut mid = 100.0;
let quotes: Vec<TradeQuote> = (0..10)
.map(|_| {
mid += 0.01;
let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap();
TradeQuote::new(trade, mid).unwrap()
})
.collect();
let last = KylesLambda::new(5)
.unwrap()
.batch(&quotes)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn warms_up_after_window_plus_one() {
let mut kl = KylesLambda::new(3).unwrap();
let quotes = quotes_with_impact(4, 0.2);
assert_eq!(kl.update(quotes[0]), None); // seeds prev mid
assert_eq!(kl.update(quotes[1]), None);
assert_eq!(kl.update(quotes[2]), None);
assert!(!kl.is_ready());
assert!(kl.update(quotes[3]).is_some());
assert!(kl.is_ready());
}
#[test]
fn batch_equals_streaming() {
let quotes = quotes_with_impact(40, 0.15);
let batch = KylesLambda::new(10).unwrap().batch(&quotes);
let mut kl = KylesLambda::new(10).unwrap();
let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut kl = KylesLambda::new(3).unwrap();
for q in quotes_with_impact(6, 0.2) {
kl.update(q);
}
assert!(kl.is_ready());
kl.reset();
assert!(!kl.is_ready());
assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None);
}
}
@@ -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());
}
}
+45 -1
View File
@@ -47,12 +47,14 @@ mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cvd;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
mod dema;
mod demand_index;
mod demark_pivots;
mod depth_slope;
mod detrended_std_dev;
mod doji;
mod donchian;
@@ -61,6 +63,7 @@ mod double_bollinger;
mod dpo;
mod drawdown_duration;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
mod elder_impulse;
mod ema;
@@ -70,6 +73,7 @@ mod evwma;
mod fama;
mod fibonacci_pivots;
mod fisher_transform;
mod footprint;
mod force_index;
mod fractal_chaos_bands;
mod frama;
@@ -99,6 +103,7 @@ mod keltner;
mod kst;
mod kurtosis;
mod kvo;
mod kyles_lambda;
mod laguerre_rsi;
mod lead_lag_cross_correlation;
mod linreg;
@@ -116,10 +121,14 @@ 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;
@@ -137,7 +146,9 @@ mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod quoted_spread;
mod r_squared;
mod realized_spread;
mod recovery_factor;
mod relative_strength_ab;
mod renko_trailing_stop;
@@ -150,6 +161,7 @@ mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod signed_volume;
mod sine_wave;
mod skewness;
mod sma;
@@ -186,6 +198,7 @@ mod three_inside;
mod three_outside;
mod three_soldiers_or_crows;
mod tii;
mod trade_imbalance;
mod treynor_ratio;
mod trima;
mod trix;
@@ -266,12 +279,14 @@ 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;
pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use depth_slope::DepthSlope;
pub use detrended_std_dev::DetrendedStdDev;
pub use doji::Doji;
pub use donchian::{Donchian, DonchianOutput};
@@ -280,6 +295,7 @@ pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use drawdown_duration::DrawdownDuration;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
@@ -289,6 +305,7 @@ pub use evwma::Evwma;
pub use fama::Fama;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
@@ -318,6 +335,7 @@ pub use keltner::{Keltner, KeltnerOutput};
pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use kyles_lambda::KylesLambda;
pub use laguerre_rsi::LaguerreRsi;
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
pub use linreg::LinearRegression;
@@ -335,10 +353,14 @@ 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};
@@ -356,7 +378,9 @@ 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 realized_spread::RealizedSpread;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_trailing_stop::RenkoTrailingStop;
@@ -369,6 +393,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;
@@ -405,6 +430,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;
@@ -707,6 +733,24 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"ThreeOutside",
],
),
(
"Microstructure",
&[
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"DepthSlope",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
"EffectiveSpread",
"RealizedSpread",
"KylesLambda",
"Footprint",
],
),
(
"Market Profile",
&["ValueArea", "InitialBalance", "OpeningRange"],
@@ -761,6 +805,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, 227, "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());
}
}
@@ -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,204 @@
//! Realized Spread — the post-trade liquidity revenue of a trade in basis
//! points.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::TradeQuote;
use crate::traits::Indicator;
/// Realized Spread — twice the signed deviation of a trade price from the mid
/// that prevails `horizon` trades *later*, expressed in basis points of the
/// trade's contemporaneous mid.
///
/// ```text
/// realizedSpread = 2 · D · (tradePrice mid_{t+horizon}) / mid_t · 10_000 (bps)
/// ```
///
/// where `D` is the aggressor sign (`+1` for a buy, `1` for a sell), `mid_t`
/// is the mid at the time of the trade, and `mid_{t+horizon}` is the mid
/// `horizon` trade-quotes later. Where the [effective spread] measures the full
/// cost paid by the aggressor against the contemporaneous mid, the realized
/// spread measures the share of that cost a liquidity provider *keeps* after
/// the mid has moved: it is the effective spread net of the price impact
/// (`effective = realized + 2 · priceImpact`). A high realized spread means
/// the quote was not picked off; a low or negative one is the signature of
/// adverse selection, the trade preceding a move in its own direction.
///
/// The indicator buffers each incoming trade-quote and emits the realized
/// spread for the trade made `horizon` updates ago, once that future mid is
/// known. It warms up for `horizon + 1` trade-quotes — `update` returns `None`
/// until the first trade can be resolved — and then emits one value per update
/// in O(1).
///
/// `Input = TradeQuote`, `Output = f64`.
///
/// [effective spread]: crate::EffectiveSpread
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RealizedSpread, Side, Trade, TradeQuote};
///
/// let mut rs = RealizedSpread::new(1).unwrap();
/// let tq = |price: f64, side, mid| TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap();
/// // First trade buffered; nothing to resolve yet.
/// assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
/// // One trade later the mid is 100.20, resolving the first buy:
/// // 2 · (+1) · (100.10 100.20) / 100.0 · 10_000 = 20 bps (adverse selection).
/// let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
/// assert!((out - (-20.0)).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RealizedSpread {
horizon: usize,
// Each pending entry is (aggressor sign, trade price, contemporaneous mid).
pending: VecDeque<(f64, f64, f64)>,
has_emitted: bool,
}
impl RealizedSpread {
/// Construct a realized-spread indicator that resolves each trade against
/// the mid `horizon` trade-quotes later.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `horizon` is zero (the realized spread
/// is defined against a strictly future mid).
pub fn new(horizon: usize) -> Result<Self> {
if horizon == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
horizon,
pending: VecDeque::with_capacity(horizon + 1),
has_emitted: false,
})
}
/// The configured horizon, in trade-quotes.
pub const fn horizon(&self) -> usize {
self.horizon
}
}
impl Indicator for RealizedSpread {
type Input = TradeQuote;
type Output = f64;
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
let sign = quote.trade.side.sign();
self.pending.push_back((sign, quote.trade.price, quote.mid));
if self.pending.len() <= self.horizon {
return None;
}
let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1");
self.has_emitted = true;
// `quote.mid` is the mid prevailing `horizon` trades after the resolved
// trade; normalise by that trade's own contemporaneous mid.
Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0)
}
fn reset(&mut self) {
self.pending.clear();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
self.horizon + 1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"RealizedSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::{Side, Trade};
use crate::traits::BatchExt;
fn tq(price: f64, side: Side, mid: f64) -> TradeQuote {
TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
}
#[test]
fn rejects_zero_horizon() {
assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero)));
assert!(RealizedSpread::new(1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rs = RealizedSpread::new(3).unwrap();
assert_eq!(rs.name(), "RealizedSpread");
assert_eq!(rs.horizon(), 3);
assert_eq!(rs.warmup_period(), 4);
assert!(!rs.is_ready());
}
#[test]
fn resolves_against_future_mid() {
let mut rs = RealizedSpread::new(1).unwrap();
assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
assert!(!rs.is_ready());
// 2 · (+1) · (100.10 100.20) / 100.0 · 10_000 = 20 bps.
let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
assert!((out - (-20.0)).abs() < 1e-9);
assert!(rs.is_ready());
}
#[test]
fn no_adverse_move_equals_effective_spread() {
// If the mid does not move over the horizon, realized == effective.
let mut rs = RealizedSpread::new(1).unwrap();
rs.update(tq(100.05, Side::Buy, 100.0));
// mid stays at 100.0 -> 2 · (100.05 100.0) / 100.0 · 10_000 = 10 bps.
let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap();
assert!((out - 10.0).abs() < 1e-9);
}
#[test]
fn longer_horizon_warms_up() {
let mut rs = RealizedSpread::new(3).unwrap();
for _ in 0..3 {
assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None);
}
assert!(!rs.is_ready());
assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some());
assert!(rs.is_ready());
}
#[test]
fn batch_equals_streaming() {
let quotes: Vec<TradeQuote> = (0..30)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
let mid = 100.0 + f64::from(i % 5) * 0.05;
tq(mid + 0.02, side, mid)
})
.collect();
let mut a = RealizedSpread::new(4).unwrap();
let mut b = RealizedSpread::new(4).unwrap();
assert_eq!(
a.batch(&quotes),
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut rs = RealizedSpread::new(1).unwrap();
rs.update(tq(100.05, Side::Buy, 100.0));
rs.update(tq(100.0, Side::Buy, 100.0));
assert!(rs.is_ready());
rs.reset();
assert!(!rs.is_ready());
assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None);
}
}
@@ -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
///
/// ```
+35 -26
View File
@@ -37,6 +37,7 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
mod error;
mod microstructure;
mod ohlcv;
mod traits;
@@ -53,39 +54,47 @@ pub use indicators::{
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
CoefficientOfVariation, Cointegration, CointegrationOutput, 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,
Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, Donchian,
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo,
DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
FisherTransform, Footprint, FootprintOutput, 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, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor,
RelativeStrengthAB, RelativeStrengthOutput, 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,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, 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, RealizedSpread, 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,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
// strips only `*Output` companions) does not count it as a separate indicator.
pub use indicators::FootprintLevel;
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
+467
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+126 -7
View File
@@ -33,13 +33,14 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through
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,
ClassicPivots, ConnorsRsi, DepthSlope, EffectiveSpread, Ema, EmpiricalModeDecomposition,
Engulfing, Frama, HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator,
Jma, KylesLambda, 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,
TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap,
VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3,
};
use wickra_data::csv::CandleReader;
@@ -114,6 +115,72 @@ 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_tradequote_input<I, F, O>(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F)
where
F: Fn() -> I,
I: Indicator<Input = TradeQuote, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(quotes.len());
let series = &quotes[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, quotes| {
b.iter(|| {
let mut ind = make();
for q in quotes {
black_box(ind.update(*q));
}
});
});
}
group.finish();
}
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
where
F: Fn() -> I,
@@ -265,6 +332,58 @@ 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);
bench_orderbook_input(c, "depth_slope", &books, DepthSlope::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()
});
// Pair each synthetic trade with the candle close as the prevailing mid to
// exercise the price-impact family. EffectiveSpread is the stateless
// representative.
let quotes: Vec<TradeQuote> = trades
.iter()
.map(|trade| TradeQuote::new_unchecked(*trade, trade.price))
.collect();
bench_tradequote_input(c, "effective_spread", &quotes, EffectiveSpread::new);
bench_tradequote_input(c, "kyles_lambda", &quotes, || KylesLambda::new(50).unwrap());
}
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.1",
"version": "0.4.3",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-linux-x64-gnu": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1"
"wickra-darwin-arm64": "0.4.3",
"wickra-darwin-x64": "0.4.3",
"wickra-linux-arm64-gnu": "0.4.3",
"wickra-linux-x64-gnu": "0.4.3",
"wickra-win32-arm64-msvc": "0.4.3",
"wickra-win32-x64-msvc": "0.4.3"
}
},
"node_modules/wickra": {
+1 -1
View File
@@ -6,7 +6,7 @@ description = "Runnable Rust examples for the Wickra technical-analysis library.
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
license-file.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
+21
View File
@@ -52,6 +52,27 @@ test = false
doc = false
bench = false
[[bin]]
name = "indicator_update_orderbook"
path = "fuzz_targets/indicator_update_orderbook.rs"
test = false
doc = false
bench = false
[[bin]]
name = "indicator_update_trade"
path = "fuzz_targets/indicator_update_trade.rs"
test = false
doc = false
bench = false
[[bin]]
name = "indicator_update_tradequote"
path = "fuzz_targets/indicator_update_tradequote.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tick_aggregator"
path = "fuzz_targets/tick_aggregator.rs"
@@ -295,6 +295,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Candlestick Patterns (family 14) ---
drive(Doji::new, &candles);
drive(|| Doji::new().signed(), &candles);
drive(Hammer::new, &candles);
drive(InvertedHammer::new, &candles);
drive(HangingMan::new, &candles);
@@ -0,0 +1,55 @@
#![no_main]
//! Fuzz order-book `Indicator<Input = OrderBook>` implementations with
//! arbitrary depth snapshots.
//!
//! Each iteration consumes a byte stream, interprets it as a sequence of
//! `f64` values (8 bytes each), packs consecutive values into `(price, size)`
//! levels, and groups levels into order-book snapshots. Books are built with
//! `OrderBook::new_unchecked` so the fuzzer can explore degenerate shapes
//! (empty sides, crossed books, non-finite prices, negative sizes) that the
//! validating constructor would reject — the indicators must never panic on
//! any of them, streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
BatchExt, DepthSlope, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread,
};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, books: &[OrderBook])
where
I: Indicator<Input = OrderBook, Output = f64> + BatchExt,
{
let mut streaming = make();
for book in books {
let _ = streaming.update(book.clone());
}
let _ = make().batch(books);
}
fuzz_target!(|data: &[u8]| {
let floats: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect();
let levels: Vec<Level> = floats
.chunks_exact(2)
.map(|c| Level::new_unchecked(c[0], c[1]))
.collect();
// Group levels into snapshots of up to four levels (split into bids / asks).
let books: Vec<OrderBook> = levels
.chunks(4)
.map(|chunk| {
let half = chunk.len() / 2;
OrderBook::new_unchecked(chunk[..half].to_vec(), chunk[half..].to_vec())
})
.collect();
drive(OrderBookImbalanceTop1::new, &books);
drive(|| OrderBookImbalanceTopN::new(3).unwrap(), &books);
drive(OrderBookImbalanceFull::new, &books);
drive(Microprice::new, &books);
drive(QuotedSpread::new, &books);
drive(DepthSlope::new, &books);
});
@@ -0,0 +1,55 @@
#![no_main]
//! Fuzz trade-flow `Indicator<Input = Trade>` implementations with arbitrary
//! trade tapes.
//!
//! Each iteration consumes a byte stream, interprets it as a sequence of `f64`
//! values (8 bytes each), and packs consecutive values into `(price, size)`
//! trades whose aggressor side alternates with the sign of the size field.
//! Trades are built with `Trade::new_unchecked` so the fuzzer can explore
//! degenerate values (non-finite, negative) that the validating constructor
//! would reject — the indicators must never panic, streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
BatchExt, CumulativeVolumeDelta, Footprint, Indicator, Side, SignedVolume, Trade,
TradeImbalance,
};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, trades: &[Trade])
where
I: Indicator<Input = Trade, Output = f64> + BatchExt,
{
let mut streaming = make();
for &trade in trades {
let _ = streaming.update(trade);
}
let _ = make().batch(trades);
}
fuzz_target!(|data: &[u8]| {
let floats: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect();
let trades: Vec<Trade> = floats
.chunks_exact(2)
.map(|c| {
let side = if c[1] >= 0.0 { Side::Buy } else { Side::Sell };
Trade::new_unchecked(c[0], c[1], side, 0)
})
.collect();
drive(SignedVolume::new, &trades);
drive(CumulativeVolumeDelta::new, &trades);
drive(|| TradeImbalance::new(5).unwrap(), &trades);
// Footprint emits a variable-length `FootprintOutput` rather than an `f64`,
// so it is driven directly rather than through the scalar-output helper.
let mut footprint = Footprint::new(0.5).unwrap();
for &trade in &trades {
let _ = footprint.update(trade);
}
footprint.reset();
let _ = Footprint::new(0.5).unwrap().batch(&trades);
});
@@ -0,0 +1,47 @@
#![no_main]
//! Fuzz price-impact `Indicator<Input = TradeQuote>` implementations with
//! arbitrary trade-quote tapes.
//!
//! Each iteration consumes a byte stream, interprets it as a sequence of `f64`
//! values (8 bytes each), and packs consecutive triples into `(price, size,
//! mid)` trade-quotes whose aggressor side alternates with the sign of the size
//! field. Trade-quotes are built with the `new_unchecked` constructors so the
//! fuzzer can explore degenerate values (non-finite, negative, zero mid) that
//! the validating constructors would reject — the indicators must never panic,
//! streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
BatchExt, EffectiveSpread, Indicator, KylesLambda, RealizedSpread, Side, Trade, TradeQuote,
};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, quotes: &[TradeQuote])
where
I: Indicator<Input = TradeQuote, Output = f64> + BatchExt,
{
let mut streaming = make();
for &quote in quotes {
let _ = streaming.update(quote);
}
let _ = make().batch(quotes);
}
fuzz_target!(|data: &[u8]| {
let floats: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect();
let quotes: Vec<TradeQuote> = floats
.chunks_exact(3)
.map(|c| {
let side = if c[1] >= 0.0 { Side::Buy } else { Side::Sell };
let trade = Trade::new_unchecked(c[0], c[1], side, 0);
TradeQuote::new_unchecked(trade, c[2])
})
.collect();
drive(EffectiveSpread::new, &quotes);
drive(|| RealizedSpread::new(5).unwrap(), &quotes);
drive(|| KylesLambda::new(5).unwrap(), &quotes);
});
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# Regenerate every committed lockfile in the workspace:
# - Rust: Cargo.lock, fuzz/Cargo.lock (cargo update)
# - Node: bindings/node/package-lock.json (npm install --package-lock-only)
# - Python: .github/requirements/*.txt (uv pip compile --generate-hashes)
#
# Run from anywhere; the script cd's to the repo root itself:
#
# ./scripts/update-lockfiles.sh
#
# The Python locks are hash-pinned (OpenSSF Scorecard PinnedDependencies) and
# generated with uv rather than pip-tools because uv can resolve a *target*
# Python version's full transitive closure — with hashes — without that
# interpreter being installed locally. That is required for the numpy cp39/cp313
# split: numpy ships no single release with wheels for both, so ci-dev is locked
# twice (Python 3.9 and Python 3.10+). If uv is not on PATH the script
# bootstraps a local copy (Linux/macOS); on Windows install uv first
# (https://docs.astral.sh/uv/getting-started/installation/).
#
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
echo "==> Rust (Cargo.lock, fuzz/Cargo.lock)"
cargo update
(cd fuzz && cargo update)
echo "==> Node (bindings/node/package-lock.json)"
(cd bindings/node && npm install --package-lock-only --no-audit --no-fund)
echo "==> Python (.github/requirements/*.txt via uv)"
if ! command -v uv >/dev/null 2>&1; then
echo " uv not found on PATH; bootstrapping a local copy..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
req=".github/requirements"
cc="./scripts/update-lockfiles.sh"
uv pip compile --quiet --python-version 3.9 --generate-hashes --custom-compile-command "$cc" "$req/ci-dev-py39.in" -o "$req/ci-dev-py39.txt"
uv pip compile --quiet --python-version 3.11 --generate-hashes --custom-compile-command "$cc" "$req/ci-dev-py3.in" -o "$req/ci-dev-py3.txt"
uv pip compile --quiet --python-version 3.11 --generate-hashes --custom-compile-command "$cc" "$req/bench.in" -o "$req/bench.txt"
echo "==> Done. Review 'git diff' before committing."