Compare commits

...

40 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
kingchenc 4631519885 release: bump 0.4.0 -> 0.4.1 (#110)
Releases the cross-asset / pairwise indicator family (PR #109):
PairwiseBeta, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration,
RelativeStrengthAB. Indicator count 214 -> 219.

Bumps workspace + binding versions and the CHANGELOG ([Unreleased] ->
[0.4.1]) with the new compare URL.
2026-06-01 13:58:50 +02:00
kingchenc 0b85142ad1 feat: cross-asset / pairwise indicators (5 new) (#109)
* feat(core): add PairwiseBeta cross-asset indicator

Rolling OLS slope of one asset's log-returns on another's. Unlike Beta,
which regresses the raw inputs it is fed, PairwiseBeta differences
consecutive prices into log-returns internally -- the conventional way to
measure cross-asset beta, where a beta on price levels would be dominated
by the shared trend.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with unit/known-value/streaming tests and a pair fuzz target.

* feat(core): add PairSpreadZScore cross-asset indicator

Standardised log-spread ln(a) - beta*ln(b) of a pair, where beta is a
rolling-OLS hedge ratio and the spread is z-scored over its own look-back.
The canonical mean-reversion / statistical-arbitrage entry signal, with
independent beta_period and z_period windows.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with sign/known-value/streaming tests and a pair fuzz target.

* feat(core): add LeadLagCrossCorrelation cross-asset indicator

Reports the integer offset k in [-max_lag, max_lag] that maximises
|corr(a[t], b[t+k])|, answering which of two assets leads the other and by
how many bars. A positive lag means a leads b. Fully causal: a's window is
held centred while b's window slides across the buffered history, so every
lag is evaluated only against data already seen.

Struct output { lag, correlation }, exposed in Rust, Python, Node and WASM
with lead-detection/streaming tests and a pair fuzz driver.

* feat(core): add Cointegration (Engle-Granger + ADF) indicator

Rolling pairs-trading screen: an OLS hedge ratio of a on b, the spread
(residual) a - (alpha + beta*b), and an augmented Dickey-Fuller t-statistic
on the spread with configurable lags. A strongly negative statistic flags a
mean-reverting, tradeable spread. Includes a small Gaussian-elimination
solver for the augmented regression.

Struct output { hedge_ratio, spread, adf_stat }, exposed in Rust, Python,
Node and WASM with stationarity/hedge-ratio/streaming tests and a pair fuzz
driver.

* feat(core): add RelativeStrengthAB cross-asset indicator

Comparative relative strength of two assets: the ratio line a/b together
with its moving average and its RSI, the classic asset-vs-asset /
asset-vs-index rotation screen. Composes the existing Sma and Rsi over the
ratio; a zero denominator or non-finite price is skipped.

Struct output { ratio, ratio_ma, ratio_rsi }, exposed in Rust, Python, Node
and WASM with flat/rising-ratio/streaming tests and a pair fuzz driver.

* test(cointegration): cover ADF guard branches

The ADF helper's short-series and degrees-of-freedom guards and the
zero-dispersion (perfect AR) path are unreachable through the public
Cointegration API (period >= 2*adf_lags + 4), so exercise them with direct
unit tests on adf_no_constant. The second linear solve cannot be singular
once the coefficient solve on the same matrix has succeeded, so it now uses
expect() instead of a dead error branch.
2026-06-01 13:45:21 +02:00
kingchenc 1ab9bc70d1 ci(release): make the release immutability-ready (draft then publish) (#108)
GitHub release immutability locks a release's assets at publish time. The
current flow publishes the release in github-release and only afterwards uploads
the Sigstore provenance bundle (P21.1e) via 'gh release upload', which
immutability would reject (actions/attest-build-provenance#734).

Reorder to draft -> attach everything -> publish:
- github-release now creates the release as a draft (draft: true) with all build
  artefacts.
- attestations attaches the provenance bundle to the draft (gh release upload
  works on drafts), unchanged otherwise.
- a new publish-release job flips the draft to published + latest, gated on
  'always() && needs.github-release.result == success' so a Sigstore hiccup in
  attestations costs only the provenance asset, never the release — the same
  isolation as before. A skipped github-release (failed publish) skips this too.

Correct with immutability off (today: release ends published with every asset)
and on (later, user toggle: all assets present before the lock). No behaviour
removed; nothing deleted.
2026-06-01 12:06:20 +02:00
kingchenc 2ab578bee8 docs: surface docs.wickra.org + keep the wiki pointer count in sync (#107)
* docs(readme): surface the documentation site (docs.wickra.org)

The README never linked the canonical docs at docs.wickra.org — visitors had
no path from the repo to the per-indicator deep dives, quickstarts, and
guides. Add a docs badge, a Documentation section mirroring the binding
READMEs and docs/README.md, and an Indicators-Overview link in the indicator
section.

* ci(sync-about): keep the wiki pointer page's indicator count in sync

The GitHub wiki was collapsed to a single Home.md that points at
docs.wickra.org but still names the indicator count ('… for all N indicators').
Add a count-sync step mirroring the docs/webpage steps — clone wickra.wiki into
its own dir, sed Home.md, commit as wickra-bot, push — with the same
continue-on-error soft-skip so a missing PAT scope never fails the run.
2026-06-01 04:10:00 +02:00
kingchenc 2be39b8b98 ci(release): attach Sigstore provenance bundle as a release asset (P21.1e) (#106)
OpenSSF Scorecard's Signed-Releases check scans the GitHub Release *assets* for
signed/provenance files (`*.intoto.jsonl`, `*.sig`, ...). It does not look at
GitHub's separate attestations store, so although the attestations job has signed
the published bytes since v0.4.0, the v0.4.0 release assets carried no provenance
file and the check stayed at 0.

Attach the Sigstore provenance bundle (already produced by
actions/attest-build-provenance) to the release as `wickra-<tag>.provenance.intoto.jsonl`:

- github-release now exposes its resolved tag as a job output.
- attestations `needs: github-release` (so the Release already exists), gains
  `contents: write`, gives the attest step an id, and uploads the bundle with
  `gh release upload --clobber` (idempotent on re-runs).

Publishes stay fully isolated — cargo/PyPI/npm all run upstream of github-release,
so a Sigstore hiccup here can never block or corrupt a publish; at worst the
release just lacks the provenance asset. Signed-Releases climbs over the next
releases as each tag carries the bundle.
2026-06-01 04:09:09 +02:00
kingchenc 99af5f8ee1 ci: retry transient registry/DNS flakes at the cargo/npm/pip tool level (#105)
The v0.4.0-era CI failure was a runner network blip — `napi build` invokes cargo,
whose fetch of index.crates.io hit "Could not resolve host: index.crates.io" and
failed the Node-on-macOS job, forcing a manual re-run. The earlier flake-hardening
(setup-node/setup-python + rust-cache retries) only covered toolchain download and
cache restore, not the registry fetches inside the actual build/publish steps.

Set tool-level network retries as workflow env so every cargo/napi/maturin/
wasm-pack/npm/pip invocation in every job inherits them — including the nested
cargo calls inside napi/maturin/wasm-pack:

- CARGO_NET_RETRY=10 (default 3): cargo classes DNS-resolve / connect / timeout
  errors as spurious and retries with backoff; 10 attempts ride out a transient
  blip instead of failing the job.
- CARGO_NET_GIT_FETCH_WITH_CLI=true: more robust git-dep fetches.
- npm_config_fetch_retries=5 / maxtimeout=120s: npm ci/install registry retries.
- PIP_RETRIES=5 / PIP_DEFAULT_TIMEOUT=120: pip install resilience.

Applied to ci.yml, release.yml and bench.yml (the workflows that build). No more
manual re-runs for transient registry flakes.
2026-06-01 04:08:18 +02:00
kingchenc bff1148d20 ci(sync-about): fix docs version-sync clone collision + webpage npm race (#104)
* ci(sync-about): fix docs version-sync clone collision + webpage npm race

Two real release-time bugs surfaced by the v0.4.0 release, where the docs
"Published versions" table never updated and the marketing-site Cloudflare
build failed:

1. docs version sync never ran. The "Sync docs version (wickra-docs)" step
   cloned into a directory literally named `docs`, but on a tag push the job
   checks out the wickra repo at the workspace root, which already contains a
   top-level `docs/` directory. `git clone … docs` therefore failed with
   "destination path 'docs' already exists", silenced by `2>/dev/null` and
   misreported as a missing-token warning, so the docs version table stayed at
   the previous release. Clone into `docs-ver` instead (mirrors the `docs-count`
   dir the count step already uses); it collides with nothing in the repo.

2. webpage build broke on a version race. The "Sync webpage version" step bumps
   package.json's `wickra-wasm` pin to the released version and pushes
   immediately, but release.yml publishes wickra-wasm to npm in parallel on the
   same tag and finishes minutes later. Cloudflare's `npm clean-install` then
   hit `ETARGET: No matching version found for wickra-wasm@^0.4.0`. Poll npm for
   wickra-wasm@<version> (up to ~15 min) before committing; if it never appears
   the step skips with a warning rather than pushing a build-breaking commit.

Both steps were designed to mirror each other across docs/webpage; these fixes
restore that symmetry so every release self-heals both sites.

* ci(sync-about): regenerate webpage package-lock on version bump

Third v0.4.0 release-sync defect: the webpage version step seds package.json's
wickra-wasm pin but never touched package-lock.json, so even after wickra-wasm
went live on npm the Cloudflare build still failed with
`npm ci` EUSAGE: "lock file's wickra-wasm@0.3.1 does not satisfy
wickra-wasm@0.4.0".

After the package.json sed, run `npm install --package-lock-only` so the lockfile
(version + resolved + integrity) matches the new pin; commit package-lock.json
alongside package.json. The earlier npm-wait already guarantees the version is
resolvable. Guarded: if the regen fails the step skips the whole commit rather
than push a package.json/lock mismatch.

The live site was unblocked out-of-band by a matching lockfile commit on the
webpage repo; this makes it self-heal on every future release.
2026-06-01 02:02:19 +02:00
kingchenc ebddc5e376 ci: set least-privilege top-level token permissions (P21.1b) (#103)
The auto-injected GITHUB_TOKEN defaulted to write-all in ci.yml, bench.yml
and release.yml (no top-level permissions block), and codeql.yml declared
its scopes only at job level. Add a top-level `permissions: contents: read`
to all four so the token starts read-only and only the jobs that genuinely
write through it raise the scope:

- release.yml: github-release keeps contents: write; node-/wasm-publish and
  attestations keep their id-token / attestations: write blocks. The
  cargo/python/node publish jobs push to crates.io/PyPI/npm via their own
  registry secrets, not the GITHUB_TOKEN, so read-only is correct for them.
- codeql.yml: analyze keeps security-events: write (job level).
- ci.yml / bench.yml: no job writes back to the repo (coverage uploads via
  CODECOV_TOKEN; bench only uploads an artifact), so no job override is needed.

sync-about.yml already had a top-level block but at contents: write; demote
the top level to read and move contents: write down to the single `sync` job
(the PR-head counter push is the only GITHUB_TOKEN write). The cross-repo
About/docs/webpage/org writes are unaffected — they run through the
fine-grained ABOUT_SYNC_TOKEN, which the permissions key does not govern.

Raises OpenSSF Scorecard Token-Permissions from 0 toward 10.
2026-06-01 02:01:31 +02:00
kingchenc eab2649f1c release: bump 0.3.1 -> 0.4.0 (#89)
Minor release. The headline user-facing change is the Node binding now
rejecting invalid indicator periods instead of silently clamping period 0
to 1 (matches Python/WASM/core); plus per-ecosystem binding READMEs and a
corrected MSRV statement in CONTRIBUTING. See CHANGELOG [0.4.0].

- Cargo.toml (workspace.package + wickra-core dep) + Cargo.lock (6 members)
- bindings/python/pyproject.toml
- bindings/node/package.json (version + 6 optionalDependencies) + package-lock.json
- bindings/node/npm/*/package.json (6 platform subpackages)
- examples/node/package-lock.json (wickra-* platform pins)
- CHANGELOG: [Unreleased] -> [0.4.0] - 2026-05-31 + compare URL

No tag pushed (release publish is a separate, user-confirmed step).
2026-06-01 01:28:53 +02:00
kingchenc f7f947e048 docs(changelog): record provenance attestations + CI security tooling (Unreleased) (#102) 2026-06-01 01:08:14 +02:00
kingchenc 01dd08714b ci: harden workflows against network/CDN flakes (resilient cache + setup retries) (#101) 2026-06-01 01:07:56 +02:00
kingchenc 0fa70c9882 ci: pin remaining GitHub Actions to commit SHAs (P21.1c) (#100) 2026-06-01 01:07:34 +02:00
kingchenc debe4523d5 ci: pass untrusted workflow contexts via env to prevent shell injection (P21.1a) (#99) 2026-06-01 00:32:24 +02:00
kingchenc a046c441e5 docs(readme): show the Wickra banner; drop the redundant heading (#98)
Embed the brand banner at the top of the README (linked to wickra.org) and
drop the now-redundant "# Wickra" heading — the banner shows the wordmark.

The image is served from https://wickra.org/og-banner.webp, which the webpage
build regenerates on every deploy from the current indicator count (4K WebP),
so the README banner stays current with no committed binary in this repo and
renders on GitHub, crates.io and docs.rs alike.
2026-06-01 00:07:36 +02:00
kingchenc f7b91f6fa5 chore: use support@wickra.org for contact/author email; drop dead sponsor link (#97)
Now that the wickra.org catch-all mailbox exists, move the project contact +
package-author email off the personal gmail to support@wickra.org across all
surfaces: CODE_OF_CONDUCT, SECURITY, CITATION.cff, Cargo.toml, the npm + PyPI
author fields, the release.yml npm author, and repo-metadata.toml. (The
package-author changes take effect on the next published release.)

repo-metadata.toml's [audit].forbidden still pins kingchencp@gmail.com (the
private commit email) as a banned substring — unchanged.

Also remove the FUNDING.yml custom "https://wickra.org/sponsor" entry: that
page 404s, so the Sponsor button linked to a dead URL. The GitHub Sponsors
entry (github: [kingchenc]) stays.
2026-05-31 23:22:57 +02:00
kingchenc 5030360a0c ci: CodeQL SAST workflow + badge (P13.6) (#96)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)

* ci: add CodeQL SAST workflow + badge (P13.6)
2026-05-31 22:34:00 +02:00
kingchenc 1dd487fabc docs(readme): GitHub release badge (P13.4) (#94)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)
2026-05-31 22:21:05 +02:00
kingchenc cee174c0de ci(release): build-provenance attestations for crates + Python (P13.2) (#91)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)
2026-05-31 22:13:06 +02:00
kingchenc c8e5d8a658 ci: add OpenSSF Scorecard workflow + badge (P13.1) (#90) 2026-05-31 22:05:00 +02:00
kingchenc dc2e19e762 ci(sync-about): self-update the marketing site count + version (P12.1) (#95)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)

* ci(sync-about): self-update the marketing site (webpage) count + version (P12.1)
2026-05-31 22:04:32 +02:00
kingchenc d8212beff6 ci(sync-about): retarget count sync to wickra-docs + point About at docs.wickra.org (P8.3) (#88)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)
2026-05-31 21:57:42 +02:00
kingchenc 86a595d505 ci(sync-about): auto-sync the published version to wickra-docs on release (#87) 2026-05-31 21:48:53 +02:00
kingchenc 9309bf9d60 docs(P6.4): repoint all doc links from the GitHub wiki to docs.wickra.org (#86)
The documentation now lives in the wickra-lib/wickra-docs VitePress repo and
will deploy to docs.wickra.org. Rewrite every tracked, user-facing wiki link
in the main repo to the new canonical site:

- docs/README.md, CONTRIBUTING.md, PULL_REQUEST_TEMPLATE.md
- bindings/{node,python,wasm}/README.md, examples/wasm/README.md
- repo-metadata.toml: wiki_url/wiki_git -> docs_url/docs_git

Page paths map 1:1 to VitePress clean URLs (e.g. /wiki/Quickstart-Rust.md ->
docs.wickra.org/Quickstart-Rust). The sync-about.yml wiki-sync step is left
untouched on purpose: it stays until the docs site is live (tracked as P8.3).
The GitHub wiki itself is not deleted yet for the same reason.
2026-05-31 21:48:24 +02:00
kingchenc 3f05342f72 docs(P7): per-ecosystem binding READMEs + correct MSRV documentation (#85)
* docs(contributing): correct MSRV to 1.86/1.88 and document the dep-forced floor (P7.1)

* docs(bindings): trim binding READMEs to per-ecosystem install + links (P7.2)

* docs(changelog): note per-ecosystem binding READMEs + MSRV doc fix (P7.1/P7.2)
2026-05-31 05:30:34 +02:00
kingchenc eb4454ab27 P5: track index.d.ts + reject invalid periods in the Node binding (#83)
* build(node): track the generated index.d.ts (P5.1)

index.js was committed but index.d.ts was gitignored, an inconsistency that
also contradicts CONTRIBUTING ('regenerate both .d.ts/.js when a binding API
changes'). Track index.d.ts too so the repo carries the TypeScript types as a
matched pair with index.js. Generated by napi build; ~214 indicator classes.

* fix(node): reject invalid periods instead of clamping them (P5.4)

The Node scalar-indicator macro clamped period 0 to 1 (via clamp_period + must)
and the multi-parameter constructors did the same, silently swallowing the
core's PeriodZero validation. The core rejects period 0 (Error::PeriodZero),
and the Python and WASM bindings already propagate it — Node was the outlier,
masking caller mistakes. Make the macro constructor fallible and let every
constructor propagate the core error via map_err, removing clamp_period/must.
Update the smoke test (period 0 now throws, matching core/Python/WASM).

* docs(changelog): note the Node period-validation change (P5.4)

Behavior change per CONTRIBUTING: Node constructors now reject invalid periods
instead of clamping. Add an [Unreleased] Changed entry.

* chore: drop accidentally committed scratch log
2026-05-31 05:22:56 +02:00
kingchenc 3093f194a2 docs: document the repo-wide lockfile policy (P4) (#82)
The .gitignore comment claimed package-lock.json is committed only under
bindings/node/, but examples/node/package-lock.json has also been tracked
since #80. Correct the comment and add a CONTRIBUTING 'Lockfile policy'
section spelling out every component: Cargo.lock + the two Node package-locks
are tracked; fuzz/Cargo.lock is ignored (cargo-fuzz default); the Python
package has no lockfile by PyO3 convention (pinned via Cargo.lock); the
ghost-ignored site keeps its lockfile local.
2026-05-31 05:11:08 +02:00
kingchenc 21c86f348f examples + bindings: Node/WASM strategy parity + test/benchmark parity (P2 + P3) (#81)
* examples(node): add RSI mean-reversion strategy

Node counterpart of strategy_rsi_mean_reversion.{py,rs}: RSI(14) < 30 long,
> 70 exit, 0.1% fees, hourly BTCUSDT. Output verified byte-identical to the
Rust reference (37 trades W24/L13, -17.84% return, 46.89% max drawdown).

* examples(node): add MACD + ADX trend-filter strategy

Node counterpart of strategy_macd_adx.{py,rs}: MACD(12,26,9) histogram
crossover entries gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (246 trades W90/L156, -47.19%
return, 53.75% max drawdown).

* examples(node): add Bollinger-squeeze breakout strategy

Node counterpart of strategy_bollinger_squeeze.{py,rs}: enter on a fresh
180-bar Bollinger-bandwidth low + close above the upper band, exit on a
2*ATR(14) stop or upper-band collapse, daily BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (1 trade, -7.82% return,
13.01% max drawdown).

* examples(wasm): add RSI mean-reversion strategy demo

Browser counterpart of strategy_rsi_mean_reversion.{py,js,rs}: RSI(14) < 30
long, > 70 exit, 0.1% fees, summary table. Same signal/fill/PnL/equity loop as
the runtime-verified Node example; loads via the established wickra_wasm.js
init + fetch-CSV pattern. (wasm32 build runs in CI.)

* examples(wasm): add MACD + ADX trend-filter strategy demo

Browser counterpart of strategy_macd_adx.{py,js,rs}: MACD(12,26,9) histogram
crossover gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Logic identical to
the runtime-verified Node example; standard wickra_wasm.js init + fetch-CSV
loader. (wasm32 build runs in CI.)

* examples(wasm): add Bollinger-squeeze breakout strategy demo

Browser counterpart of strategy_bollinger_squeeze.{py,js,rs}: fresh 180-bar
Bollinger-bandwidth low + upper-band breakout, 2*ATR(14) stop, daily BTCUSDT,
0.1% fees. Logic identical to the runtime-verified Node example; standard
wickra_wasm.js init + fetch-CSV loader. (wasm32 build runs in CI.)

* ci: add examples syntax-smoke job (P2.3)

The Rust examples are built by 'cargo build -p wickra-examples --bins'; the
Node, browser-WASM and Python examples had no build gate. New job parse-checks
every examples/{node,wasm}/*.js, extracts and node --checks each WASM .html
module script, and python -m py_compile's every examples/python/*.py — so a
broken example edit fails CI instead of landing silently.

* docs(examples): list the new Node + WASM strategy examples

Add the three Node strategy scripts and three WASM strategy demos to the
examples README tables, bringing Node and WASM to parity with the existing
Rust and Python strategy rows.

* chore(examples): refresh examples/node lockfile for the linked wickra binding

npm install rewrote the file: dependency snapshot of the local wickra binding
that the examples link against (version 0.1.4 -> 0.3.1, license + engines
fields), which had gone stale in the committed lockfile.

* test(node): add input-validation suite

Node counterpart of bindings/python/tests/test_input_validation.py: invalid
constructor parameters (ATR zero period, MACD non-increasing fast/slow,
BollingerBands negative multiplier, PSAR step > max, ValueArea period/pct,
InitialBalance/OpeningRange zero period, Ichimoku non-increasing periods,
Ehlers-family ordering) and unequal-length candle/ValueArea batch inputs all
throw a JS Error. Validated against the built binding.

* test(node): add indicator completeness contract

Introspects every exported indicator class and asserts the full interface
(update / batch / reset / isReady / warmupPeriod) plus the pre-warmup contract
for zero-arg indicators, and guards that the full catalogue (>= 200 classes)
is exported. Catches a new indicator wired without the standard methods, or a
stale/partial native build dropping exports, with no per-indicator boilerplate.

* test(wasm): broaden scalar streaming-vs-batch coverage

Extend the inline wasm-bindgen-test suite with a streaming==batch check across
~70 scalar indicators spanning moving averages, momentum, volatility,
statistics/regression, Ehlers/cycle and risk/performance families (previously
only EMA + the candle-input group were covered per-indicator), plus four more
invalid-constructor assertions. Constructor args mirror the CI-passing Node
factories. Host-compiles (cargo test -p wickra-wasm --no-run); executed in CI
via wasm-pack test --node.

* bench(node): add indicator throughput benchmark

Node counterpart of the Rust criterion benches / Python compare_libraries:
measures streaming (per-tick update) and batch throughput in Mupd/s across a
representative indicator set over a synthetic OHLCV series (--bars, default
200k). Dependency-free; wired as 'npm run bench'.

* docs(wasm): list strategy demos + document the benchmark story

Add the three new strategy demos to the WASM examples table and a Performance
section: parallel_assets.html is the in-browser benchmark, with raw throughput
covered by the Rust criterion / Python / Node benchmarks (the WASM engine is the
same core compiled to wasm32).
2026-05-31 05:09:26 +02:00
kingchenc a2ff35f5f9 ci(sync-about): auto-sync repo homepage + org profile from the indicator count (#84)
Extend the count-sync workflow to drive three more surfaces from the same
single count, so the org page never drifts again:

- Repo About **homepage** URL — enforced unconditionally via
  `gh repo edit --homepage` in the existing About step (fixes the stale
  kingchenc URL and self-heals). Uses the same Administration-write
  permission as --description, so NO extra token scope.
- Org **profile README** count (wickra-lib/.github, profile/README.md) —
  clone + sed + bot commit/push, same pattern as the Sync Wiki step.
- Org **description** field ('… N indicators, install-free.') — gh api PATCH.

The two org steps need ABOUT_SYNC_TOKEN scope the main-repo syncs do not
(write on wickra-lib/.github; admin:org for the description PATCH). Both are
written to soft-skip with a ::warning:: and continue-on-error, so the run
stays green until the scope is granted — then they sync with no further
change. Homepage swap-point to a docs domain is a one-line change.

Refs findings P9 / P10.
2026-05-31 03:02:32 +02:00
119 changed files with 15070 additions and 1198 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
-1
View File
@@ -3,4 +3,3 @@
# Leave a key empty (e.g. patreon:) to skip a platform.
github: [kingchenc]
custom: ["https://wickra.org/sponsor"]
+3 -3
View File
@@ -24,9 +24,9 @@
- [ ] New behaviour has tests; bug fixes have a regression test.
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
and their type stubs (If applicable).
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
and the `README.md` are updated (If applicable). Wiki edits go to a
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
and the `README.md` are updated (If applicable). Docs edits go to a
separate repository: `https://github.com/wickra-lib/wickra-docs`.
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
+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
+50 -3
View File
@@ -22,8 +22,26 @@ on:
required: false
default: "10"
# Least-privilege default for the auto-injected GITHUB_TOKEN. The single job
# only builds and uploads an artifact (upload-artifact uses the artifact
# storage API, not the contents scope), so it never needs repo write (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / PyPI inside any build step (cargo,
# maturin, pip) retries automatically instead of failing the job. Cargo treats
# "couldn't resolve host" / connect / timeout as spurious and retries with
# backoff; 10 attempts ride out a transient DNS blip on a runner.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
cross-library-bench:
@@ -35,16 +53,39 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: .github/requirements/bench.txt
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: .github/requirements/bench.txt
- name: Install Python deps + peer libs
run: |
python -m pip install --upgrade pip
python -m pip install maturin numpy pandas talipp finta
# Hash-locked deps (OpenSSF Scorecard PinnedDependencies). bench.yml
# runs on a single Python version (3.11), so one lock file suffices.
python -m pip install --require-hashes -r .github/requirements/bench.txt
- name: Build Wickra wheel
working-directory: bindings/python
@@ -56,10 +97,16 @@ jobs:
- name: Run cross-library benchmark
working-directory: bindings/python
# workflow_dispatch inputs are untrusted; pass them through the
# environment and quote them rather than interpolating into the shell
# command (OpenSSF Scorecard: Dangerous-Workflow).
env:
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
run: |
python -m benchmarks.compare_libraries \
--size ${{ github.event.inputs.size || '20000' }} \
--iterations ${{ github.event.inputs.iterations || '10' }} \
--size "$BENCH_SIZE" \
--iterations "$BENCH_ITERATIONS" \
--streaming-window 5000 --streaming-iterations 2 \
| tee benchmark.txt
+176 -2
View File
@@ -6,9 +6,29 @@ on:
pull_request:
branches: [main]
# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
# napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
# failing the job and needing a manual re-run. Cargo treats "couldn't resolve
# host" / connect / timeout as spurious and retries with backoff; 10 attempts
# ride out a transient DNS blip on a runner. Complements the setup-action /
# cache retries (which only covered toolchain download + cache restore).
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
rust:
@@ -28,6 +48,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Format check
run: cargo fmt --all -- --check
@@ -55,6 +77,95 @@ jobs:
# streaming.
run: cargo build -p wickra-examples --bins
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
# browser-WASM and Python examples otherwise have no build gate, so a broken
# edit could land unnoticed. This is a parse-only smoke — actually running the
# examples needs the built native binding / wasm module / wheel, which the
# binding jobs provide separately.
examples-smoke:
name: Examples (syntax smoke)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Node examples — syntax check
run: |
shopt -s nullglob
count=0
for f in examples/node/*.js examples/wasm/*.js; do
echo "node --check $f"
node --check "$f"
count=$((count + 1))
done
echo "checked $count Node/WASM .js files"
- name: WASM demo module scripts — syntax check
# The .html demos embed an ES module; extract it and parse-check so a
# broken edit to the in-page strategy logic fails CI.
run: |
shopt -s nullglob
count=0
for f in examples/wasm/*.html; do
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
echo "node --check (module of) $f"
node --check module-check.mjs
count=$((count + 1))
done
rm -f module-check.mjs
echo "checked $count WASM .html module scripts"
- name: Python examples — byte-compile
run: |
shopt -s nullglob
count=0
for f in examples/python/*.py; do
echo "py_compile $f"
python -m py_compile "$f"
count=$((count + 1))
done
echo "compiled $count Python files"
# Clippy for the Python and Node bindings. These are kept out of the main
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
@@ -71,17 +182,49 @@ jobs:
components: clippy
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Clippy (bindings, all targets)
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
@@ -118,6 +261,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Build on MSRV
run: cargo build ${{ matrix.packages }} --verbose
@@ -139,9 +284,12 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-llvm-cov
@@ -191,6 +339,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
with:
workspaces: fuzz
@@ -203,6 +353,7 @@ jobs:
# never gets off the ground. The prebuilt binary avoids the entire
# transitive-dep compile.
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-fuzz
@@ -242,6 +393,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# setup-python downloads the interpreter from the Actions tool cache /
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
@@ -254,6 +407,8 @@ jobs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: .github/requirements/ci-dev-*.txt
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
@@ -267,11 +422,21 @@ jobs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: .github/requirements/ci-dev-*.txt
- name: Install Python dev dependencies
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install maturin pytest numpy hypothesis
# Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
# Split by Python version: numpy ships no single release with wheels
# for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
if [ "${{ matrix.python-version }}" = "3.9" ]; then
python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
else
python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
fi
- name: Build wheel
working-directory: bindings/python
@@ -304,6 +469,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install wasm-pack
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
@@ -314,6 +481,7 @@ jobs:
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
# release, which has `--features` as a top-level flag (since 0.12).
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
@@ -345,6 +513,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# setup-node downloads Node from nodejs.org and we've seen it fail on
# Windows runners with "Attempting to download 18..." followed by a
@@ -356,6 +526,8 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: bindings/node/package-lock.json
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
@@ -369,10 +541,12 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: bindings/node/package-lock.json
- name: Install Node dependencies
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
+54
View File
@@ -0,0 +1,54 @@
name: CodeQL
# Static analysis security testing (findings P13.x). Analyses the Rust core and
# the Python / JavaScript binding surfaces with GitHub's CodeQL engine. Results
# appear under Security → Code scanning. `build-mode: none` analyses source
# directly — no compilation step — for every language here.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '31 3 * * 0' # Sundays 03:31 UTC
# Least-privilege default for the auto-injected GITHUB_TOKEN. The analyze job
# raises exactly the scopes CodeQL needs (security-events: write to upload
# results) in its own job-level block below; this top-level read-only default
# covers any future job (OpenSSF Scorecard: Token-Permissions).
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write # upload CodeQL results to code-scanning
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: rust
build-mode: none
- language: python
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
category: "/language:${{ matrix.language }}"
+222 -10
View File
@@ -5,8 +5,30 @@ on:
tags: ["v*"]
workflow_dispatch:
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
# (cargo/python/node) push to external registries via their own secrets
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
# they need no repo write. The jobs that genuinely write through the
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
# attestations (id-token / attestations: write) — declare those rights in their
# own job-level permissions blocks, which override this default (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm inside any build or publish step
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
# --------------------------------------------------------------------------
@@ -26,6 +48,8 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# Idempotent publishing: if the version is already on crates.io we
# treat that as success so re-runs of the workflow don't fail.
@@ -85,6 +109,7 @@ jobs:
# re-resolving Cargo.lock.
- name: Install cargo-cyclonedx
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-cyclonedx
@@ -131,7 +156,21 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Sync root README into bindings/python so it ships with the wheel
@@ -206,7 +245,23 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
@@ -215,10 +270,12 @@ jobs:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
@@ -247,14 +304,31 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Download all platform binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -397,7 +471,24 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
@@ -410,6 +501,7 @@ jobs:
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
@@ -428,7 +520,7 @@ jobs:
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json'));
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
pkg.author = 'kingchenc <support@wickra.org>';
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
pkg.homepage = 'https://github.com/wickra-lib/wickra';
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
@@ -457,13 +549,24 @@ jobs:
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
#
# The release is created as a DRAFT here and only flipped to published by the
# downstream publish-release job, after the provenance bundle is attached. That
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
# with GitHub release immutability, which locks assets at publish time (P24):
# the old "publish, then upload provenance" order would have the provenance
# upload rejected once immutability is enabled.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the GitHub Release
name: Attach assets to the draft GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Expose the resolved tag so the attestations job can attach the provenance
# bundle to this same release without re-resolving it.
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -508,7 +611,7 @@ jobs:
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
- name: Create / update GitHub Release with assets
- name: Create / update the draft GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
@@ -516,6 +619,9 @@ jobs:
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
# Created as a draft; publish-release flips it to published + latest once
# the provenance bundle is attached (P24, immutability-ready).
draft: true
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
@@ -541,4 +647,110 @@ jobs:
### Auto-generated changelog
See below; GitHub computes it from the commits since the previous tag.
See below; GitHub computes it from the commits since the previous tag.
# --------------------------------------------------------------------------
# Build provenance attestations (findings P13.2)
# --------------------------------------------------------------------------
attestations:
name: Attest build provenance
needs: [cargo-publish, python-wheels, python-sdist, github-release]
runs-on: ubuntu-latest
# Signed SLSA build-provenance attestations for the published crates and
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
# from `npm publish --provenance`, so they are covered there.
#
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
# of github-release, so a Sigstore hiccup here can never block or corrupt a
# publish (the isolation the SBOM step lacked before #79). It additionally
# `needs: github-release` so the (still-draft) GitHub Release already exists
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
# not GitHub's separate attestations store, so the bundle has to live on the
# release. The release is published afterwards by the publish-release job
# whether or not this attestation succeeds (P24), so a failure here still only
# costs the provenance asset, never the release.
permissions:
id-token: write # OIDC for keyless Sigstore signing
attestations: write # write the attestations to this repo
contents: write # upload the provenance bundle as a release asset
steps:
- name: Download crate files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: crate-files
path: artifacts/crates
- name: Download wheels + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: wheels-*
path: artifacts/python
merge-multiple: true
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/crates/*.crate
artifacts/python/*.whl
artifacts/python/*.tar.gz
# Attach the Sigstore provenance bundle to the GitHub Release as a
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
# signed provenance on the release itself (P21.1e). attest-build-provenance
# writes a single JSONL bundle covering every subject above; copy it to a
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
# idempotent. github.token has contents: write here, which is all gh needs.
- name: Attach provenance bundle to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot attach provenance."
exit 1
fi
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
echo "::error::attestation bundle not found at '$BUNDLE'."
exit 1
fi
dest="wickra-${TAG}.provenance.intoto.jsonl"
cp "$BUNDLE" "$dest"
echo "Uploading $dest to release $TAG"
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
# --------------------------------------------------------------------------
# Publish the drafted release LAST (P24 — immutability-ready).
#
# github-release creates the release as a draft and attestations attaches the
# provenance bundle to it; only now, with every asset in place, is it flipped to
# published + latest. With GitHub release immutability enabled, assets lock at
# this publish step — so the provenance bundle and every build artefact are
# already present and never need a (rejected) post-publish upload.
#
# `if: always() && needs.github-release.result == 'success'` preserves the old
# robustness: the release is published whenever the draft was created, even if
# the attestations job hit a Sigstore hiccup — that only costs the provenance
# asset, exactly as before. If github-release was skipped (a publish job failed)
# there is no draft, so this is skipped too and no release is published.
# --------------------------------------------------------------------------
publish-release:
name: Publish the GitHub Release
needs: [github-release, attestations]
if: always() && needs.github-release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # flip the draft release to published
steps:
- name: Flip the draft release to published (latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot publish."
exit 1
fi
echo "::notice::publishing release $TAG (draft -> published, latest)"
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
+49
View File
@@ -0,0 +1,49 @@
name: OpenSSF Scorecard
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
# schedule, on branch-protection changes, and on push to main. `publish_results`
# uploads the score to the public OpenSSF API so the README badge resolves, and
# the SARIF is surfaced under the repo's Security → Code scanning tab.
on:
branch_protection_rule:
schedule:
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
push:
branches: [main]
workflow_dispatch:
# Read-only by default; the analysis job widens to exactly what it needs.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload the SARIF result to code-scanning
id-token: write # OIDC token to publish results to the OpenSSF API
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
- name: Upload SARIF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
sarif_file: results.sarif
+353 -25
View File
@@ -7,9 +7,34 @@ name: Sync indicator count
#
# 1. README.md prose — synced on PR branches (this workflow)
# 2. GitHub repo "About" description — synced on push to main / v* tag
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
# — synced on push to main / v* tag
# 4. site/index.md (local-only marketing site, not synced from CI)
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
# 4. Marketing site count (wickra-lib/webpage: index.md /
# .vitepress/config.ts) — push to main / v* tag*
# 5. org profile README count (wickra-lib/.github, profile/README.md)
# — synced on push to main / v* tag*
# 6. org description ("… N indicators, install-free.")
# — synced on push to main / v* tag*
# 7. docs site published version (wickra-lib/wickra-docs: the
# "Published versions" table in overview.md + the Rust quickstart prose)
# — synced on v* tag only*
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
# nav version label, and the wickra-wasm dep) — synced on v* tag only*
# 9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
# collapsed to a single page that points at docs.wickra.org but still names
# the count) — synced on push to main / v* tag*
#
# *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
# wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
# need write on wickra-lib/.github and admin:org for the org-description PATCH;
# surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
# repo's permission).
# Until that scope is granted these steps emit a ::warning:: and soft-skip —
# they never fail the run. The repo "About" homepage URL is also enforced in
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
#
# Note: surface 7 carries the release *version*, not the indicator count, so
# it is driven by the v* tag (which is the version) rather than the count.
#
# We count public types (not `mod xxx;` lines) because some modules export
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
@@ -35,18 +60,24 @@ on:
types: [opened, synchronize, reopened]
workflow_dispatch:
# `contents: write` is needed so the workflow can push the counter
# fix-up commit to the PR head branch via the auto-provided
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
# write` the workflow needs — to push the counter fix-up commit to the PR head
# branch — is raised at the job level below, not here, so the top-level default
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
# docs / webpage / org writes still go through the fine-grained PAT
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
permissions:
contents: write
contents: read
pull-requests: read
jobs:
sync:
runs-on: ubuntu-latest
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
permissions:
contents: write
pull-requests: read
steps:
# On PRs from forks the head ref lives in another repo; pushing
# back to it from this workflow is blocked by GitHub. We still
@@ -54,11 +85,20 @@ jobs:
# falls back to a hard failure when push isn't possible.
- name: Determine if push to PR head is possible
id: ctx
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
# controlled on fork PRs) are passed through the environment, never
# interpolated straight into the shell, so a crafted branch name cannot
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
if [ "$EVENT_NAME" = "pull_request" ]; then
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
echo "can_push=true" >> "$GITHUB_OUTPUT"
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
else
echo "can_push=false" >> "$GITHUB_OUTPUT"
echo "head_ref=" >> "$GITHUB_OUTPUT"
@@ -72,7 +112,7 @@ jobs:
# any fix-up commit we make goes onto the PR branch itself. On
# push events we check out the default ref. fetch-depth: 0 lets
# us push back without "shallow update not allowed".
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
@@ -125,6 +165,10 @@ jobs:
run: |
n="${{ steps.count.outputs.count }}"
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
# profile image (regenerated with the new count by .github/banner.yml)
# instead of serving a stale cached copy.
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
if git diff --quiet; then
echo "No README changes after sed (counter regex did not match anything); skipping push."
echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -134,12 +178,18 @@ jobs:
- name: Commit & push counter fix to PR head
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
# head_ref still carries the (untrusted) PR branch name forwarded by the
# ctx step; pass it through the environment so the push refspec cannot be
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
COUNT: ${{ steps.count.outputs.count }}
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
run: |
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add README.md
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
git commit -m "chore: sync indicator count to ${COUNT}"
git push origin "HEAD:${HEAD_REF}"
# ----- main / tag flow ------------------------------------------
#
@@ -150,36 +200,314 @@ jobs:
# wiki repo (separate repo, no main history pollution). README is
# not touched on main any more.
- name: Update GitHub About description
- name: Update GitHub About (description + homepage)
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
# Canonical homepage — the docs site (P8.3). This is enforced on every
# run, so it must only point at docs.wickra.org once that domain is
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
# gated on the domain resolving, otherwise the About link would 404.
homepage="https://docs.wickra.org"
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
# Enforce the homepage unconditionally — it is a constant, so this both
# corrects the stale kingchenc URL and self-heals any future drift.
# Same Administration-write permission as --description (no extra scope).
gh repo edit --homepage "$homepage"
current=$(gh repo view --json description -q .description)
if [ "$current" = "$desc" ]; then
echo "About unchanged."
echo "About description unchanged; homepage enforced."
else
gh repo edit --description "$desc"
echo "About updated."
echo "About description + homepage updated."
fi
- name: Sync Wiki
# Counter sync target moved from the retired GitHub wiki to the docs site
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
# org steps so a token/scope gap never fails the run. Uses its own clone
# dir (docs-count) so it cannot collide with the tag-only version step
# below, which clones the same repo into `docs`.
- name: Sync docs indicator count (wickra-docs)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
cd wiki
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
exit 0
fi
cd docs-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
if git diff --quiet; then
echo "Wiki unchanged."
echo "Docs indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add Home.md FAQ.md Streaming-vs-Batch.md
git add index.md overview.md Indicators-Overview.md
git commit -m "chore: sync indicator count to ${n}"
git push
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Docs indicator count synced to ${n}."
fi
# The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
# Home.md pointer page that sends visitors to docs.wickra.org, but that
# page still names the count ("… for all N indicators"), so keep it in
# sync here too. Mirrors the docs/webpage count steps: own clone dir
# (wiki-count) and the same soft-skip contract. Wiki write rides on the
# parent repo's permission, so the PAT needs write on wickra-lib/wickra;
# the wiki has no signing gate, so a plain wickra-bot commit is fine.
- name: Sync wiki pointer indicator count (wickra.wiki)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra.wiki.git" wiki-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
exit 0
fi
cd wiki-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
if git diff --quiet; then
echo "Wiki pointer indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add Home.md
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
else
echo "Wiki pointer indicator count synced to ${n}."
fi
# ----- org-profile sync (soft-skip until PAT scope lands) -------
#
# These two steps keep the org page (github.com/wickra-lib) in sync
# with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
# syncs do not: write on wickra-lib/.github, and admin:org for the org
# description PATCH. Both are written to soft-skip with a ::warning::
# (never fail the run) so this workflow stays green before the scope is
# granted — once it is, they start syncing with no further code change.
- name: Sync org profile README count
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
exit 0
fi
cd orgprofile
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
if git diff --quiet; then
echo "Org profile README count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add profile/README.md
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Org profile README synced to ${n}."
fi
- name: Sync org description
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
org="wickra-lib"
# Reading the org description is public; the PATCH needs admin:org.
current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
if [ -z "$current" ]; then
echo "::warning::could not read org description (network/PAT?). Skipping."
exit 0
fi
updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
if [ "$current" = "$updated" ]; then
echo "Org description count unchanged."
exit 0
fi
if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
echo "Org description synced to ${n}."
else
echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
fi
# ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
#
# Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
# version in the "Published versions" table (overview.md) and the Rust
# quickstart prose. Unlike the indicator count these change only on a
# release, so this step runs on v* tag pushes only and takes the version
# straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
# wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
# soft-skips with a ::warning:: and never fails the run; once granted, every
# release self-heals the docs version with no code change (replaces the old
# manual P0.5 post-release wiki bump).
- name: Sync docs version (wickra-docs)
if: startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
version="${GITHUB_REF#refs/tags/v}"
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
exit 0
fi
# Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
# the wickra repo at the workspace root, which already contains a
# top-level `docs/` directory, so `git clone … docs` fails with
# "destination path 'docs' already exists" — silently, because of the
# 2>/dev/null below — and the version sync never runs (this is exactly
# why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
# `docs-count` dir used by the count step above and collides with
# nothing in the repo.
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
exit 0
fi
cd docs-ver
# Published-versions table rows (crates.io / PyPI / npm): replace only the
# version number, leaving the trailing padding + pipe intact. The '.' in
# the quickstart pattern matches the literal backtick around the version
# without needing a backtick in this shell string. Historical "since
# X.Y.Z" references contain no such anchor and are never matched.
sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
if git diff --quiet; then
echo "Docs version already at ${version}."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add overview.md Quickstart-Rust.md
git commit -m "chore: sync published version to ${version}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Docs version synced to ${version}."
fi
# ----- webpage (marketing site) self-update (findings P12.1) ------------
#
# The marketing site (wickra-lib/webpage) carries the same indicator count
# and published version as the docs. Mirrors the docs steps above: the
# count syncs on push-to-main + tag, the version syncs on v* tags only.
# Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
# a tag run. Soft-skips with a ::warning:: if the token can't reach the
# repo, so the run never fails.
- name: Sync webpage indicator count (wickra-lib/webpage)
if: github.event_name != 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
n="${{ steps.count.outputs.count }}"
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
exit 0
fi
cd webpage-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
if git diff --quiet; then
echo "Webpage indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add index.md .vitepress/config.ts
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Webpage indicator count synced to ${n}."
fi
- name: Sync webpage version (wickra-lib/webpage)
if: startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
run: |
version="${GITHUB_REF#refs/tags/v}"
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
exit 0
fi
# The webpage pins wickra-wasm to the released version in package.json,
# and its Cloudflare Pages build runs `npm clean-install`. release.yml
# publishes wickra-wasm to npm in parallel on this same tag and finishes
# minutes later, so committing the bump immediately would point the site
# at a version npm cannot resolve yet (ETARGET) and break the build —
# exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
# actually live on npm before committing; if it never appears (the wasm
# publish failed), skip rather than push a build-breaking commit.
echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
attempts=0
until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
exit 0
fi
echo " not on npm yet (attempt ${attempts}/30); waiting 30s..."
sleep 30
done
echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
exit 0
fi
cd webpage-ver
# api/*.md "Latest" lines, the nav version label, and the wickra-wasm
# dep pin. The '.' anchors match the backtick / quote / caret without a
# literal in this shell string; historical "Since X.Y.Z" prose has no
# such anchor and is never matched.
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
# Keep package-lock.json in sync with the package.json bump. The site's
# Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
# with EUSAGE if the lockfile still pins the previous wickra-wasm —
# editing package.json alone is not enough. The npm-wait above already
# proved wickra-wasm@$version is resolvable, so --package-lock-only
# regenerates the lock (version + resolved + integrity) without fetching
# node_modules. Guard it: if the regen fails, skip the whole commit so we
# never push a package.json/lock mismatch that would break the build.
if ! npm install --package-lock-only --no-audit --no-fund; then
echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
exit 0
fi
if git diff --quiet; then
echo "Webpage version already at ${version}."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add api/*.md .vitepress/config.ts package.json package-lock.json
git commit -m "chore: sync published version to ${version}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
else
echo "Webpage version synced to ${version}."
fi
+2 -2
View File
@@ -14,8 +14,8 @@ jobs:
name: metadata audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Audit repo-metadata.toml drift
+7 -3
View File
@@ -44,10 +44,14 @@ tarpaulin-report.html
# Node binding artifacts
**/node_modules/
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
# package-lock.json is committed (under bindings/node/) so contributors
# get reproducible npm installs. Top-level lockfiles still aren't expected.
# index.js + index.d.ts are generated by `napi build` but committed (a matched
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
# regenerating both when a binding's public API changes.
# package-lock.json is committed for the tracked Node packages — bindings/node/
# and examples/node/ — so contributors get reproducible npm installs. There is
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
# WASM build output
bindings/wasm/pkg/
+153 -1
View File
@@ -7,6 +7,154 @@ 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
- **Cross-asset pairwise indicators.** A new two-series family of
`Indicator<Input = (f64, f64)>` implementations that relate two distinct
assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
Node, and WASM:
- **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
**log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
it is fed, `PairwiseBeta` differences consecutive prices into log-returns
internally — the conventional way to measure cross-asset beta, where a beta
on price levels would be dominated by the shared trend.
- **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
`ln(a) β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
spread is z-scored over its own look-back. The canonical mean-reversion /
statistical-arbitrage entry signal, with independent `beta_period` and
`z_period` windows.
- **LeadLag Cross-Correlation** (`LeadLagCrossCorrelation`) — the integer
offset `k ∈ [max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`,
answering which of two assets leads the other and by how many bars. Emits
`{ lag, correlation }`; a positive lag means `a` leads `b`.
- **Cointegration** (`Cointegration`) — the EngleGranger two-step screen for
pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
`a (α + β·b)`, and an augmented DickeyFuller `t`-statistic on the spread
(configurable `adf_lags`). A strongly negative statistic flags a
mean-reverting, tradeable spread. Emits `{ hedge_ratio, spread, adf_stat }`.
- **Relative Strength A-vs-B** (`RelativeStrengthAB`) — the comparative
relative strength of two assets: the ratio line `a / b` together with its
moving average and its RSI, the classic asset-vs-asset / asset-vs-index
rotation screen. Emits `{ ratio, ratio_ma, ratio_rsi }`.
## [0.4.0] - 2026-06-01
### Added
- **Build-provenance attestations for release artifacts.** The release workflow
now emits signed SLSA build-provenance attestations for the published crates
and Python wheels/sdist (`actions/attest-build-provenance`); npm packages
carry inline Sigstore provenance from `npm publish --provenance`. Every
published artifact is cryptographically traceable to this repository's release
workflow run.
### Security
- **CodeQL static analysis and OpenSSF Scorecard run in CI.** CodeQL (Rust,
Python, JavaScript) and the OpenSSF Scorecard workflow now run on every push;
results appear under Security → Code scanning and a public Scorecard badge is
shown in the README.
- **CI workflows hardened against script injection.** Untrusted event contexts
(PR branch names, `workflow_dispatch` inputs) are passed through the step
environment instead of being interpolated directly into shell commands.
### Changed
- **Node binding: invalid indicator periods now throw instead of being silently
clamped.** The scalar-indicator constructors previously clamped `period = 0`
to `1`; every Node constructor now propagates the core's validation error
(e.g. `period must be greater than zero`), matching the Python and WASM
bindings and the Rust core. Constructing with a valid period is unaffected.
- **Binding package READMEs are now per-ecosystem.** The Python, Node.js, and
WebAssembly READMEs were byte-identical 314-line copies of the workspace
README and had drifted out of sync (stale indicator count, Python snippets
shown on the Node and WASM package pages). Each is now a focused landing page
with the correct install command, a language-correct quick-start snippet, and
links to the canonical documentation — removing the manual three-way sync
burden. No code or API changes.
- **CONTRIBUTING now states the correct MSRV (1.86 workspace / 1.88
`bindings/node`)** and documents that these are the dependency-forced floors,
kept minimal on purpose. The previous text claimed 1.75 / 1.77, which the
`msrv` CI job has enforced against since the criterion and napi-build bumps.
## [0.3.1] - 2026-05-30
### Fixed
@@ -832,7 +980,11 @@ 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.3.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
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
+1 -1
View File
@@ -6,7 +6,7 @@ message: >-
type: software
authors:
- alias: kingchenc
email: wickra.lib@gmail.com
email: support@wickra.org
repository-code: "https://github.com/wickra-lib/wickra"
url: "https://wickra.org"
abstract: >-
+1 -1
View File
@@ -33,7 +33,7 @@ project in public spaces.
## Enforcement
Instances of unacceptable behaviour may be reported to the project maintainer
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
at **support@wickra.org**. All reports will be reviewed and investigated
promptly and fairly, and the maintainer will respect the privacy and security
of the reporter.
+33 -5
View File
@@ -35,8 +35,13 @@ cargo test --workspace
cargo test -p wickra-data --features live-binance
```
The minimum supported Rust version is **1.75** for the workspace crates and
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
The minimum supported Rust version is **1.86** for the workspace crates and
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
not chosen freely — they are the lowest versions our dependencies allow
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
the library builds for the widest possible audience; please don't raise it
without a dependency that actually requires it.
### Python
@@ -63,6 +68,29 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
wasm-pack test --node bindings/wasm
```
## Lockfile policy
| Component | Lockfile | Tracked? | Why |
| --- | --- | --- | --- |
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
| `bindings/python` | — | n/a (no lockfile) | The published package pins only `numpy>=1.22` at runtime; its native code is pinned through the workspace `Cargo.lock`. The CI/bench dev tooling it installs is hash-locked separately — see the `.github/requirements` row. |
| `.github/requirements` | `*.txt` (hash-pinned) | **yes** | CI/bench Python tooling, locked with `uv pip compile --generate-hashes` (OpenSSF Scorecard PinnedDependencies). `ci-dev` is split per Python version — `ci-dev-py39.txt` and `ci-dev-py3.txt` — because numpy ships no single release with wheels for both cp39 and cp313; `bench.txt` covers the single-version bench job. |
| `fuzz` | `fuzz/Cargo.lock` | **no** (ignored) | `fuzz/` is a detached crate; `cargo-fuzz init` generates `fuzz/.gitignore` which ignores its `Cargo.lock`. The fuzz smoke job resolves dependencies fresh, so the lock is not needed for reproducibility here. |
| `site` (marketing) | `package-lock.json` | **no** (ghost-ignored) | The VitePress site is a local-only project excluded via `.git/info/exclude`; its lockfile stays local. |
When adding a new committed Node package, commit its `package-lock.json` too and
remove any matching ignore rule. Do **not** add a top-level `package-lock.json`
the repository root is not an npm package.
To refresh every committed lockfile in the workspace — `Cargo.lock`,
`fuzz/Cargo.lock`, the Node binding lock, and the hash-pinned Python
requirements — run `./scripts/update-lockfiles.sh`. It uses `uv` for the Python
locks (and bootstraps it on Linux/macOS if absent) so each target Python
version's hashed transitive closure can be regenerated without that interpreter
installed. Dependabot also keeps the `.github/requirements` pins current.
## Standards for a change
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
@@ -76,9 +104,9 @@ wasm-pack test --node bindings/wasm
- **Bindings.** A change to a public indicator API must be mirrored across the
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
- **Docs.** Update the relevant page on the
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
`README.md` when behaviour or the public API changes. The Wiki lives in
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
[documentation site](https://docs.wickra.org) and the
`README.md` when behaviour or the public API changes. The docs live in
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
## Commit and pull-request workflow
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.3.1"
version = "0.4.3"
dependencies = [
"console_error_panic_hook",
"js-sys",
+4 -4
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.3.1"
authors = ["kingchenc <wickra.lib@gmail.com>"]
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.3.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)
+39 -5
View File
@@ -1,11 +1,18 @@
# Wickra
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=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)
[![CodeQL](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://img.shields.io/github/v/release/wickra-lib/wickra?logo=github&color=green)](https://github.com/wickra-lib/wickra/releases/latest)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
@@ -31,6 +38,25 @@ for price in live_feed:
print("overbought")
```
## Documentation
Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 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),
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), the
[data layer](https://docs.wickra.org/Data-Layer).
- **Guides** — [Cookbook](https://docs.wickra.org/Cookbook),
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
@@ -109,9 +135,10 @@ python -m benchmarks.compare_libraries
## Indicators
214 streaming-first indicators across sixteen families. Every one passes the
232 streaming-first indicators across seventeen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
@@ -123,15 +150,22 @@ semantics tests.
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, 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.
@@ -203,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 214 indicators
│ ├── wickra-core/ core engine + all 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
@@ -18,7 +18,7 @@ Report it privately through one of:
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
("Report a vulnerability" under the repository's *Security* tab), or
- email to **wickra.lib@gmail.com** with a subject line starting with
- email to **support@wickra.org** with a subject line starting with
`[wickra security]`.
Please include:
+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
+45 -286
View File
@@ -1,314 +1,73 @@
# Wickra
# Wickra — Node.js
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators for Node.js. `npm install wickra`
prebuilt native binary, no system dependencies.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Node.js binding (napi-rs);
it exposes 200+ streaming-first indicators across sixteen families.
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
## Install
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
npm install wickra
```
## Indicators
The native addon ships as a prebuilt binary per platform (Linux, macOS,
Windows — x64 and arm64), selected automatically through optional
dependencies. There is nothing to compile.
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
## Quick start
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
```js
const wickra = require('wickra');
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
// Batch: run an indicator over a whole array.
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
const values = new wickra.RSI(14).batch(prices); // null during warmup
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
// Streaming: the same indicator, fed tick by tick in O(1).
const rsi = new wickra.RSI(14);
for (const price of liveFeed) {
const value = rsi.update(price); // no recomputation over history
if (value !== null && value > 70) {
console.log('overbought');
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
## Project layout
## Documentation
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
## Building everything from source
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
## Disclaimer
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
@@ -0,0 +1,70 @@
// Completeness contract for the Wickra Node bindings: every exported indicator
// class must expose the full streaming + batch + lifecycle interface. This
// catches a new indicator being wired into the binding without the standard
// methods (or an export silently disappearing) without needing a hand-written
// test per indicator.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// An "indicator class" is an exported constructor whose prototype carries the
// streaming `update` method. This excludes `version` (a plain function) and any
// non-indicator export.
function indicatorClasses() {
return Object.keys(wickra).filter((name) => {
const value = wickra[name];
return (
typeof value === 'function' &&
value.prototype &&
typeof value.prototype.update === 'function'
);
});
}
test('the binding exports the full indicator catalogue', () => {
const names = indicatorClasses();
// The published catalogue is 214 indicators. Guard against a regression that
// silently drops exported classes (e.g. a stale or partial native build).
assert.ok(
names.length >= 200,
`expected at least 200 indicator classes, got ${names.length}`,
);
});
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
const missing = [];
for (const name of indicatorClasses()) {
const proto = wickra[name].prototype;
for (const method of required) {
if (typeof proto[method] !== 'function') {
missing.push(`${name}.${method}`);
}
}
}
assert.deepEqual(
missing,
[],
`indicator classes missing required methods: ${missing.join(', ')}`,
);
});
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
// Every indicator that takes no constructor arguments must still satisfy the
// pre-warmup contract. (Indicators with required parameters are exercised by
// the dedicated suites; here we cover the zero-arg ones generically.)
let checked = 0;
for (const name of indicatorClasses()) {
let instance;
try {
instance = new wickra[name]();
} catch {
continue; // needs constructor arguments — covered elsewhere
}
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
checked += 1;
}
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
});
+279
View File
@@ -461,6 +461,8 @@ test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
const pairFactories = {
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
Beta: () => new wickra.Beta(14),
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
};
@@ -492,6 +494,85 @@ test('Beta perfect two-to-one', () => {
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('PairwiseBeta squared price is two', () => {
// b needs varying returns; a = b² ⇒ a's log-returns are exactly 2× b's.
const bench = Array.from({ length: 20 }, (_, i) => 100 + 10 * Math.sin(i * 0.5));
const asset = bench.map((v) => v * v);
const out = new wickra.PairwiseBeta(5).batch(asset, bench);
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('PairSpreadZScore flat benchmark is sign of last move', () => {
// Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of move.
const a = [100, 100, 110, 105, 130];
const b = [100, 100, 100, 100, 100];
const out = new wickra.PairSpreadZScore(2, 2).batch(a, b);
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
assert.ok(Math.abs(out[out.length - 2] + 1) < 1e-9);
});
const llSignal = (t) =>
Math.sin(t * 0.4) + 0.4 * Math.sin(t * 1.1) + 0.2 * Math.cos(t * 0.27);
test('LeadLagCrossCorrelation detects positive lead (object output)', () => {
const ll = new wickra.LeadLagCrossCorrelation(12, 5);
let last = null;
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3.
for (let t = 0; t < 60; t++) last = ll.update(llSignal(t), llSignal(t - 3));
assert.equal(last.lag, 3);
assert.ok(last.correlation > 0.99);
});
test('LeadLagCrossCorrelation batch is flat 2*n with last row matching', () => {
const n = 60;
const a = Array.from({ length: n }, (_, t) => llSignal(t));
const b = Array.from({ length: n }, (_, t) => llSignal(t - 3));
const out = new wickra.LeadLagCrossCorrelation(12, 5).batch(a, b);
assert.equal(out.length, 2 * n);
assert.equal(out[2 * (n - 1)], 3);
assert.ok(out[2 * (n - 1) + 1] > 0.99);
});
test('Cointegration detects mean-reverting pair (object output)', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const co = new wickra.Cointegration(40, 1);
let last = null;
for (let i = 0; i < n; i++) last = co.update(a[i], b[i]);
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.1);
assert.ok(last.adfStat < -2);
});
test('Cointegration batch is flat 3*n with last row matching', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const out = new wickra.Cointegration(40, 1).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.1);
assert.ok(out[3 * (n - 1) + 2] < -2);
});
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
const rs = new wickra.RelativeStrengthAB(5, 5);
let last = null;
for (let i = 0; i < 30; i++) last = rs.update(200, 100); // ratio is a constant 2
assert.ok(Math.abs(last.ratio - 2) < 1e-12);
assert.ok(Math.abs(last.ratioMa - 2) < 1e-12);
assert.ok(Math.abs(last.ratioRsi - 50) < 1e-9);
});
test('RelativeStrengthAB batch is flat 3*n with last row matching', () => {
const n = 30;
const a = Array.from({ length: n }, () => 200);
const b = Array.from({ length: n }, () => 100);
const out = new wickra.RelativeStrengthAB(5, 5).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 1e-12);
assert.ok(Math.abs(out[3 * (n - 1) + 2] - 50) < 1e-9);
});
test('SpearmanCorrelation monotone non-linear is 1', () => {
const x = Array.from({ length: 10 }, (_, i) => i + 1);
const y = x.map((v) => v ** 3);
@@ -815,3 +896,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));
});
@@ -0,0 +1,84 @@
// Input-validation tests for the Wickra Node bindings: malformed constructor
// parameters and mismatched batch inputs must raise a JS Error (the napi
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
// Node counterpart of bindings/python/tests/test_input_validation.py.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// --- Constructors reject invalid periods / parameters ---
test('ATR rejects a zero period at construction', () => {
// ATR validates its period (it drives the Wilder-smoothing length). The
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
// warmup-1 pass-through rather than an error, so they are not asserted here.
assert.throws(() => new wickra.ATR(0), /.*/);
});
test('MACD rejects zero and non-increasing fast/slow periods', () => {
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
// fast must be strictly less than slow.
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
});
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
});
test('PSAR rejects a step greater than its maximum', () => {
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
});
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
});
test('InitialBalance and OpeningRange reject a zero period', () => {
assert.throws(() => new wickra.InitialBalance(0), /.*/);
assert.throws(() => new wickra.OpeningRange(0), /.*/);
});
test('Ichimoku rejects zero and non-increasing periods', () => {
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
// Periods must satisfy tenkan < kijun < senkouB.
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
});
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
// InverseFisherTransform needs a non-zero scaling factor.
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
// MAMA needs fast limit > slow limit.
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
// EmpiricalModeDecomposition needs a positive fraction.
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
// binding treats their period 0 as a warmup-1 pass-through (same as the
// simple moving averages) rather than an error.
});
// --- Batch methods reject mismatched input lengths ---
test('candle batch methods reject unequal-length columns', () => {
const high = [10, 11, 12];
const low = [9, 10]; // one short
const close = [9.5, 10.5, 11.5];
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
});
test('ValueArea batch rejects unequal-length columns', () => {
const high = [1, 2, 3];
const low = [0.5, 1.5]; // short
const volume = [10, 10, 10];
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
});
+6 -6
View File
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
}
});
test('zero period is clamped to a valid window', () => {
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
// clamp pathological values like period=0 to the smallest valid window.
const sma = new wickra.SMA(0);
assert.equal(sma.warmupPeriod(), 1);
assert.equal(sma.update(42), 42);
test('zero period is rejected at construction', () => {
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
// it as a thrown JS error, consistent with the Python and WASM bindings.
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
// A valid period still constructs and runs.
assert.equal(new wickra.SMA(1).update(42), 42);
});
+99
View File
@@ -0,0 +1,99 @@
// Throughput benchmark for the Wickra Node bindings.
//
// Measures how many indicator updates per second the native binding sustains,
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
// O(1) streaming engine (there is no install-free TA library on npm with a
// comparable surface to compare against), so the headline number is raw
// throughput, not a cross-library ratio.
//
// Run after building the binding:
//
// cd bindings/node && npm install && npx napi build --platform --release
// node benchmarks/throughput.js # 200k bars (default)
// node benchmarks/throughput.js --bars 1000000
const wickra = require('..');
function parseBars() {
const idx = process.argv.indexOf('--bars');
if (idx !== -1 && process.argv[idx + 1]) {
const n = Number(process.argv[idx + 1]);
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
console.error('--bars must be a number >= 1000');
process.exit(1);
}
return 200_000;
}
const BARS = parseBars();
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
const close = new Array(BARS);
const high = new Array(BARS);
const low = new Array(BARS);
const volume = new Array(BARS);
for (let i = 0; i < BARS; i++) {
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
close[i] = mid + Math.sin(i * 0.05) * 2;
high[i] = Math.max(close[i], mid) + 1.5;
low[i] = Math.min(close[i], mid) - 1.5;
volume[i] = 1000 + (i % 97) * 13;
}
// Median elapsed-ns over a few repetitions, after one warmup pass.
function timeNs(fn, reps = 3) {
fn(); // warmup (JIT + cache)
const samples = [];
for (let r = 0; r < reps; r++) {
const t0 = process.hrtime.bigint();
fn();
samples.push(Number(process.hrtime.bigint() - t0));
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length / 2)];
}
function mupsFromNs(ns) {
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
}
// Each indicator: a streaming step and a batch call over the full series.
const indicators = [
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
];
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
console.log('-'.repeat(60));
for (const ind of indicators) {
const streamNs = timeNs(() => {
const inst = ind.make();
for (let i = 0; i < BARS; i++) ind.step(inst, i);
});
const batchNs = timeNs(() => {
ind.batch(ind.make());
});
console.log(
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
);
}
console.log(
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
'better. Numbers are machine-dependent — use them for relative comparison.',
);
+2474
View File
File diff suppressed because it is too large Load Diff
+67 -50
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, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, 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
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.Decycler = Decycler
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.RVIVolatility = RVIVolatility
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.KAMA = KAMA
module.exports.RVI = RVI
module.exports.PGO = PGO
module.exports.KST = KST
module.exports.SMI = SMI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.ConnorsRSI = ConnorsRSI
module.exports.Inertia = Inertia
module.exports.ALMA = ALMA
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.VIDYA = VIDYA
module.exports.JMA = JMA
module.exports.Alligator = Alligator
module.exports.EVWMA = EVWMA
module.exports.APO = APO
module.exports.ConnorsRSI = ConnorsRSI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.SMI = SMI
module.exports.KST = KST
module.exports.PGO = PGO
module.exports.RVI = RVI
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
module.exports.CFO = CFO
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.ElderImpulse = ElderImpulse
module.exports.STC = STC
module.exports.ElderImpulse = ElderImpulse
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.CFO = CFO
module.exports.APO = APO
module.exports.KAMA = KAMA
module.exports.EVWMA = EVWMA
module.exports.Alligator = Alligator
module.exports.JMA = JMA
module.exports.VIDYA = VIDYA
module.exports.ALMA = ALMA
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.KVO = KVO
module.exports.VolumeOscillator = VolumeOscillator
module.exports.NVI = NVI
module.exports.PVI = PVI
module.exports.VolumeOscillator = VolumeOscillator
module.exports.KVO = KVO
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
module.exports.VZO = VZO
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.Vortex = Vortex
module.exports.RWI = RWI
module.exports.WaveTrend = WaveTrend
module.exports.RWI = RWI
module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
module.exports.RVIVolatility = RVIVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.MaEnvelope = MaEnvelope
module.exports.AccelerationBands = AccelerationBands
module.exports.StarcBands = StarcBands
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
@@ -479,19 +497,6 @@ module.exports.MAMA = MAMA
module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.ValueArea = ValueArea
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
@@ -510,7 +515,19 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
// Family 15: Risk / Performance metrics
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.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.3.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.3.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.3.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.3.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.3.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.3.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.3.1",
"version": "0.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.3.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.3.1",
"wickra-darwin-x64": "0.3.1",
"wickra-linux-arm64-gnu": "0.3.1",
"wickra-linux-x64-gnu": "0.3.1",
"wickra-win32-arm64-msvc": "0.3.1",
"wickra-win32-x64-msvc": "0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.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.3.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.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"
+11 -10
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.3.1",
"version": "0.4.3",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <wickra.lib@gmail.com>",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
"types": "index.d.ts",
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"keywords": [
"trading",
"indicators",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.3.1",
"wickra-linux-arm64-gnu": "0.3.1",
"wickra-darwin-x64": "0.3.1",
"wickra-darwin-arm64": "0.3.1",
"wickra-win32-x64-msvc": "0.3.1",
"wickra-win32-arm64-msvc": "0.3.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",
@@ -60,7 +60,8 @@
"artifacts": "napi artifacts",
"universal": "napi universal",
"version": "napi version",
"test": "node --test __tests__/"
"test": "node --test __tests__/",
"bench": "node benchmarks/throughput.js"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
+976 -80
View File
File diff suppressed because it is too large Load Diff
+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
+41 -283
View File
@@ -1,314 +1,72 @@
# Wickra
# Wickra — Python
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators for Python. `pip install wickra` — no
system dependencies, no C build tooling.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Python binding (PyO3); it
exposes 200+ streaming-first indicators across sixteen families.
## Install
```bash
pip install wickra
```
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
compile and no C library to track down.
## Quick start
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
# Batch: classic TA-Lib-style usage over a whole array.
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
# Streaming: the same indicator, fed tick by tick in O(1).
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
value = rsi.update(price) # no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
## Documentation
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
## Benchmark: how much faster is "streaming-first"?
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
## Disclaimer
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
## Indicators
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+3 -3
View File
@@ -4,11 +4,11 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.3.1"
version = "0.4.3"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
+44
View File
@@ -161,6 +161,11 @@ from ._wickra import (
HurstExponent,
PearsonCorrelation,
Beta,
PairwiseBeta,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
RelativeStrengthAB,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
@@ -235,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,
@@ -393,6 +415,11 @@ __all__ = [
"HurstExponent",
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
"RelativeStrengthAB",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
@@ -467,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",
File diff suppressed because it is too large Load Diff
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
def test_pairwise_beta_rejects_bad_period():
with pytest.raises(ValueError):
ta.PairwiseBeta(0)
with pytest.raises(ValueError):
ta.PairwiseBeta(1)
def test_unequal_length_pair_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.PairwiseBeta(20).batch(a, b)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 20).batch(a, b)
def test_pair_spread_zscore_rejects_bad_periods():
with pytest.raises(ValueError):
ta.PairSpreadZScore(1, 20)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 1)
def test_lead_lag_rejects_bad_params():
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(1, 5)
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(10, 0)
def test_lead_lag_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
def test_cointegration_rejects_too_small_period():
# period must be >= 2*adf_lags + 4.
with pytest.raises(ValueError):
ta.Cointegration(3, 0)
with pytest.raises(ValueError):
ta.Cointegration(5, 1)
def test_cointegration_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.Cointegration(20, 1).batch(a, b)
def test_relative_strength_rejects_zero_periods():
with pytest.raises(ValueError):
ta.RelativeStrengthAB(0, 14)
with pytest.raises(ValueError):
ta.RelativeStrengthAB(20, 0)
def test_relative_strength_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.RelativeStrengthAB(10, 14).batch(a, b)
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
@@ -100,3 +166,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)
+184
View File
@@ -429,6 +429,67 @@ def test_information_ratio_known_window():
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_pairwise_beta_squared_price_is_two():
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
# b must have *varying* returns (a constant-return path has zero variance
# and an undefined slope, which the indicator reports as 0).
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = b**2
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
def test_pairwise_beta_inverse_price_is_minus_one():
# a = 1/b ⇒ a's log-returns are 1× b's ⇒ pairwise beta = 1.
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = 1.0 / b
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
def test_pair_spread_zscore_flat_benchmark_sign():
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ 1.
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
b = np.full_like(a, 100.0)
out = ta.PairSpreadZScore(2, 2).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
def test_lead_lag_cross_correlation_negative_lead():
# a is a delayed copy of b ⇒ b leads a ⇒ lag = 2, correlation ≈ 1.
def sig(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
n = 60
a = np.array([sig(t - 2) for t in range(n)])
b = np.array([sig(t) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert int(out[-1, 0]) == -2
assert out[-1, 1] > 0.99
def test_cointegration_perfect_pair():
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
b = np.array([100.0 + t for t in range(40)])
a = 2.0 * b + 5.0
out = ta.Cointegration(20, 1).batch(a, b)
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
def test_relative_strength_rising_ratio_is_overbought():
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
n = 20
a = np.array([100.0 + 2.0 * t for t in range(n)])
b = np.full(n, 100.0)
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out[-1, 0] > 1.0
assert math.isclose(out[-1, 2], 100.0, abs_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
@@ -762,3 +823,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)"
@@ -159,6 +159,8 @@ PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
(ta.PairwiseBeta, (20,)),
(ta.PairSpreadZScore, (20, 20)),
]
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
def _ll_signal(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
def test_lead_lag_detects_lead():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
b = np.array([_ll_signal(t - 3) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert out.shape == (n, 2)
assert int(out[-1, 0]) == 3
assert out[-1, 1] > 0.99
def test_lead_lag_streaming_matches_batch():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
b = np.array([_ll_signal(t - 2) for t in range(n)])
ind = ta.LeadLagCrossCorrelation(12, 5)
batch = ind.batch(a, b)
streamer = ta.LeadLagCrossCorrelation(12, 5)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
else:
lag, corr = v
assert int(batch[i, 0]) == lag
assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
def test_cointegration_detects_mean_reverting_pair():
n = 80
b = np.array([50.0 + 0.5 * t for t in range(n)])
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
out = ta.Cointegration(40, 1).batch(a, b)
assert out.shape == (n, 3)
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
def test_cointegration_streaming_matches_batch():
n = 70
b = np.array([30.0 + 0.7 * t for t in range(n)])
a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
batch = ta.Cointegration(25, 2).batch(a, b)
streamer = ta.Cointegration(25, 2)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
hr, sp, adf = v
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
b = np.full(n, 100.0) # ratio is a constant 2
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out.shape == (n, 3)
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
def test_relative_strength_streaming_matches_batch():
n = 60
tt = np.arange(n)
a = 100.0 + 5.0 * np.sin(tt * 0.3)
b = 100.0 + 2.0 * np.cos(tt * 0.2)
batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
streamer = ta.RelativeStrengthAB(10, 14)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
ratio, ma, rsi = v
assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
@@ -1772,3 +1863,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
+45 -287
View File
@@ -1,314 +1,72 @@
# Wickra
# Wickra — WebAssembly
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![npm](https://img.shields.io/npm/v/wickra-wasm.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra-wasm)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators in the browser. `npm install
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading dashboards and historical backtests
share the exact same implementation. This package is the WebAssembly binding
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
indicators across sixteen families.
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
## Install
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
npm install wickra-wasm
```
## Indicators
## Quick start
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
The module ships a default `init` export that loads the `.wasm` payload; await
it once before constructing indicators.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
```js
import init, { RSI } from 'wickra-wasm';
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
await init(); // load the WebAssembly module once
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
// Streaming: feed prices tick by tick in O(1).
const rsi = new RSI(14);
for (const price of liveFeed) {
const value = rsi.update(price); // null during warmup
if (value !== null && value > 70) {
console.log('overbought');
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
`null` while the indicator is still warming up.
## Project layout
## Documentation
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
## Building everything from source
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
## Disclaimer
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+851 -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};
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
wc::PearsonCorrelation
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
// ---------- PairSpreadZScore (two params) ----------
#[wasm_bindgen(js_name = "PairSpreadZScore")]
pub struct WasmPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[wasm_bindgen(js_class = "PairSpreadZScore")]
impl WasmPairSpreadZScore {
#[wasm_bindgen(constructor)]
pub fn new(beta_period: usize, z_period: usize) -> Result<WasmPairSpreadZScore, JsError> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
/// input position (`NaN` during warmup).
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- LeadLagCrossCorrelation (two params, object output) ----------
#[wasm_bindgen(js_name = "LeadLagCrossCorrelation")]
pub struct WasmLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[wasm_bindgen(js_class = "LeadLagCrossCorrelation")]
impl WasmLeadLagCrossCorrelation {
#[wasm_bindgen(constructor)]
pub fn new(window: usize, max_lag: usize) -> Result<WasmLeadLagCrossCorrelation, JsError> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `{ lag, correlation }`, or `null` during warmup. Positive lag
/// means `a` leads `b`.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"lag".into(), &(o.lag as f64).into()).ok();
Reflect::set(&obj, &"correlation".into(), &o.correlation.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `2 * n`: `[lag0, corr0, lag1, corr1, ...]`.
/// Warmup positions are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Cointegration (two params, object output) ----------
#[wasm_bindgen(js_name = "Cointegration")]
pub struct WasmCointegration {
inner: wc::Cointegration,
}
#[wasm_bindgen(js_class = "Cointegration")]
impl WasmCointegration {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, adf_lags: usize) -> Result<WasmCointegration, JsError> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `{ hedgeRatio, spread, adfStat }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
Reflect::set(&obj, &"adfStat".into(), &o.adf_stat.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[hedgeRatio0, spread0, adfStat0, hedgeRatio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- RelativeStrengthAB (two params, object output) ----------
#[wasm_bindgen(js_name = "RelativeStrengthAB")]
pub struct WasmRelativeStrengthAb {
inner: wc::RelativeStrengthAB,
}
#[wasm_bindgen(js_class = "RelativeStrengthAB")]
impl WasmRelativeStrengthAb {
#[wasm_bindgen(constructor)]
pub fn new(ma_period: usize, rsi_period: usize) -> Result<WasmRelativeStrengthAb, JsError> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `{ ratio, ratioMa, ratioRsi }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"ratio".into(), &o.ratio.into()).ok();
Reflect::set(&obj, &"ratioMa".into(), &o.ratio_ma.into()).ok();
Reflect::set(&obj, &"ratioRsi".into(), &o.ratio_rsi.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[ratio0, ratioMa0, ratioRsi0, ratio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
@@ -5950,7 +6167,8 @@ impl WasmOpeningRange {
//
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
// direction-less and emits 0/+1 only.
// direction-less by default (0/+1); pass `signed = true` to its constructor for
// the dragonfly/gravestone signed +-1 encoding.
macro_rules! wasm_candle_pattern {
($wasm:ident, $inner:ty, $js:ident) => {
@@ -6017,7 +6235,75 @@ macro_rules! wasm_candle_pattern {
};
}
wasm_candle_pattern!(WasmDoji, wc::Doji, Doji);
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `wasm_candle_pattern!`.
#[wasm_bindgen(js_name = Doji)]
pub struct WasmDoji {
inner: wc::Doji,
}
impl Default for WasmDoji {
fn default() -> Self {
Self::new(None)
}
}
#[wasm_bindgen(js_class = Doji)]
impl WasmDoji {
#[wasm_bindgen(constructor)]
pub fn new(signed: Option<bool>) -> WasmDoji {
let inner = if signed.unwrap_or(false) {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = isSigned)]
pub fn is_signed(&self) -> bool {
self.inner.is_signed()
}
}
wasm_candle_pattern!(WasmHammer, wc::Hammer, Hammer);
wasm_candle_pattern!(WasmInvertedHammer, wc::InvertedHammer, InvertedHammer);
wasm_candle_pattern!(WasmHangingMan, wc::HangingMan, HangingMan);
@@ -6045,6 +6331,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::*;
@@ -6139,6 +6841,12 @@ mod tests {
fn close_enough(a: f64, b: f64) -> bool {
if a.is_nan() {
b.is_nan()
} else if a == b {
// Exact equality, including matching infinities (e.g. ProfitFactor
// with no losing trades is +inf in both the streaming and batch
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
// would otherwise reject two equal infinities.
true
} else {
(a - b).abs() < 1e-9
}
@@ -6630,6 +7338,146 @@ mod tests {
"ready after 5 finite inputs even with prior NaNs"
);
}
// Streaming `update` must reproduce `batch` value-for-value for every scalar
// indicator — the core O(1) state-machine invariant. Each entry builds a
// fresh instance for the batch pass and another for the streaming pass. The
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
// factories, so they are known-valid.
macro_rules! assert_scalar_stream_eq {
($ctor:expr, $prices:expr) => {{
let prices: &[f64] = $prices;
let batch = { $ctor }.batch(prices);
let mut streaming = { $ctor };
for (i, &p) in prices.iter().enumerate() {
let b = batch.get_index(i as u32);
match streaming.update(p) {
Some(v) => assert!(
close_enough(v, b),
"{} streaming != batch at {i}: {v} vs {b}",
stringify!($ctor)
),
None => assert!(
b.is_nan(),
"{} expected NaN warmup at {i}",
stringify!($ctor)
),
}
}
}};
}
#[allow(clippy::too_many_lines)]
#[wasm_bindgen_test]
fn scalar_streaming_matches_batch_broad() {
let prices: Vec<f64> = (0..120)
.map(|i| {
let t = f64::from(i);
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
})
.collect();
let p = prices.as_slice();
// Moving averages.
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
// Momentum / oscillators.
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
// Volatility / statistics / regression.
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
// Ehlers / cycle.
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
assert_scalar_stream_eq!(
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
p
);
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
// Risk / performance (scalar f64 input).
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
}
// Additional invalid-constructor coverage. These wrap the same fallible core
// `new` as the Python / Node bindings, where the equivalent calls are
// confirmed to error.
#[wasm_bindgen_test]
fn additional_invalid_constructors_are_rejected() {
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
}
}
// ============================== Family 15: Risk / Performance ==============================
+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>`.
@@ -0,0 +1,446 @@
//! Cointegration — rolling EngleGranger hedge ratio plus an ADF stationarity test.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`Cointegration`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CointegrationOutput {
/// EngleGranger hedge ratio `β`: the rolling OLS slope of `a` on `b`.
pub hedge_ratio: f64,
/// The current spread (regression residual) `a (α + β·b)`.
pub spread: f64,
/// Augmented DickeyFuller `t`-statistic on the spread. **More negative**
/// means more strongly mean-reverting (cointegrated); compare against the
/// usual ADF/MacKinnon critical values (e.g. roughly `2.9` at 5%). `0`
/// when the test is undefined (a degenerate, zero-variance spread).
pub adf_stat: f64,
}
/// Rolling cointegration test for a pair of assets (EngleGranger two-step).
///
/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you
/// prefer). Over the trailing window of `period` pairs the indicator:
///
/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least
/// squares of `a` on `b`, and forms the **spread** `eₜ = aₜ (α + β·bₜ)`;
/// 2. runs an **augmented DickeyFuller** test (no constant, no trend, with
/// `adf_lags` lagged differences) on the spread series and reports its
/// `t`-statistic.
///
/// A strongly negative ADF statistic means the spread reverts to its mean — the
/// pair is cointegrated and the spread is tradeable. A statistic near zero
/// means the spread wanders like a random walk (no cointegration). This is the
/// classic pairs-trading screen: `β` tells you the hedge size, the spread is
/// what you trade, and the ADF statistic tells you whether it is worth trading.
///
/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from
/// running sums, while the spread series and the small ADF regression are
/// recomputed over the window — both bounded by the fixed parameters, not the
/// series length.
///
/// # Example
///
/// ```
/// use wickra_core::{Cointegration, Indicator};
///
/// let mut c = Cointegration::new(30, 1).unwrap();
/// let mut last = None;
/// for t in 0..60 {
/// let b = 100.0 + f64::from(t);
/// // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated.
/// let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin();
/// last = c.update((a, b));
/// }
/// let out = last.unwrap();
/// assert!((out.hedge_ratio - 2.0).abs() < 0.1);
/// assert!(out.adf_stat < 0.0); // mean-reverting spread
/// ```
#[derive(Debug, Clone)]
pub struct Cointegration {
period: usize,
adf_lags: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Cointegration {
/// Construct a new rolling cointegration test.
///
/// `period` is the look-back window; `adf_lags` is the number of lagged
/// differences in the augmented DickeyFuller regression (`0` is the plain
/// DickeyFuller test).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is
/// the smallest window that leaves the ADF regression at least one degree
/// of freedom.
pub fn new(period: usize, adf_lags: usize) -> Result<Self> {
let min_period = 2 * adf_lags + 4;
if period < min_period {
return Err(Error::InvalidPeriod {
message: "cointegration needs period >= 2*adf_lags + 4",
});
}
Ok(Self {
period,
adf_lags,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Look-back window length.
pub const fn period(&self) -> usize {
self.period
}
/// Number of lagged differences in the ADF regression.
pub const fn adf_lags(&self) -> usize {
self.adf_lags
}
}
impl Indicator for Cointegration {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = CointegrationOutput;
fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
let (a, b) = input;
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let (hedge_ratio, intercept) = if var_b == 0.0 {
// A flat `b` window has no defined slope; fall back to a level shift.
(0.0, mean_a)
} else {
let cov = self.sum_ab / n - mean_a * mean_b;
let beta = cov / var_b;
(beta, mean_a - beta * mean_b)
};
// Build the spread (residual) series over the window, oldest → newest.
let spreads: Vec<f64> = self
.window
.iter()
.map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi))
.collect();
let spread = *spreads.last().expect("window is full");
let adf_stat = adf_no_constant(&spreads, self.adf_lags);
Some(CointegrationOutput {
hedge_ratio,
spread,
adf_stat,
})
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Cointegration"
}
}
/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian
/// elimination, returning `None` if the matrix is (numerically) singular.
///
/// `mat` is row-major and consumed; `rhs` is the right-hand side.
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
let dim = rhs.len();
for col in 0..dim {
let pivot = mat[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let pivot_row = mat[col].clone();
for row in (col + 1)..dim {
let factor = mat[row][col] / pivot;
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
*cell -= factor * above;
}
rhs[row] -= factor * rhs[col];
}
}
let mut sol = vec![0.0; dim];
for row in (0..dim).rev() {
let known: f64 = mat[row]
.iter()
.zip(&sol)
.skip(row + 1)
.map(|(coeff, value)| coeff * value)
.sum();
sol[row] = (rhs[row] - known) / mat[row][row];
}
Some(sol)
}
/// Augmented DickeyFuller `t`-statistic on `series`, with `lags` lagged
/// differences and **no** constant or trend term (the EngleGranger residual
/// form). Returns `0.0` when the regression is degenerate.
///
/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic
/// is `ρ̂ / se(ρ̂)`.
fn adf_no_constant(series: &[f64], lags: usize) -> f64 {
let len = series.len();
let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences
let first = lags + 1; // first usable observation index
if len <= first {
return 0.0;
}
let num_obs = len - first;
if num_obs <= num_reg {
return 0.0; // need at least one residual degree of freedom
}
let regressors = |idx: usize| -> Vec<f64> {
let mut row = vec![0.0; num_reg];
row[0] = series[idx - 1];
for lag in 1..=lags {
row[lag] = series[idx - lag] - series[idx - lag - 1];
}
row
};
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
let mut xty = vec![0.0; num_reg];
for idx in first..len {
let diff = series[idx] - series[idx - 1];
let row = regressors(idx);
for (ri, &left) in row.iter().enumerate() {
xty[ri] += left * diff;
for (ci, &right) in row.iter().enumerate() {
xtx[ri][ci] += left * right;
}
}
}
let Some(theta) = solve(xtx.clone(), xty) else {
return 0.0;
};
let rho = theta[0];
let mut rss = 0.0;
for idx in first..len {
let diff = series[idx] - series[idx - 1];
let pred: f64 = regressors(idx)
.iter()
.zip(&theta)
.map(|(coeff, value)| coeff * value)
.sum();
let resid = diff - pred;
rss += resid * resid;
}
let dof = (num_obs - num_reg) as f64;
let sigma2 = rss / dof;
// (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first
// solve already factored successfully, so this one cannot be singular.
let mut unit = vec![0.0; num_reg];
unit[0] = 1.0;
let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded");
let var_rho = sigma2 * inverse[0];
if var_rho <= 0.0 {
return 0.0;
}
rho / var_rho.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_too_small_period() {
// period must be >= 2*lags + 4.
assert!(Cointegration::new(3, 0).is_err()); // needs >= 4
assert!(Cointegration::new(4, 0).is_ok());
assert!(Cointegration::new(5, 1).is_err()); // needs >= 6
assert!(Cointegration::new(6, 1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let c = Cointegration::new(30, 2).unwrap();
assert_eq!(c.period(), 30);
assert_eq!(c.adf_lags(), 2);
assert_eq!(c.warmup_period(), 30);
assert_eq!(c.name(), "Cointegration");
}
#[test]
fn adf_guards_and_degenerate_spread() {
// Series too short for any observation ⇒ 0.
assert_eq!(adf_no_constant(&[1.0], 1), 0.0);
// Long enough but too few degrees of freedom ⇒ 0.
assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0);
// A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly,
// so the residual variance — and hence the t-statistic — is 0.
let geom: Vec<f64> = (0..8).map(|t| 0.5_f64.powi(t)).collect();
assert_eq!(adf_no_constant(&geom, 0), 0.0);
}
#[test]
fn recovers_hedge_ratio() {
// a = 2·b + 5 + small wobble ⇒ β ≈ 2.
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let b = 100.0 + f64::from(t);
let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin();
(a, b)
})
.collect();
let out = Cointegration::new(30, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(
(out.hedge_ratio - 2.0).abs() < 0.1,
"beta {}",
out.hedge_ratio
);
}
#[test]
fn stationary_spread_is_strongly_negative() {
// A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF.
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 50.0 + 0.5 * f64::from(t);
let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin();
(a, b)
})
.collect();
let out = Cointegration::new(40, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat);
}
#[test]
fn perfect_cointegration_has_zero_spread_and_defined_ratio() {
// a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
let out = Cointegration::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9);
assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6);
assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12);
}
#[test]
fn flat_b_falls_back_to_level() {
// Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a mean(a).
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0))
.collect();
let out = Cointegration::new(10, 0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12);
}
#[test]
fn plain_dickey_fuller_lags_zero() {
// Exercise the lags = 0 path (1×1 ADF system).
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 20.0 + 0.4 * f64::from(t);
let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin();
(a, b)
})
.collect();
let out = Cointegration::new(20, 0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!((out.hedge_ratio - 1.5).abs() < 0.1);
assert!(out.adf_stat < 0.0);
}
#[test]
fn reset_clears_state() {
let mut c = Cointegration::new(10, 1).unwrap();
for t in 0..20 {
let b = 100.0 + f64::from(t);
c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b));
}
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin();
(a, b)
})
.collect();
let batch = Cointegration::new(25, 2).unwrap().batch(&pairs);
let mut c = Cointegration::new(25, 2).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+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);
}
}
@@ -0,0 +1,324 @@
//! LeadLag Cross-Correlation — which of two assets leads the other, and by how much.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`LeadLagCrossCorrelation`]: the lead/lag offset and its correlation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LeadLagCrossCorrelationOutput {
/// The offset `k ∈ [max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`.
///
/// A **positive** lag means `a` leads `b` by `lag` samples (a's pattern
/// shows up in `b` that many steps later); a **negative** lag means `b`
/// leads `a`; `0` means the two are most correlated contemporaneously.
pub lag: i64,
/// The (signed) Pearson correlation at that lag, in `[1, +1]`.
pub correlation: f64,
}
/// Rolling leadlag cross-correlation between two synchronised series.
///
/// Each `update` receives one `(a, b)` pair. The indicator keeps the most
/// recent `window + 2·max_lag` samples of each series and, once full, reports
/// the integer offset `k ∈ [max_lag, +max_lag]` that maximises the absolute
/// Pearson correlation between `a` and a copy of `b` shifted by `k`:
///
/// ```text
/// lag = argmax_k | corr( a[t], b[t+k] ) |
/// ```
///
/// This answers "does BTC lead ETH on this timescale, and by how many bars?".
/// A positive lag means `a` leads `b`; a negative lag means `b` leads `a`. The
/// reported `correlation` is the signed correlation at that lag, so its sign
/// tells you whether the lead relationship is positive or inverse.
///
/// The comparison is fully causal: `a`'s window is held fixed in the centre of
/// the buffer and `b`'s window slides across it, so every lag — positive and
/// negative — is evaluated only against data already seen. The candidate lags
/// are scanned in order of increasing `|k|`, so ties resolve to the smallest
/// absolute offset (lag `0` wins an exact tie).
///
/// Each `update` is `O(window · max_lag)` — proportional to the fixed
/// parameters, not the series length. A flat window in either channel makes a
/// correlation undefined; it is reported as `0` rather than `NaN`.
///
/// Feed raw prices or returns depending on your convention; leadlag on
/// returns is the more common choice for relating two assets.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LeadLagCrossCorrelation};
///
/// let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
/// let mut last = None;
/// for t in 0..60 {
/// let a = (f64::from(t) * 0.4).sin() + 0.4 * (f64::from(t) * 1.1).sin();
/// // `b` is `a` delayed by 3 samples, so `a` leads `b` by 3.
/// let b = (f64::from(t - 3) * 0.4).sin() + 0.4 * (f64::from(t - 3) * 1.1).sin();
/// last = ll.update((a, b));
/// }
/// let out = last.unwrap();
/// assert_eq!(out.lag, 3);
/// assert!(out.correlation > 0.99);
/// ```
#[derive(Debug, Clone)]
pub struct LeadLagCrossCorrelation {
window: usize,
max_lag: usize,
len: usize,
a_buf: VecDeque<f64>,
b_buf: VecDeque<f64>,
}
impl LeadLagCrossCorrelation {
/// Construct a new leadlag cross-correlation.
///
/// `window` is the number of overlapping points each correlation is
/// computed over; `max_lag` is the largest offset (in either direction)
/// that is searched.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `window < 2` or `max_lag == 0`.
pub fn new(window: usize, max_lag: usize) -> Result<Self> {
if window < 2 {
return Err(Error::InvalidPeriod {
message: "lead-lag cross-correlation needs window >= 2",
});
}
if max_lag == 0 {
return Err(Error::InvalidPeriod {
message: "lead-lag cross-correlation needs max_lag >= 1",
});
}
let len = window + 2 * max_lag;
Ok(Self {
window,
max_lag,
len,
a_buf: VecDeque::with_capacity(len),
b_buf: VecDeque::with_capacity(len),
})
}
/// Number of overlapping points per correlation.
pub const fn window(&self) -> usize {
self.window
}
/// Largest offset searched in either direction.
pub const fn max_lag(&self) -> usize {
self.max_lag
}
/// Pearson correlation between `a[a_start .. a_start+window]` and
/// `b[b_start .. b_start+window]`, clamped to `[1, 1]`. Returns `0` when
/// either window has zero variance.
fn corr_at(&self, a_start: usize, b_start: usize) -> f64 {
let n = self.window as f64;
let mut sa = 0.0;
let mut sb = 0.0;
let mut saa = 0.0;
let mut sbb = 0.0;
let mut sab = 0.0;
for j in 0..self.window {
let x = self.a_buf[a_start + j];
let y = self.b_buf[b_start + j];
sa += x;
sb += y;
saa += x * x;
sbb += y * y;
sab += x * y;
}
let mean_a = sa / n;
let mean_b = sb / n;
let var_a = (saa / n - mean_a * mean_a).max(0.0);
let var_b = (sbb / n - mean_b * mean_b).max(0.0);
let denom = (var_a * var_b).sqrt();
if denom == 0.0 {
return 0.0;
}
let cov = sab / n - mean_a * mean_b;
(cov / denom).clamp(-1.0, 1.0)
}
}
impl Indicator for LeadLagCrossCorrelation {
/// `(a, b)` pair.
type Input = (f64, f64);
type Output = LeadLagCrossCorrelationOutput;
fn update(&mut self, input: (f64, f64)) -> Option<LeadLagCrossCorrelationOutput> {
let (a, b) = input;
if self.a_buf.len() == self.len {
self.a_buf.pop_front();
self.b_buf.pop_front();
}
self.a_buf.push_back(a);
self.b_buf.push_back(b);
if self.a_buf.len() < self.len {
return None;
}
// `a`'s window sits in the centre; `b`'s window slides ±max_lag.
let a_start = self.max_lag;
// Start at lag 0, then widen outward so ties prefer the smallest |lag|.
// The lag is tracked as a signed counter incremented by ±1, so no
// unsigned index is ever cast to a signed type.
let mut best_lag: i64 = 0;
let mut best_corr = self.corr_at(a_start, a_start);
let mut best_abs = best_corr.abs();
let mut lag_neg: i64 = 0;
let mut lag_pos: i64 = 0;
for d in 1..=self.max_lag {
lag_neg -= 1;
lag_pos += 1;
// Negative lag: b shifted earlier (b leads a).
let c_neg = self.corr_at(a_start, a_start - d);
if c_neg.abs() > best_abs {
best_abs = c_neg.abs();
best_corr = c_neg;
best_lag = lag_neg;
}
// Positive lag: b shifted later (a leads b).
let c_pos = self.corr_at(a_start, a_start + d);
if c_pos.abs() > best_abs {
best_abs = c_pos.abs();
best_corr = c_pos;
best_lag = lag_pos;
}
}
Some(LeadLagCrossCorrelationOutput {
lag: best_lag,
correlation: best_corr,
})
}
fn reset(&mut self) {
self.a_buf.clear();
self.b_buf.clear();
}
fn warmup_period(&self) -> usize {
self.len
}
fn is_ready(&self) -> bool {
self.a_buf.len() == self.len
}
fn name(&self) -> &'static str {
"LeadLagCrossCorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn signal(t: i64) -> f64 {
let t = t as f64;
(t * 0.4).sin() + 0.4 * (t * 1.1).sin() + 0.2 * (t * 0.27).cos()
}
#[test]
fn rejects_invalid_params() {
assert!(LeadLagCrossCorrelation::new(1, 5).is_err());
assert!(LeadLagCrossCorrelation::new(10, 0).is_err());
assert!(LeadLagCrossCorrelation::new(10, 5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
assert_eq!(ll.window(), 10);
assert_eq!(ll.max_lag(), 4);
// len = window + 2*max_lag = 10 + 8 = 18.
assert_eq!(ll.warmup_period(), 18);
assert_eq!(ll.name(), "LeadLagCrossCorrelation");
}
#[test]
fn detects_positive_lead() {
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t - 3))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 3);
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
}
#[test]
fn detects_negative_lead() {
// a is a delayed copy of b ⇒ b leads a ⇒ lag = 2.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t - 2), signal(t))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, -2);
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
}
#[test]
fn contemporaneous_is_lag_zero() {
// Identical streams correlate best at lag 0 with correlation 1.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 0);
assert_relative_eq!(out.correlation, 1.0, epsilon = 1e-9);
}
#[test]
fn flat_channel_yields_zero_correlation() {
// A constant `a` has no variance ⇒ every correlation is 0 ⇒ lag 0.
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, signal(t))).collect();
let out = LeadLagCrossCorrelation::new(10, 4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 0);
assert_relative_eq!(out.correlation, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
for t in 0..40 {
ll.update((signal(t), signal(t - 2)));
}
assert!(ll.is_ready());
ll.reset();
assert!(!ll.is_ready());
assert_eq!(ll.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80).map(|t| (signal(t), signal(t - 1))).collect();
let batch = LeadLagCrossCorrelation::new(12, 5).unwrap().batch(&pairs);
let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// `shadow_tolerance` defaults to `0.05` (5 % of the bar range allowed on each
/// side) and must lie in `[0, 1)`.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,170 @@
//! Microprice — size-weighted fair value of the top of book.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Microprice — the size-weighted mid of the top of book.
///
/// The microprice tilts the mid toward the side that is *more likely to be
/// hit*: it weights each touch price by the size resting on the **opposite**
/// side, so a heavy ask (sell pressure) pulls the fair value down toward the
/// bid, and vice versa:
///
/// ```text
/// microprice = (bidPrice₁·askSize₁ + askPrice₁·bidSize₁) / (bidSize₁ + askSize₁)
/// ```
///
/// When both top sizes are zero the weighting is undefined and the plain mid
/// `(bidPrice₁ + askPrice₁) / 2` is returned. An empty book yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, Microprice, OrderBook};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 3.0).unwrap()],
/// )
/// .unwrap();
/// let mut mp = Microprice::new();
/// // (100·3 + 101·1) / (1 + 3) = 401 / 4 = 100.25 — pulled toward the bid.
/// assert_eq!(mp.update(book), Some(100.25));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Microprice {
has_emitted: bool,
}
impl Microprice {
/// Construct a new microprice indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Microprice {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let total = bid.size + ask.size;
if total <= 0.0 {
return Some(f64::midpoint(bid.price, ask.price));
}
Some((bid.price * ask.size + ask.price * bid.size) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Microprice"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let mp = Microprice::new();
assert_eq!(mp.name(), "Microprice");
assert_eq!(mp.warmup_period(), 1);
assert!(!mp.is_ready());
}
#[test]
fn weights_toward_thin_side() {
let mut mp = Microprice::new();
// Heavy ask -> microprice pulled toward bid.
assert_eq!(
mp.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
Some(100.25)
);
assert!(mp.is_ready());
}
#[test]
fn balanced_top_equals_mid() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
Some(100.5)
);
}
#[test]
fn zero_size_falls_back_to_mid() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(book(&[(100.0, 0.0)], &[(102.0, 0.0)])),
Some(101.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut mp = Microprice::new();
assert_eq!(
mp.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 1.0 + f64::from(i % 4);
book(&[(100.0, 2.0)], &[(101.0, ask)])
})
.collect();
let mut a = Microprice::new();
let mut b = Microprice::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut mp = Microprice::new();
mp.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
}
}
+55 -1
View File
@@ -43,15 +43,18 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cvd;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
mod dema;
mod demand_index;
mod demark_pivots;
mod depth_slope;
mod detrended_std_dev;
mod doji;
mod donchian;
@@ -60,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;
@@ -69,6 +73,7 @@ mod evwma;
mod fama;
mod fibonacci_pivots;
mod fisher_transform;
mod footprint;
mod force_index;
mod fractal_chaos_bands;
mod frama;
@@ -98,7 +103,9 @@ mod keltner;
mod kst;
mod kurtosis;
mod kvo;
mod kyles_lambda;
mod laguerre_rsi;
mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
@@ -114,14 +121,20 @@ mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mom;
mod morning_evening_star;
mod natr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
mod ob_imbalance_topn;
mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -133,8 +146,11 @@ 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;
mod roc;
mod rogers_satchell;
@@ -145,6 +161,7 @@ mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod signed_volume;
mod sine_wave;
mod skewness;
mod sma;
@@ -181,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;
@@ -257,15 +275,18 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
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};
@@ -274,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;
@@ -283,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;
@@ -312,7 +335,9 @@ 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;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
@@ -328,14 +353,20 @@ pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mom::Mom;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
pub use ob_imbalance_topn::OrderBookImbalanceTopN;
pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -347,8 +378,11 @@ 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;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -359,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;
@@ -395,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;
@@ -697,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"],
@@ -751,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());
}
}
@@ -0,0 +1,299 @@
//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Z-score of the log-spread `ln(a) β·ln(b)` between two assets.
///
/// This is the canonical mean-reversion / statistical-arbitrage signal for a
/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the
/// indicator does two things:
///
/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of
/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the
/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the
/// residual against the origin, `s = ln(a) β·ln(b)`.
/// 2. **Standardisation.** The spread is then z-scored over the trailing
/// `z_period` spreads: `z = (s mean_s) / std_s`.
///
/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a
/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means
/// the pair is at its typical relationship. The two windows are independent:
/// `beta_period` controls how much history the hedge ratio adapts over, and
/// `z_period` controls the look-back for the mean and dispersion of the spread.
///
/// Each `update` is O(1): five running sums maintain the rolling OLS and two
/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has
/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`,
/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion)
/// yields a z-score of `0` rather than `NaN`.
///
/// Prices must be strictly positive and finite for the logarithm to be
/// defined; a non-positive or non-finite price is skipped (it does not enter
/// either window), exactly as a real feed would discard a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairSpreadZScore};
///
/// let mut zs = PairSpreadZScore::new(2, 2).unwrap();
/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with
/// // a 2-sample z-window the z-score collapses to the sign of the last move.
/// let mut last = None;
/// for a in [100.0, 100.0, 110.0, 120.0] {
/// last = zs.update((a, 100.0));
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairSpreadZScore {
beta_period: usize,
z_period: usize,
// Rolling OLS of y = ln(a) on x = ln(b).
reg: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xx: f64,
sum_xy: f64,
// Rolling mean/variance of the spread.
spreads: VecDeque<f64>,
sum_s: f64,
sum_ss: f64,
}
impl PairSpreadZScore {
/// Construct a new pair spread z-score.
///
/// `beta_period` is the look-back for the rolling hedge ratio; `z_period`
/// is the look-back for standardising the spread.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if either period is below `2`
/// (variance needs at least two points).
pub fn new(beta_period: usize, z_period: usize) -> Result<Self> {
if beta_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs beta_period >= 2",
});
}
if z_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs z_period >= 2",
});
}
Ok(Self {
beta_period,
z_period,
reg: VecDeque::with_capacity(beta_period),
sum_x: 0.0,
sum_y: 0.0,
sum_xx: 0.0,
sum_xy: 0.0,
spreads: VecDeque::with_capacity(z_period),
sum_s: 0.0,
sum_ss: 0.0,
})
}
/// Look-back of the rolling hedge-ratio regression.
pub const fn beta_period(&self) -> usize {
self.beta_period
}
/// Look-back of the rolling spread standardisation.
pub const fn z_period(&self) -> usize {
self.z_period
}
/// The current hedge ratio `β`, or `None` while the regression is warming
/// up. A flat `ln(b)` window reports `0`.
fn hedge_ratio(&self) -> Option<f64> {
if self.reg.len() < self.beta_period {
return None;
}
let n = self.beta_period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
if var_x == 0.0 {
return Some(0.0);
}
let cov = self.sum_xy / n - mean_x * mean_y;
Some(cov / var_x)
}
fn push_spread(&mut self, s: f64) -> Option<f64> {
if self.spreads.len() == self.z_period {
let old = self.spreads.pop_front().expect("non-empty");
self.sum_s -= old;
self.sum_ss -= old * old;
}
self.spreads.push_back(s);
self.sum_s += s;
self.sum_ss += s * s;
if self.spreads.len() < self.z_period {
return None;
}
let m = self.z_period as f64;
let mean_s = self.sum_s / m;
let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0);
let std_s = var_s.sqrt();
if std_s == 0.0 {
// A flat spread window has no dispersion to standardise against.
return Some(0.0);
}
Some((s - mean_s) / std_s)
}
}
impl Indicator for PairSpreadZScore {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: skip it without disturbing either window.
return None;
}
let x = b.ln();
let y = a.ln();
if self.reg.len() == self.beta_period {
let (ox, oy) = self.reg.pop_front().expect("non-empty");
self.sum_x -= ox;
self.sum_y -= oy;
self.sum_xx -= ox * ox;
self.sum_xy -= ox * oy;
}
self.reg.push_back((x, y));
self.sum_x += x;
self.sum_y += y;
self.sum_xx += x * x;
self.sum_xy += x * y;
let beta = self.hedge_ratio()?;
let spread = y - beta * x;
self.push_spread(spread)
}
fn reset(&mut self) {
self.reg.clear();
self.sum_x = 0.0;
self.sum_y = 0.0;
self.sum_xx = 0.0;
self.sum_xy = 0.0;
self.spreads.clear();
self.sum_s = 0.0;
self.sum_ss = 0.0;
}
fn warmup_period(&self) -> usize {
// `beta_period` samples to define the hedge ratio (and the first
// spread), then `z_period 1` more to fill the spread window.
self.beta_period + self.z_period - 1
}
fn is_ready(&self) -> bool {
self.spreads.len() == self.z_period
}
fn name(&self) -> &'static str {
"PairSpreadZScore"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_periods_below_two() {
assert!(PairSpreadZScore::new(1, 5).is_err());
assert!(PairSpreadZScore::new(5, 1).is_err());
assert!(PairSpreadZScore::new(2, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let z = PairSpreadZScore::new(10, 20).unwrap();
assert_eq!(z.beta_period(), 10);
assert_eq!(z.z_period(), 20);
assert_eq!(z.warmup_period(), 29);
assert_eq!(z.name(), "PairSpreadZScore");
}
#[test]
fn flat_benchmark_two_sample_window_is_sign_of_move() {
// Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move.
let mut z = PairSpreadZScore::new(2, 2).unwrap();
assert_eq!(z.update((100.0, 100.0)), None);
assert_eq!(z.update((100.0, 100.0)), None);
// The ±1 result is exact in real arithmetic; the variance is computed
// via Σs²−mean² so a few ulps of cancellation error remain.
assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn constant_spread_yields_zero() {
// Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect();
let last = PairSpreadZScore::new(3, 4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_is_skipped() {
let mut z = PairSpreadZScore::new(2, 2).unwrap();
// A non-positive or non-finite price never enters the windows.
assert_eq!(z.update((0.0, 100.0)), None);
assert_eq!(z.update((100.0, f64::NAN)), None);
assert!(!z.is_ready());
// Valid ticks then warm the indicator normally.
z.update((100.0, 100.0));
z.update((100.0, 100.0));
z.update((110.0, 100.0));
assert!(z.is_ready());
}
#[test]
fn reset_clears_state() {
let mut z = PairSpreadZScore::new(3, 3).unwrap();
for i in 0..10 {
let b = 100.0 + 5.0 * f64::from(i).sin();
z.update((b * 1.5, b));
}
assert!(z.is_ready());
z.reset();
assert!(!z.is_ready());
assert_eq!(z.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 10.0 * (t * 0.2).sin();
let a = b * (1.0 + 0.05 * (t * 0.5).cos());
(a, b)
})
.collect();
let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs);
let mut z = PairSpreadZScore::new(14, 10).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,292 @@
//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
///
/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
/// indicator differences consecutive prices into log-returns
/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
/// `a`'s returns on `b`'s returns over the trailing window of `period` return
/// pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ rₐ·r_b r̄ₐ·r̄_b
/// var_b = (1/n) · Σ r_b² r̄_b²
/// Beta = cov_ab / var_b
/// ```
///
/// This is the slope of the OLS line and measures how much asset `a` moves, in
/// return space, for a unit return of asset `b`. A reading of `1.0` means the
/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
/// moves; negative readings signal an inverse relationship and the basis for a
/// hedge.
///
/// This differs from [`crate::Beta`], which regresses the raw inputs it is
/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
/// and it computes the returns for you, which is the conventional way to
/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
/// shared trend and rarely what you want).
///
/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
/// window has zero return variance and Beta is undefined; the indicator
/// returns `0` in that case rather than producing `NaN`.
///
/// Prices must be strictly positive and finite for the log-return to be
/// defined. A non-positive or non-finite price breaks the return chain: that
/// sample is dropped and the next valid price re-seeds the previous-price
/// reference, exactly as a real feed would resume after a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairwiseBeta};
///
/// let mut indicator = PairwiseBeta::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// // A varying (non-constant-return) positive price path.
/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
/// // `a = b²`, so a's log-returns are exactly twice b's.
/// last = indicator.update((b * b, b));
/// }
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairwiseBeta {
period: usize,
prev: Option<(f64, f64)>,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl PairwiseBeta {
/// Construct a new rolling pairwise Beta over `period` return pairs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "pairwise beta needs period >= 2",
});
}
Ok(Self {
period,
prev: None,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured period (number of return pairs in the rolling window).
pub const fn period(&self) -> usize {
self.period
}
fn push_return(&mut self, ra: f64, rb: f64) -> Option<f64> {
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((ra, rb));
self.sum_a += ra;
self.sum_b += rb;
self.sum_bb += rb * rb;
self.sum_ab += ra * rb;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let cov = self.sum_ab / n - mean_a * mean_b;
if var_b == 0.0 {
// A flat benchmark-return window has no defined beta.
return Some(0.0);
}
Some(cov / var_b)
}
}
impl Indicator for PairwiseBeta {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: drop it and restart the return chain.
self.prev = None;
return None;
}
let Some((pa, pb)) = self.prev else {
self.prev = Some((a, b));
return None;
};
self.prev = Some((a, b));
let ra = (a / pa).ln();
let rb = (b / pb).ln();
self.push_return(ra, rb)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
// One prior price to seed, then `period` return pairs.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"PairwiseBeta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(PairwiseBeta::new(0).is_err());
assert!(PairwiseBeta::new(1).is_err());
assert!(PairwiseBeta::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let b = PairwiseBeta::new(14).unwrap();
assert_eq!(b.period(), 14);
assert_eq!(b.warmup_period(), 15);
assert_eq!(b.name(), "PairwiseBeta");
}
#[test]
fn squared_price_gives_beta_two() {
// a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(b * b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
}
#[test]
fn inverse_price_gives_beta_minus_one() {
// a = 1/b ⇒ a's log-returns are 1× b's ⇒ beta = 1.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(1.0 / b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_returns_zero() {
// b constant ⇒ zero return variance ⇒ beta defined as 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_breaks_return_chain() {
let mut b = PairwiseBeta::new(3).unwrap();
// Seed, one good return, then a non-positive price drops the chain.
assert_eq!(b.update((100.0, 100.0)), None);
assert_eq!(b.update((101.0, 101.0)), None);
assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
assert!(!b.is_ready());
// A non-finite price is rejected the same way.
assert_eq!(b.update((f64::NAN, 50.0)), None);
assert!(!b.is_ready());
// Recovery: subsequent valid prices rebuild the window cleanly.
for i in 0..5 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
}
#[test]
fn reset_clears_state() {
let mut b = PairwiseBeta::new(3).unwrap();
for i in 0..6 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
b.reset();
assert!(!b.is_ready());
assert_eq!(b.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 5.0 * t.sin();
let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
(a, b)
})
.collect();
let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
let mut b = PairwiseBeta::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -26,6 +26,13 @@ use crate::traits::Indicator;
/// only — no trend filter is applied; combine with a trend indicator for
/// actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,153 @@
//! Quoted Spread — top-of-book spread in basis points.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of
/// the mid price.
///
/// ```text
/// mid = (bidPrice₁ + askPrice₁) / 2
/// quotedSpread = (askPrice₁ bidPrice₁) / mid · 10_000 (bps)
/// ```
///
/// This is the round-trip cost of crossing the spread at the touch, normalised
/// by price so it is comparable across instruments. For a valid (uncrossed)
/// book the result is non-negative. An empty book yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 1.0).unwrap()],
/// vec![Level::new(100.5, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut qs = QuotedSpread::new();
/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps.
/// let bps = qs.update(book).unwrap();
/// assert!((bps - 49.875_311_72).abs() < 1e-6);
/// ```
#[derive(Debug, Clone, Default)]
pub struct QuotedSpread {
has_emitted: bool,
}
impl QuotedSpread {
/// Construct a new quoted-spread indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for QuotedSpread {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let mid = f64::midpoint(bid.price, ask.price);
Some((ask.price - bid.price) / mid * 10_000.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"QuotedSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let qs = QuotedSpread::new();
assert_eq!(qs.name(), "QuotedSpread");
assert_eq!(qs.warmup_period(), 1);
assert!(!qs.is_ready());
}
#[test]
fn known_value_in_bps() {
let mut qs = QuotedSpread::new();
// spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap();
assert!((bps - 99.502_487_56).abs() < 1e-6);
assert!(qs.is_ready());
}
#[test]
fn tight_book_is_small() {
let mut qs = QuotedSpread::new();
let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap();
assert!(bps > 0.0 && bps < 2.0);
}
#[test]
fn empty_book_is_zero() {
let mut qs = QuotedSpread::new();
assert_eq!(
qs.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 100.5 + f64::from(i % 4) * 0.1;
book(&[(100.0, 1.0)], &[(ask, 1.0)])
})
.collect();
let mut a = QuotedSpread::new();
let mut b = QuotedSpread::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut qs = QuotedSpread::new();
qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(qs.is_ready());
qs.reset();
assert!(!qs.is_ready());
}
}
@@ -0,0 +1,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);
}
}
@@ -0,0 +1,233 @@
//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
use crate::error::Result;
use crate::indicators::{Rsi, Sma};
use crate::traits::Indicator;
/// Output of [`RelativeStrengthAB`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RelativeStrengthOutput {
/// The raw relative-strength ratio `a / b`.
pub ratio: f64,
/// Simple moving average of the ratio over `ma_period`.
pub ratio_ma: f64,
/// Relative Strength Index of the ratio over `rsi_period`.
pub ratio_rsi: f64,
}
/// Comparative relative strength of asset `a` against asset `b`.
///
/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
/// `a / b`. The ratio is then smoothed with a simple moving average and run
/// through an RSI, so a single indicator gives you the relative-strength level,
/// its trend, and whether that trend is overbought or oversold:
///
/// ```text
/// ratio = a / b
/// ratio_ma = SMA(ratio, ma_period)
/// ratio_rsi = RSI(ratio, rsi_period)
/// ```
///
/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
/// "asset-vs-index" rotation screen.
///
/// The first output appears once both the moving average and the RSI have
/// warmed up; the ratio itself is computed from the first valid pair. A
/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
/// and is skipped, leaving the internal averages untouched.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RelativeStrengthAB};
///
/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
/// let mut last = None;
/// for _ in 0..20 {
/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
/// }
/// let out = last.unwrap();
/// assert!((out.ratio - 2.0).abs() < 1e-12);
/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RelativeStrengthAB {
ma_period: usize,
rsi_period: usize,
ma: Sma,
rsi: Rsi,
}
impl RelativeStrengthAB {
/// Construct a new comparative relative-strength indicator.
///
/// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
/// is the RSI look-back of the ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
/// is zero.
pub fn new(ma_period: usize, rsi_period: usize) -> Result<Self> {
Ok(Self {
ma_period,
rsi_period,
ma: Sma::new(ma_period)?,
rsi: Rsi::new(rsi_period)?,
})
}
/// Moving-average look-back of the ratio.
pub const fn ma_period(&self) -> usize {
self.ma_period
}
/// RSI look-back of the ratio.
pub const fn rsi_period(&self) -> usize {
self.rsi_period
}
}
impl Indicator for RelativeStrengthAB {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = RelativeStrengthOutput;
fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
let (a, b) = input;
if b == 0.0 || !a.is_finite() || !b.is_finite() {
// Undefined ratio: skip without disturbing the internal averages.
return None;
}
let ratio = a / b;
let ma = self.ma.update(ratio);
let rsi = self.rsi.update(ratio);
match (ma, rsi) {
(Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
ratio,
ratio_ma,
ratio_rsi,
}),
_ => None,
}
}
fn reset(&mut self) {
self.ma.reset();
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.ma.warmup_period().max(self.rsi.warmup_period())
}
fn is_ready(&self) -> bool {
self.ma.is_ready() && self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"RelativeStrengthAB"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(RelativeStrengthAB::new(0, 5).is_err());
assert!(RelativeStrengthAB::new(5, 0).is_err());
assert!(RelativeStrengthAB::new(5, 5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rs = RelativeStrengthAB::new(10, 14).unwrap();
assert_eq!(rs.ma_period(), 10);
assert_eq!(rs.rsi_period(), 14);
// SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
assert_eq!(rs.warmup_period(), 15);
assert_eq!(rs.name(), "RelativeStrengthAB");
}
#[test]
fn constant_ratio_is_flat() {
// a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
}
#[test]
fn rising_ratio_is_overbought() {
// a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
.collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.ratio > 1.0);
assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
}
#[test]
fn zero_denominator_is_skipped() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
// b == 0 and non-finite inputs never reach the internal averages.
assert_eq!(rs.update((100.0, 0.0)), None);
assert_eq!(rs.update((f64::NAN, 100.0)), None);
assert!(!rs.is_ready());
for _ in 0..8 {
rs.update((150.0, 100.0));
}
assert!(rs.is_ready());
}
#[test]
fn reset_clears_state() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
for t in 0..10 {
rs.update((100.0 + f64::from(t), 100.0));
}
assert!(rs.is_ready());
rs.reset();
assert!(!rs.is_ready());
assert_eq!(rs.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let tt = f64::from(t);
(
100.0 + 5.0 * (tt * 0.3).sin(),
100.0 + 2.0 * (tt * 0.2).cos(),
)
})
.collect();
let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Shooting Star is bearish by definition, so under the uniform candlestick
/// sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits
/// `1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
/// The same geometry read at the bottom of a downtrend is the bullish
/// `InvertedHammer`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -0,0 +1,128 @@
//! Signed Volume — per-trade volume signed by aggressor side.
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Signed Volume — the size of each trade signed by its aggressor side.
///
/// ```text
/// signedVolume = size · (+1 if buy, 1 if sell)
/// ```
///
/// A positive value is buyer-initiated flow, a negative value seller-initiated.
/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and
/// trade-flow imbalance.
///
/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SignedVolume, Side, Trade};
///
/// let mut sv = SignedVolume::new();
/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap();
/// assert_eq!(sv.update(buy), Some(2.0));
/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap();
/// assert_eq!(sv.update(sell), Some(-3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct SignedVolume {
has_emitted: bool,
}
impl SignedVolume {
/// Construct a new signed-volume indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for SignedVolume {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
self.has_emitted = true;
Some(trade.size * trade.side.sign())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SignedVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let sv = SignedVolume::new();
assert_eq!(sv.name(), "SignedVolume");
assert_eq!(sv.warmup_period(), 1);
assert!(!sv.is_ready());
}
#[test]
fn buy_is_positive() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0));
assert!(sv.is_ready());
}
#[test]
fn sell_is_negative() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0));
}
#[test]
fn zero_size_is_zero() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..20)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 4) as f64, side, i)
})
.collect();
let mut a = SignedVolume::new();
let mut b = SignedVolume::new();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut sv = SignedVolume::new();
sv.update(trade(1.0, Side::Buy, 0));
assert!(sv.is_ready());
sv.reset();
assert!(!sv.is_ready());
}
}
@@ -24,6 +24,13 @@ use crate::traits::Indicator;
///
/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -17,6 +17,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -20,6 +20,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,193 @@
//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing
/// window of `window` trades.
///
/// ```text
/// buyVol = Σ size of buyer-initiated trades in the window
/// sellVol = Σ size of seller-initiated trades in the window
/// imbalance = (buyVol sellVol) / (buyVol + sellVol)
/// ```
///
/// The output lies in `[1, +1]`: `+1` means the window was all aggressive
/// buying, `1` all aggressive selling, `0` balanced (or no volume). The
/// indicator warms up for `window` trades — `update` returns `None` until the
/// window is full — then emits the rolling imbalance, maintained in O(1) per
/// trade.
///
/// `Input = Trade`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, TradeImbalance};
///
/// let mut ti = TradeImbalance::new(2).unwrap();
/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None);
/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap());
/// assert_eq!(out, Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct TradeImbalance {
window: usize,
history: VecDeque<(f64, f64)>,
buy_sum: f64,
sell_sum: f64,
}
impl TradeImbalance {
/// Construct a trade-imbalance indicator over a window of `window` trades.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window` is zero.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
history: VecDeque::with_capacity(window),
buy_sum: 0.0,
sell_sum: 0.0,
})
}
/// The configured window length, in trades.
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for TradeImbalance {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let (buy, sell) = if trade.side.sign() > 0.0 {
(trade.size, 0.0)
} else {
(0.0, trade.size)
};
self.history.push_back((buy, sell));
self.buy_sum += buy;
self.sell_sum += sell;
if self.history.len() > self.window {
let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
self.buy_sum -= old_buy;
self.sell_sum -= old_sell;
}
if self.history.len() < self.window {
return None;
}
let total = self.buy_sum + self.sell_sum;
if total <= 0.0 {
return Some(0.0);
}
Some((self.buy_sum - self.sell_sum) / total)
}
fn reset(&mut self) {
self.history.clear();
self.buy_sum = 0.0;
self.sell_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.history.len() >= self.window
}
fn name(&self) -> &'static str {
"TradeImbalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn rejects_zero_window() {
assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let ti = TradeImbalance::new(5).unwrap();
assert_eq!(ti.name(), "TradeImbalance");
assert_eq!(ti.warmup_period(), 5);
assert_eq!(ti.window(), 5);
assert!(!ti.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut ti = TradeImbalance::new(2).unwrap();
assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
assert!(!ti.is_ready());
// Window full: buyVol 3, sellVol 1 -> 0.5.
assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
assert!(ti.is_ready());
}
#[test]
fn rolls_off_old_trades() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
// Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
assert!((out - (4.0 / 6.0)).abs() < 1e-12);
}
#[test]
fn zero_volume_window_is_zero() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(0.0, Side::Buy, 0));
assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..30)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 5) as f64, side, i)
})
.collect();
let mut a = TradeImbalance::new(5).unwrap();
let mut b = TradeImbalance::new(5).unwrap();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1));
assert!(ti.is_ready());
ti.reset();
assert!(!ti.is_ready());
assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
}
}
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
+39 -28
View File
@@ -37,6 +37,7 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
mod error;
mod microstructure;
mod ohlcv;
mod traits;
@@ -52,38 +53,48 @@ pub use indicators::{
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
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, 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);

Some files were not shown because too many files have changed in this diff Show More