Compare commits

..

38 Commits

Author SHA1 Message Date
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
kingchenc 01aeb965d1 release: bump 0.3.0 -> 0.3.1 (#80)
* chore: remove ROADMAP.md from the public repo

ROADMAP is kept as a local-only draft (ghost-ignored via
.git/info/exclude); it is not part of the published package surface.

* release: bump 0.3.0 -> 0.3.1

CI-only patch: fixes the release.yml CycloneDX SBOM step (cargo-cyclonedx
has no -p flag, see #79) that skipped the GitHub Release attach-assets job
on 0.3.0. No library changes — republishes the same code with a working
release pipeline.

- Cargo.toml (workspace.package + wickra-core dep) + Cargo.lock
- bindings/python/pyproject.toml
- bindings/node/package.json (version + 6 optionalDependencies) + package-lock.json
- bindings/node/npm/*/package.json (6 platform subpackages)
- CHANGELOG: finalize [0.3.0] (was still under [Unreleased]), add [0.3.1]

* chore: track examples/node/package-lock.json

Since the global package-lock ignore rule was dropped (#68) this file was
left untracked. Commit it for reproducible example installs, consistent
with bindings/node (findings P4.1).
2026-05-30 19:50:45 +02:00
kingchenc f1fed6cdd5 ci(release): fix CycloneDX SBOM generation (cargo-cyclonedx has no -p flag) (#79)
cargo-cyclonedx 0.5.9 walks the whole workspace in a single pass and
writes a <package>.cdx.json next to each member's Cargo.toml; it has no
-p/--package selector. The previous three 'cargo cyclonedx ... -p <crate>'
invocations aborted with 'error: unexpected argument -p found', failing
the cargo-publish job after the crates were already published and, in
turn, skipping the github-release attach-assets job (it needs all four
publish jobs). v0.3.0 published to every registry but got no GitHub
Release page or SBOM assets as a result.

Replace the three invalid calls with a single workspace pass and copy
the three crates.io crate SBOMs into the upload dir.
2026-05-30 19:26:30 +02:00
114 changed files with 12671 additions and 1393 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Initialize CodeQL
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
category: "/language:${{ matrix.language }}"
+230 -14
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,16 +109,21 @@ jobs:
# re-resolving Cargo.lock.
- name: Install cargo-cyclonedx
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-cyclonedx
- name: Generate CycloneDX SBOMs
run: |
cargo cyclonedx --format json --top-level -p wickra-core
cargo cyclonedx --format json --top-level -p wickra-data
cargo cyclonedx --format json --top-level -p wickra
# cargo-cyclonedx walks the whole workspace in a single pass and
# writes a <package>.cdx.json next to each member's Cargo.toml; it
# has no -p/--package selector. Collect the three crates.io crates
# (the .crate files published by this job) into the upload dir.
cargo cyclonedx --format json --top-level
mkdir -p sboms
find . -name "*.cdx.json" -not -path "./target/*" -exec cp {} sboms/ \;
cp crates/wickra-core/wickra-core.cdx.json sboms/
cp crates/wickra-data/wickra-data.cdx.json sboms/
cp crates/wickra/wickra.cdx.json sboms/
ls -lh sboms/
- name: Upload SBOMs
@@ -127,7 +156,21 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Sync root README into bindings/python so it ships with the wheel
@@ -202,7 +245,23 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
@@ -211,10 +270,12 @@ jobs:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Build native module
working-directory: bindings/node
@@ -243,14 +304,31 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
working-directory: bindings/node
run: npm install
run: npm ci
- name: Download all platform binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -393,7 +471,24 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
@@ -406,6 +501,7 @@ jobs:
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
@@ -424,7 +520,7 @@ jobs:
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json'));
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
pkg.author = 'kingchenc <support@wickra.org>';
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
pkg.homepage = 'https://github.com/wickra-lib/wickra';
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
@@ -453,13 +549,24 @@ jobs:
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
#
# The release is created as a DRAFT here and only flipped to published by the
# downstream publish-release job, after the provenance bundle is attached. That
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
# with GitHub release immutability, which locks assets at publish time (P24):
# the old "publish, then upload provenance" order would have the provenance
# upload rejected once immutability is enabled.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the GitHub Release
name: Attach assets to the draft GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Expose the resolved tag so the attestations job can attach the provenance
# bundle to this same release without re-resolving it.
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -504,7 +611,7 @@ jobs:
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
- name: Create / update GitHub Release with assets
- name: Create / update the draft GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
@@ -512,6 +619,9 @@ jobs:
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
# Created as a draft; publish-release flips it to published + latest once
# the provenance bundle is attached (P24, immutability-ready).
draft: true
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
@@ -537,4 +647,110 @@ jobs:
### Auto-generated changelog
See below; GitHub computes it from the commits since the previous tag.
See below; GitHub computes it from the commits since the previous tag.
# --------------------------------------------------------------------------
# Build provenance attestations (findings P13.2)
# --------------------------------------------------------------------------
attestations:
name: Attest build provenance
needs: [cargo-publish, python-wheels, python-sdist, github-release]
runs-on: ubuntu-latest
# Signed SLSA build-provenance attestations for the published crates and
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
# from `npm publish --provenance`, so they are covered there.
#
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
# of github-release, so a Sigstore hiccup here can never block or corrupt a
# publish (the isolation the SBOM step lacked before #79). It additionally
# `needs: github-release` so the (still-draft) GitHub Release already exists
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
# not GitHub's separate attestations store, so the bundle has to live on the
# release. The release is published afterwards by the publish-release job
# whether or not this attestation succeeds (P24), so a failure here still only
# costs the provenance asset, never the release.
permissions:
id-token: write # OIDC for keyless Sigstore signing
attestations: write # write the attestations to this repo
contents: write # upload the provenance bundle as a release asset
steps:
- name: Download crate files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: crate-files
path: artifacts/crates
- name: Download wheels + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: wheels-*
path: artifacts/python
merge-multiple: true
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/crates/*.crate
artifacts/python/*.whl
artifacts/python/*.tar.gz
# Attach the Sigstore provenance bundle to the GitHub Release as a
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
# signed provenance on the release itself (P21.1e). attest-build-provenance
# writes a single JSONL bundle covering every subject above; copy it to a
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
# idempotent. github.token has contents: write here, which is all gh needs.
- name: Attach provenance bundle to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot attach provenance."
exit 1
fi
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
echo "::error::attestation bundle not found at '$BUNDLE'."
exit 1
fi
dest="wickra-${TAG}.provenance.intoto.jsonl"
cp "$BUNDLE" "$dest"
echo "Uploading $dest to release $TAG"
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
# --------------------------------------------------------------------------
# Publish the drafted release LAST (P24 — immutability-ready).
#
# github-release creates the release as a draft and attestations attaches the
# provenance bundle to it; only now, with every asset in place, is it flipped to
# published + latest. With GitHub release immutability enabled, assets lock at
# this publish step — so the provenance bundle and every build artefact are
# already present and never need a (rejected) post-publish upload.
#
# `if: always() && needs.github-release.result == 'success'` preserves the old
# robustness: the release is published whenever the draft was created, even if
# the attestations job hit a Sigstore hiccup — that only costs the provenance
# asset, exactly as before. If github-release was skipped (a publish job failed)
# there is no draft, so this is skipped too and no release is published.
# --------------------------------------------------------------------------
publish-release:
name: Publish the GitHub Release
needs: [github-release, attestations]
if: always() && needs.github-release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # flip the draft release to published
steps:
- name: Flip the draft release to published (latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot publish."
exit 1
fi
echo "::notice::publishing release $TAG (draft -> published, latest)"
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
+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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Run Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
- name: Upload SARIF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
sarif_file: results.sarif
+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/
+146 -1
View File
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.2] - 2026-06-01
### Added
- **Microstructure family — order book (part 1).** A new family of indicators
that consume an order-book depth snapshot (`OrderBook` of sorted, uncrossed
bid/ask `Level`s) rather than OHLCV, exposed in Rust, Python, Node and WASM:
- **Order-Book Imbalance** — `OrderBookImbalanceTop1`, `OrderBookImbalanceTopN`
(configurable depth) and `OrderBookImbalanceFull` measure signed depth
pressure `(bidDepth askDepth) / (bidDepth + askDepth)` over the top level,
the top-N levels, or the full book.
- **Microprice** — the size-weighted fair value
`(bidPx·askSz + askPx·bidSz) / (bidSz + askSz)`, tilting the mid toward the
side more likely to be hit.
- **Quoted Spread** — the top-of-book spread in basis points of the mid.
- **Microstructure family — trade flow (part 2).** Indicators over a trade tape
(`Trade` with an aggressor `Side`), exposed in Rust, Python, Node and WASM:
- **Signed Volume** — per-trade size signed by aggressor side (`+size` buy,
`size` sell).
- **Cumulative Volume Delta** — the running total of signed volume; reset to
re-anchor per session.
- **Trade Imbalance** — the rolling `(buyVol sellVol)/(buyVol + sellVol)`
over a configurable window of trades.
New public value types `Level`, `OrderBook`, `Side`, `Trade` and `TradeQuote`
back this and the upcoming trade-flow and price-impact indicators. Python and
Node accept a batch over a list of snapshots; WASM exposes per-snapshot
`update`.
- **Signed Doji encoding.** `Doji` gains an opt-in `.signed()` mode
(`Doji(signed=True)` in Python, `new Doji(true)` in Node and WASM) that
classifies a detected Doji by the position of its body within the bar range —
a dragonfly (long lower shadow) emits `+1.0` (bullish), a gravestone (long
upper shadow) emits `1.0` (bearish), and a long-legged / standard Doji emits
`0.0` (neutral). The default construction is unchanged — a direction-less
`+1.0` / `0.0` detection flag — so existing callers are unaffected. This
completes the uniform `+1` bull / `1` bear / `0` none sign convention across
every candlestick pattern, making the family a drop-in machine-learning
feature where bullish and bearish instances share a single dimension.
### Fixed
- **README banner now self-updates.** The top README banner points at the org
profile image that `.github/banner.yml` regenerates from the indicator count,
and `sync-about.yml` bumps a `?v=<count>` cache-buster so GitHub's Camo proxy
refetches it immediately. Also fixes the webpage indicator-count sync, which
silently crashed on a removed `public/hero.svg` and left the marketing site's
count (and its OG banner) stale.
### Security
- **CI dependency installs are pinned by hash.** The Node binding now installs
with `npm ci` (strict `package-lock.json`), and the Python CI/bench tooling is
installed from hash-locked `--require-hashes` requirements under
`.github/requirements/` (OpenSSF Scorecard PinnedDependencies). The `ci-dev`
tooling is locked twice — for Python 3.9 and for 3.10+ — because numpy ships no
single release with wheels for both cp39 and cp313. A new
`scripts/update-lockfiles.sh` regenerates every workspace lockfile (Rust, Node
and the hash-pinned Python requirements) via `uv`, and Dependabot keeps the
pinned requirements current.
## [0.4.1] - 2026-06-01
### Added
- **Cross-asset pairwise indicators.** A new two-series family of
`Indicator<Input = (f64, f64)>` implementations that relate two distinct
assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
Node, and WASM:
- **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
**log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
it is fed, `PairwiseBeta` differences consecutive prices into log-returns
internally — the conventional way to measure cross-asset beta, where a beta
on price levels would be dominated by the shared trend.
- **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
`ln(a) β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
spread is z-scored over its own look-back. The canonical mean-reversion /
statistical-arbitrage entry signal, with independent `beta_period` and
`z_period` windows.
- **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
- **Release pipeline — CycloneDX SBOM generation.** `cargo-cyclonedx` has no
`-p`/`--package` selector; it walks the whole workspace in a single pass.
The `release.yml` SBOM step invoked it as `cargo cyclonedx … -p <crate>` and
aborted with `error: unexpected argument '-p' found`, which failed the
crates.io publish job *after* the crates were already published and skipped
the GitHub Release attach-assets job (no release page, no SBOM artefacts).
The step now runs a single workspace pass and collects the three crates.io
crate SBOMs. No library changes relative to 0.3.0 — this patch republishes
the same code with a working release pipeline.
## [0.3.0] - 2026-05-30
### Added
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
pragmatically as standard `Indicator`s rather than a separate
@@ -817,7 +957,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.2.7...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.2...HEAD
[0.4.2]: https://github.com/wickra-lib/wickra/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/wickra-lib/wickra/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
+1 -1
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.0"
version = "0.4.2"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.3.0"
version = "0.4.2"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.3.0"
version = "0.4.2"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.3.0"
version = "0.4.2"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.3.0"
version = "0.4.2"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.3.0"
version = "0.4.2"
dependencies = [
"console_error_panic_hook",
"js-sys",
+4 -4
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.3.0"
authors = ["kingchenc <wickra.lib@gmail.com>"]
version = "0.4.2"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
license = "PolyForm-Noncommercial-1.0.0"
license-file = "LICENSE"
repository = "https://github.com/wickra-lib/wickra"
homepage = "https://github.com/wickra-lib/wickra"
readme = "README.md"
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.3.0" }
wickra-core = { path = "crates/wickra-core", version = "0.4.2" }
thiserror = "2"
rayon = "1.10"
+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=227" 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 227 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), the
[data layer](https://docs.wickra.org/Data-Layer).
- **Guides** — [Cookbook](https://docs.wickra.org/Cookbook),
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
@@ -109,9 +135,10 @@ python -m benchmarks.compare_libraries
## Indicators
214 streaming-first indicators across sixteen families. Every one passes the
227 streaming-first indicators across seventeen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
@@ -123,15 +150,22 @@ semantics tests.
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
@@ -203,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 214 indicators
│ ├── wickra-core/ core engine + all 227 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
-203
View File
@@ -1,203 +0,0 @@
# Roadmap
What Wickra is heading toward, what is explicitly out of scope, and what
contributors can expect across the next 0.x versions. Roadmap items are
**aspirations, not commitments** — order may shift based on real
user-feedback and bug-priority.
For "what shipped already" see [`CHANGELOG.md`](CHANGELOG.md). For the
internal structure that the roadmap items will plug into, see
[`ARCHITECTURE.md`](ARCHITECTURE.md).
## North star
> A streaming-first technical-analysis core that is the obvious default
> for anyone writing a Rust, Python, Node or browser-based trading
> system — drop-in fast, drop-in correct, drop-in tested.
Three measurable proxies for "obvious default":
1. **Coverage.** Every textbook indicator from TA-Lib, pandas-ta and
talipp is in Wickra and produces matching reference values.
2. **Performance.** Streaming `update` is the fastest published number
across Rust / Python / Node / WASM for any technical indicator.
3. **Trust.** Releases are reproducible, signed, SBOM-attached, with a
public 100% test/branch coverage badge.
## 0.3.0 — target window: Q3 2026
The first release after the org migration to `wickra-lib` lands and the
project portal at `wickra.org` is live.
### Headline goals
- **WASM has automated tests.** `wasm-bindgen-test` job in CI exercising
a representative subset (~30 indicators across all families). Today
WASM is only smoke-validated through manual examples.
- **Release-pipeline trust.** SBOM (CycloneDX) generated per release,
Sigstore cosign signatures attached to every published artifact,
npm `--provenance` flag enabled, PyPI Trusted Publishers configured.
- **Hosted documentation portal.** `wickra.org` (Cloudflare Pages,
VitePress) replaces the GitHub Wiki as the canonical doc surface.
Per-indicator deep-dives, quickstarts, FAQ, search.
- **End-to-end strategy examples.** 3 runnable examples that wire
Wickra indicators into a full mean-reversion / trend-following /
breakout strategy with PnL and equity-curve output.
- **`ARCHITECTURE.md` + `ROADMAP.md` + `CITATION.cff`** governance
baseline (this is it).
### Stretch goals (might slip to 0.4.0)
- **Per-binding hosted API reference.** TypeDoc for Node/WASM,
Sphinx for Python, both hosted on `wickra.org/api/*`. Rust stays on
`docs.rs`.
- **Property-tests (`proptest`) for mathematical invariants.** Bound
checks on RSI ∈ [0, 100], Bollinger ordering, batch-streaming
equivalence on random inputs.
- **Nightly long-fuzz workflow.** Each fuzz target gets ~1h overnight,
findings auto-converted to issues.
## 0.4.0 — target window: Q4 2026
### Indicator catalogue expansion
The current 214 indicators cover the textbook canon. The next wave is
the "stuff people actually ask for but skip because it's painful in
other libs":
- **Anchored indicators.** AnchoredVwap is already in, but anchored
variants of common indicators (AnchoredATR, AnchoredVolatility) are
on the wishlist for explicit session-based analysis.
- **Multi-timeframe (MTF) chaining helpers.** A `Mtf<Indicator>` wrapper
that runs an indicator on a different timeframe of the same input
stream — solves the most common reason people drop down to ad-hoc
buffering code.
- **Order-flow primitives** (gated on tick-data ingestion landing in
`wickra-data`): CumulativeDelta, BidAskImbalance, VolumeAtPrice.
- **Pivot-confirmation patterns.** WilliamsFractals is already in;
ZigZag is in. Next: PivotHigh/PivotLow with configurable
left/right-bar confirmation, used as a feature for higher-level
pattern detectors.
- **Harmonic-chart patterns** (Gartley, Bat, Butterfly, Crab, Shark).
These need the pivot-detector + ratio-matcher framework first; the
candlestick-pattern family from 0.2.8 is the precedent.
### Live-data layer
- **Tick-data ingestion.** Extend `wickra-data` from OHLCV-only to
also accept raw ticks; the existing `Aggregator` already aggregates
ticks into bars but isn't exposed in the public live-feed API yet.
- **Generic exchange trait.** `LiveFeed { fn subscribe(...) -> impl
Stream<Item = Candle> }` so the Binance adapter is one implementor
among many. **Note**: Wickra will not aggregate exchanges itself
(use `ccxt` if you need that) — the trait exists so user code can
swap feeds without touching indicator code.
### Performance & reliability
- **Performance-regression tracking.** `bench.yml` outputs deployed to
a `gh-pages` branch, `github-action-benchmark` plot over time,
threshold-based alerts on regression.
- **Indicator parity test suite.** Every indicator that has a TA-Lib
/ pandas-ta / talipp equivalent must pass a golden-value test
against that reference. Currently most do but the test names are
scattered — consolidate into one `parity_tests` module.
## 0.5.0 — target window: 2027 H1
### Possible new bindings
Open questions, prioritised by demand signals (≈ GitHub stars + issue
requests + community polls):
- **Java / Kotlin** binding via UniFFI — interest from Android-side
trading-app developers and the JVM-quant community.
- **Go** binding — interest from algorithmic-trading shops running on
Linux + Go infra.
- **C / C++** header export (`cbindgen`) — drops Wickra into existing
C/C++ trading stacks (e.g. older Bloomberg / FIX-protocol shops).
- **Swift** — niche but real, iOS / macOS native trading apps.
- **.NET** — closes the Windows-native gap that NAPI-Node doesn't fill
(some shops are still on .NET Framework).
None of these are committed — each is a 1-2-month project on its own,
and bindings without active maintenance are a liability. Priority will
be driven by which language community shows up with PRs.
### `wickra-data` widening
- **Historical fetch from > 1 source.** Today only Binance REST/WS.
Add Coinbase and Kraken as reference implementations — explicitly
not exchange-aggregation, just "here are two more adapters using
the trait".
## Beyond — long-term aspirations
- **`wickra-backtest` sub-crate** (decision pending). A minimal
event-driven backtester wrapping signal generation + position
sizing + fees + slippage. Decision factor: do users keep building
these ad-hoc from `examples/`? If yes, codifying it saves the
ecosystem time. If most users plug Wickra into existing backtesters
(vectorbt, backtrader, Lean, Hummingbot), keep Wickra focused.
- **`wickra-plot` companion** (low priority). `plotters`-backed Rust
rendering of indicator outputs. Mainly useful for static report
generation; live charting belongs in the JS/web layer.
- **Academic adoption.** Citable `CITATION.cff`; targeting at least
one peer-reviewed paper using Wickra as the reference indicator
engine.
## What is explicitly **not** on the roadmap
These are recurring requests that Wickra will decline so contributors
don't waste time on them:
| Feature | Why declined |
|---|---|
| Exchange aggregation across N venues | That's `ccxt`'s job. Wickra ships *one* feed implementor (Binance) for tests and demo; users plug their preferred exchange. |
| Full backtesting framework (à la Lean, vectorbt) | Scope creep. May land as `wickra-backtest` *if* a clear minimal API surfaces from user demand, but Wickra core stays an indicator library. |
| Strategy auto-tuning / hyperparameter search | Wrong abstraction layer — belongs in user-side ML/backtester. |
| Order-management / broker integration | Even further out of scope. |
| GUI / web-based studio | Marketing-site demos are fine; full IDE is not. |
| Indicator implementations that require optional Python deps (e.g. scipy KDE) | Bindings must work on a clean install. Pure-Rust math only. |
| Proprietary indicator implementations (e.g. paywalled vendor formulas) | License conflicts + audit burden. |
| GPU / CUDA acceleration | O(1) per update means the bottleneck is API overhead, not compute. SIMD-batch is a maybe; GPU adds no value for streaming. |
## How indicator wishlist requests are handled
1. **Open an issue** using the `feature_request` template, naming the
indicator, citing one of:
- TA-Lib reference
- pandas-ta reference
- peer-reviewed paper
- widely-published trading book
2. Maintainer triages within ~1 week. Acceptance criteria:
- Formula has at least one written reference (no random
YouTuber-only indicators)
- Implementation can be O(1) streaming
- Test vectors are obtainable (from reference lib, hand-computed,
or paper-provided)
3. Once accepted, the indicator gets added to the next family-batch
PR. Family-batches typically ship 5-20 indicators at a time.
## Versioning
Wickra follows [SemVer](https://semver.org/). The promise:
- **0.x.0 (minor):** new indicators, new bindings, new optional features,
new optional config knobs. Adding methods to `Indicator` with default
impls is minor.
- **0.x.y (patch):** bug fixes, performance improvements, doc fixes.
- **Major (1.0.0 and later):** changes to the `Indicator` trait
signature, removal of indicators, breaking changes to `Candle` /
`OHLCV` field order.
The 1.0 milestone is reserved for when the indicator catalogue is
considered "stable enough" and the bindings API has stabilised — likely
~2027 once 2-3 more bindings have shipped and stress-tested the trait.
## Discussion
For roadmap discussion, open a [Discussions](https://github.com/wickra-lib/wickra/discussions)
thread tagged `roadmap`. Specific indicator wishlist items go in
[Issues](https://github.com/wickra-lib/wickra/issues) via the
`feature_request` template.
+1 -1
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');
});
+166
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,88 @@ test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
// simple mean of 20.
assert.ok(out[2] > 20);
});
test('Doji signed mode encodes dragonfly/gravestone/neutral direction', () => {
// Default: direction-less detection flag (+1 doji / 0 otherwise).
const flag = new wickra.Doji();
assert.equal(flag.isSigned(), false);
assert.equal(flag.update(10, 11, 9, 10), 1); // body 0, range 2 -> doji
assert.equal(flag.update(10, 12, 10, 12), 0); // body == range -> not a doji
// Signed: classify a detected doji by its body position within the range.
const d = new wickra.Doji(true);
assert.equal(d.isSigned(), true);
assert.equal(d.update(10, 10.05, 6, 10), 1); // dragonfly -> bullish +1
assert.equal(d.update(10, 14, 9.95, 10), -1); // gravestone -> bearish -1
assert.equal(d.update(10, 12, 8, 10), 0); // long-legged -> neutral 0
assert.equal(d.update(10, 12, 10, 12), 0); // not a doji -> 0
});
test('order-book indicators reference values', () => {
// Top-1: (3 - 1) / (3 + 1) = 0.5.
assert.equal(new wickra.OrderBookImbalanceTop1().update([100], [3], [101], [1]), 0.5);
// Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
assert.ok(
Math.abs(new wickra.OrderBookImbalanceTopN(2).update([100, 99], [2, 1], [101, 102], [1, 1]) - 0.2) < 1e-12,
);
// Full: bidDepth 1, askDepth 3 -> -0.5.
assert.equal(new wickra.OrderBookImbalanceFull().update([100], [1], [101, 102], [2, 1]), -0.5);
// Microprice: (100*3 + 101*1) / 4 = 100.25.
assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25);
// Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps.
assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6);
});
test('order-book streaming update matches batch', () => {
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100, 99],
bidSz: [1 + (i % 5), 1],
askPx: [101, 102],
askSz: [1 + ((i + 1) % 3), 1],
}));
const batch = new wickra.Microprice().batch(snaps);
const streamer = new wickra.Microprice();
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('order-book TopN rejects zero levels', () => {
assert.throws(() => new wickra.OrderBookImbalanceTopN(0));
});
test('order-book update rejects a crossed book', () => {
assert.throws(() => new wickra.QuotedSpread().update([102], [1], [101], [1]));
});
test('trade-flow indicators reference values', () => {
assert.equal(new wickra.SignedVolume().update(100, 2, true), 2);
assert.equal(new wickra.SignedVolume().update(100, 3, false), -3);
const cvd = new wickra.CumulativeVolumeDelta();
assert.equal(cvd.update(100, 5, true), 5);
assert.equal(cvd.update(100, 2, false), 3);
const ti = new wickra.TradeImbalance(2);
assert.equal(ti.update(100, 3, true), null); // warming up
assert.equal(ti.update(100, 1, false), 0.5); // (3 - 1) / 4
});
test('trade-flow streaming update matches batch', () => {
const n = 30;
const price = Array.from({ length: n }, () => 100);
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
const batch = new wickra.CumulativeVolumeDelta().batch(price, size, isBuy);
const streamer = new wickra.CumulativeVolumeDelta();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok(Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}: ${s} vs ${batch[i]}`);
}
});
test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.TradeImbalance(0));
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
@@ -0,0 +1,84 @@
// Input-validation tests for the Wickra Node bindings: malformed constructor
// parameters and mismatched batch inputs must raise a JS Error (the napi
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
// Node counterpart of bindings/python/tests/test_input_validation.py.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// --- Constructors reject invalid periods / parameters ---
test('ATR rejects a zero period at construction', () => {
// ATR validates its period (it drives the Wilder-smoothing length). The
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
// warmup-1 pass-through rather than an error, so they are not asserted here.
assert.throws(() => new wickra.ATR(0), /.*/);
});
test('MACD rejects zero and non-increasing fast/slow periods', () => {
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
// fast must be strictly less than slow.
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
});
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
});
test('PSAR rejects a step greater than its maximum', () => {
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
});
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
});
test('InitialBalance and OpeningRange reject a zero period', () => {
assert.throws(() => new wickra.InitialBalance(0), /.*/);
assert.throws(() => new wickra.OpeningRange(0), /.*/);
});
test('Ichimoku rejects zero and non-increasing periods', () => {
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
// Periods must satisfy tenkan < kijun < senkouB.
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
});
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
// InverseFisherTransform needs a non-zero scaling factor.
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
// MAMA needs fast limit > slow limit.
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
// EmpiricalModeDecomposition needs a positive fraction.
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
// binding treats their period 0 as a warmup-1 pass-through (same as the
// simple moving averages) rather than an error.
});
// --- Batch methods reject mismatched input lengths ---
test('candle batch methods reject unequal-length columns', () => {
const high = [10, 11, 12];
const low = [9, 10]; // one short
const close = [9.5, 10.5, 11.5];
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
});
test('ValueArea batch rejects unequal-length columns', () => {
const high = [1, 2, 3];
const low = [0.5, 1.5]; // short
const volume = [10, 10, 10];
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
});
+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.',
);
+2423
View File
File diff suppressed because it is too large Load Diff
+62 -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, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.Decycler = Decycler
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.RVIVolatility = RVIVolatility
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.KAMA = KAMA
module.exports.RVI = RVI
module.exports.PGO = PGO
module.exports.KST = KST
module.exports.SMI = SMI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.ConnorsRSI = ConnorsRSI
module.exports.Inertia = Inertia
module.exports.ALMA = ALMA
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.VIDYA = VIDYA
module.exports.JMA = JMA
module.exports.Alligator = Alligator
module.exports.EVWMA = EVWMA
module.exports.APO = APO
module.exports.ConnorsRSI = ConnorsRSI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.SMI = SMI
module.exports.KST = KST
module.exports.PGO = PGO
module.exports.RVI = RVI
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
module.exports.CFO = CFO
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.ElderImpulse = ElderImpulse
module.exports.STC = STC
module.exports.ElderImpulse = ElderImpulse
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.CFO = CFO
module.exports.APO = APO
module.exports.KAMA = KAMA
module.exports.EVWMA = EVWMA
module.exports.Alligator = Alligator
module.exports.JMA = JMA
module.exports.VIDYA = VIDYA
module.exports.ALMA = ALMA
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.KVO = KVO
module.exports.VolumeOscillator = VolumeOscillator
module.exports.NVI = NVI
module.exports.PVI = PVI
module.exports.VolumeOscillator = VolumeOscillator
module.exports.KVO = KVO
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
module.exports.VZO = VZO
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.Vortex = Vortex
module.exports.RWI = RWI
module.exports.WaveTrend = WaveTrend
module.exports.RWI = RWI
module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
module.exports.RVIVolatility = RVIVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.MaEnvelope = MaEnvelope
module.exports.AccelerationBands = AccelerationBands
module.exports.StarcBands = StarcBands
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
@@ -479,19 +497,6 @@ module.exports.MAMA = MAMA
module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.ValueArea = ValueArea
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
@@ -510,7 +515,14 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
// Family 15: Risk / Performance metrics
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
module.exports.Microprice = Microprice
module.exports.QuotedSpread = QuotedSpread
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
module.exports.SignedVolume = SignedVolume
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
module.exports.TradeImbalance = TradeImbalance
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
"wickra.darwin-arm64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-x64",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
"wickra.linux-arm64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
"wickra.linux-x64-gnu.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
"wickra.win32-arm64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.3.0",
"version": "0.4.2",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
"wickra.win32-x64-msvc.node"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"engines": {
"node": ">= 18"
},
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.3.0",
"version": "0.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.3.0",
"version": "0.4.2",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.3.0",
"wickra-darwin-x64": "0.3.0",
"wickra-linux-arm64-gnu": "0.3.0",
"wickra-linux-x64-gnu": "0.3.0",
"wickra-win32-arm64-msvc": "0.3.0",
"wickra-win32-x64-msvc": "0.3.0"
"wickra-darwin-arm64": "0.4.2",
"wickra-darwin-x64": "0.4.2",
"wickra-linux-arm64-gnu": "0.4.2",
"wickra-linux-x64-gnu": "0.4.2",
"wickra-win32-arm64-msvc": "0.4.2",
"wickra-win32-x64-msvc": "0.4.2"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.2.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.2.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.2.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.2.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.2.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.0.tgz",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.2.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+11 -10
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.3.0",
"version": "0.4.2",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <wickra.lib@gmail.com>",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
"types": "index.d.ts",
"license": "PolyForm-Noncommercial-1.0.0",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"keywords": [
"trading",
"indicators",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.3.0",
"wickra-linux-arm64-gnu": "0.3.0",
"wickra-darwin-x64": "0.3.0",
"wickra-darwin-arm64": "0.3.0",
"wickra-win32-x64-msvc": "0.3.0",
"wickra-win32-arm64-msvc": "0.3.0"
"wickra-linux-x64-gnu": "0.4.2",
"wickra-linux-arm64-gnu": "0.4.2",
"wickra-darwin-x64": "0.4.2",
"wickra-darwin-arm64": "0.4.2",
"wickra-win32-x64-msvc": "0.4.2",
"wickra-win32-arm64-msvc": "0.4.2"
},
"scripts": {
"build": "napi build --platform --release",
@@ -60,7 +60,8 @@
"artifacts": "napi artifacts",
"universal": "napi universal",
"version": "napi version",
"test": "node --test __tests__/"
"test": "node --test __tests__/",
"bench": "node benchmarks/throughput.js"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
+676 -80
View File
@@ -22,26 +22,6 @@ fn map_err(e: wc::Error) -> NapiError {
NapiError::new(Status::InvalidArg, e.to_string())
}
/// Helper for the scalar-indicator macro only. Scalar `new` functions can fail
/// solely on `period == 0`, which `clamp_period` already rules out, so the
/// `Result` is provably `Ok` here. Candle indicators and the multi-parameter
/// indicators have genuinely fallible parameters and instead use fallible
/// `#[napi(constructor)]`s that return `napi::Result<Self>` and throw a JS error.
fn must<T>(r: Result<T, wc::Error>) -> T {
r.expect("wickra: scalar indicator parameter clamped to a valid range")
}
/// Clamp a period parameter so the underlying indicator never sees zero. JS
/// callers who pass `0` get a window of `1` instead of a thrown exception —
/// effectively a pass-through indicator that still produces valid outputs.
const fn clamp_period(p: u32) -> usize {
if p == 0 {
1
} else {
p as usize
}
}
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
}
@@ -64,10 +44,10 @@ macro_rules! node_scalar_indicator {
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(<$rust_ty>::new(clamp_period(period))),
}
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
@@ -137,11 +117,10 @@ node_scalar_indicator!(
);
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
// parameter and additionally rejects `period == 1` (a 1-bar standard
// deviation is always zero), so the `clamp_period`-to-1 strategy from
// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible
// constructor instead, throws a JS error on bad period.
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` and
// rejects both `period == 0` and `period == 1` (a 1-bar standard deviation is
// always zero). Hand-rolled, but behaves like `node_scalar_indicator!`: the
// fallible `new` propagates the core error and throws a JS error on bad period.
#[napi(js_name = "RVIVolatility")]
pub struct RviVolatilityNode {
inner: wc::RviVolatility,
@@ -327,12 +306,271 @@ node_pair_indicator!(
wc::PearsonCorrelation
);
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
node_pair_indicator!(
SpearmanCorrelationNode,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
// ============================== PairSpreadZScore ==============================
/// Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
/// price pair per update, a single z-score out.
#[napi(js_name = "PairSpreadZScore")]
pub struct PairSpreadZScoreNode {
inner: wc::PairSpreadZScore,
}
#[napi]
impl PairSpreadZScoreNode {
#[napi(constructor)]
pub fn new(beta_period: u32, z_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period as usize, z_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns a length-`n`
/// array with `NaN` for warmup positions.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== LeadLagCrossCorrelation ==============================
/// Lead/lag result: the offset that maximises correlation, and that correlation.
#[napi(object)]
pub struct LeadLagValue {
/// Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`.
pub lag: i32,
/// Signed correlation at that lag, in `[-1, 1]`.
pub correlation: f64,
}
#[napi(js_name = "LeadLagCrossCorrelation")]
pub struct LeadLagCrossCorrelationNode {
inner: wc::LeadLagCrossCorrelation,
}
#[napi]
impl LeadLagCrossCorrelationNode {
#[napi(constructor)]
pub fn new(window: u32, max_lag: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window as usize, max_lag as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<LeadLagValue> {
self.inner.update((a, b)).map(|o| LeadLagValue {
lag: o.lag as i32,
correlation: o.correlation,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
/// column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 2];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Cointegration ==============================
/// Cointegration result: hedge ratio, current spread, and the ADF statistic.
#[napi(object)]
pub struct CointegrationValue {
/// EngleGranger hedge ratio (OLS slope of `a` on `b`).
pub hedge_ratio: f64,
/// Current spread (regression residual) `a - (alpha + beta*b)`.
pub spread: f64,
/// Augmented DickeyFuller statistic on the spread; more negative ⇒ more
/// strongly mean-reverting.
pub adf_stat: f64,
}
#[napi(js_name = "Cointegration")]
pub struct CointegrationNode {
inner: wc::Cointegration,
}
#[napi]
impl CointegrationNode {
#[napi(constructor)]
pub fn new(period: u32, adf_lags: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Cointegration::new(period as usize, adf_lags as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<CointegrationValue> {
self.inner.update((a, b)).map(|o| CointegrationValue {
hedge_ratio: o.hedge_ratio,
spread: o.spread,
adf_stat: o.adf_stat,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 3];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== RelativeStrengthAB ==============================
/// Relative-strength triple: the a/b ratio, its moving average, and its RSI.
#[napi(object)]
pub struct RelativeStrengthValue {
/// Raw ratio `a / b`.
pub ratio: f64,
/// Moving average of the ratio.
pub ratio_ma: f64,
/// RSI of the ratio.
pub ratio_rsi: f64,
}
#[napi(js_name = "RelativeStrengthAB")]
pub struct RelativeStrengthAbNode {
inner: wc::RelativeStrengthAB,
}
#[napi]
impl RelativeStrengthAbNode {
#[napi(constructor)]
pub fn new(ma_period: u32, rsi_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period as usize, rsi_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<RelativeStrengthValue> {
self.inner.update((a, b)).map(|o| RelativeStrengthValue {
ratio: o.ratio,
ratio_ma: o.ratio_ma,
ratio_rsi: o.ratio_rsi,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 3];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== MACD ==============================
/// MACD triple: macd line, signal line, histogram.
@@ -1351,7 +1589,7 @@ impl InertiaNode {
#[napi(constructor)]
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period))
inner: wc::Inertia::new(rvi_period as usize, linreg_period as usize)
.map_err(map_err)?,
})
}
@@ -1412,9 +1650,9 @@ impl ConnorsRsiNode {
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ConnorsRsi::new(
clamp_period(period_rsi),
clamp_period(period_streak),
clamp_period(period_rank),
period_rsi as usize,
period_streak as usize,
period_rank as usize,
)
.map_err(map_err)?,
})
@@ -1484,12 +1722,8 @@ impl SmiNode {
#[napi(constructor)]
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Smi::new(
clamp_period(period),
clamp_period(d_period),
clamp_period(d2_period),
)
.map_err(map_err)?,
inner: wc::Smi::new(period as usize, d_period as usize, d2_period as usize)
.map_err(map_err)?,
})
}
#[napi]
@@ -1559,15 +1793,15 @@ impl KstNode {
) -> napi::Result<Self> {
Ok(Self {
inner: wc::Kst::new(
clamp_period(roc1),
clamp_period(roc2),
clamp_period(roc3),
clamp_period(roc4),
clamp_period(sma1),
clamp_period(sma2),
clamp_period(sma3),
clamp_period(sma4),
clamp_period(signal),
roc1 as usize,
roc2 as usize,
roc3 as usize,
roc4 as usize,
sma1 as usize,
sma2 as usize,
sma3 as usize,
sma4 as usize,
signal as usize,
)
.map_err(map_err)?,
})
@@ -1620,7 +1854,7 @@ impl PgoNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Pgo::new(clamp_period(period)).map_err(map_err)?,
inner: wc::Pgo::new(period as usize).map_err(map_err)?,
})
}
#[napi]
@@ -1672,7 +1906,7 @@ impl RviNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?,
inner: wc::Rvi::new(period as usize).map_err(map_err)?,
})
}
#[napi]
@@ -1732,9 +1966,9 @@ impl AwesomeOscillatorHistogramNode {
pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AwesomeOscillatorHistogram::new(
clamp_period(fast),
clamp_period(slow),
clamp_period(sma_period),
fast as usize,
slow as usize,
sma_period as usize,
)
.map_err(map_err)?,
})
@@ -1783,13 +2017,8 @@ impl StcNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Stc::new(
clamp_period(fast),
clamp_period(slow),
clamp_period(schaff_period),
factor,
)
.map_err(map_err)?,
inner: wc::Stc::new(fast as usize, slow as usize, schaff_period as usize, factor)
.map_err(map_err)?,
})
}
#[napi]
@@ -1829,10 +2058,10 @@ impl ElderImpulseNode {
) -> napi::Result<Self> {
Ok(Self {
inner: wc::ElderImpulse::new(
clamp_period(ema_period),
clamp_period(macd_fast),
clamp_period(macd_slow),
clamp_period(macd_signal),
ema_period as usize,
macd_fast as usize,
macd_slow as usize,
macd_signal as usize,
)
.map_err(map_err)?,
})
@@ -1875,12 +2104,8 @@ impl ZeroLagMacdNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ZeroLagMacd::new(
clamp_period(fast),
clamp_period(slow),
clamp_period(signal),
)
.map_err(map_err)?,
inner: wc::ZeroLagMacd::new(fast as usize, slow as usize, signal as usize)
.map_err(map_err)?,
})
}
#[napi]
@@ -1927,7 +2152,7 @@ impl CfoNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
inner: wc::Cfo::new(period as usize).map_err(map_err)?,
})
}
#[napi]
@@ -1961,7 +2186,7 @@ impl ApoNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Apo::new(clamp_period(fast), clamp_period(slow)).map_err(map_err)?,
inner: wc::Apo::new(fast as usize, slow as usize).map_err(map_err)?,
})
}
#[napi]
@@ -2032,7 +2257,7 @@ impl EvwmaNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Evwma::new(clamp_period(period)).map_err(map_err)?,
inner: wc::Evwma::new(period as usize).map_err(map_err)?,
})
}
#[napi]
@@ -2088,7 +2313,7 @@ impl AlligatorNode {
#[napi(constructor)]
pub fn new(jaw: u32, teeth: u32, lips: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Alligator::new(clamp_period(jaw), clamp_period(teeth), clamp_period(lips))
inner: wc::Alligator::new(jaw as usize, teeth as usize, lips as usize)
.map_err(map_err)?,
})
}
@@ -2146,7 +2371,7 @@ impl JmaNode {
#[napi(constructor)]
pub fn new(period: u32, phase: f64, power: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Jma::new(clamp_period(period), phase, power).map_err(map_err)?,
inner: wc::Jma::new(period as usize, phase, power).map_err(map_err)?,
})
}
#[napi]
@@ -2182,8 +2407,7 @@ impl VidyaNode {
#[napi(constructor)]
pub fn new(period: u32, cmo_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Vidya::new(clamp_period(period), clamp_period(cmo_period))
.map_err(map_err)?,
inner: wc::Vidya::new(period as usize, cmo_period as usize).map_err(map_err)?,
})
}
#[napi]
@@ -2219,7 +2443,7 @@ impl AlmaNode {
#[napi(constructor)]
pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Alma::new(clamp_period(period), offset, sigma).map_err(map_err)?,
inner: wc::Alma::new(period as usize, offset, sigma).map_err(map_err)?,
})
}
#[napi]
@@ -8357,7 +8581,8 @@ impl OpeningRangeNode {
//
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
// direction-less and emits 0/+1 only.
// direction-less by default (0/+1); pass `signed = true` to its constructor for
// the dragonfly/gravestone signed +-1 encoding.
macro_rules! node_candle_pattern {
($node:ident, $inner:ty, $js:literal) => {
@@ -8428,7 +8653,80 @@ macro_rules! node_candle_pattern {
};
}
node_candle_pattern!(DojiNode, wc::Doji, "Doji");
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `node_candle_pattern!`.
#[napi(js_name = "Doji")]
pub struct DojiNode {
inner: wc::Doji,
}
impl Default for DojiNode {
fn default() -> Self {
Self::new(None)
}
}
#[napi]
impl DojiNode {
#[napi(constructor)]
pub fn new(signed: Option<bool>) -> Self {
let inner = if signed.unwrap_or(false) {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<f64>> {
let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"open, high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let candle =
wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi(js_name = "isSigned")]
pub fn is_signed(&self) -> bool {
self.inner.is_signed()
}
}
node_candle_pattern!(HammerNode, wc::Hammer, "Hammer");
node_candle_pattern!(InvertedHammerNode, wc::InvertedHammer, "InvertedHammer");
node_candle_pattern!(HangingManNode, wc::HangingMan, "HangingMan");
@@ -8456,6 +8754,304 @@ node_candle_pattern!(SpinningTopNode, wc::SpinningTop, "SpinningTop");
node_candle_pattern!(ThreeInsideNode, wc::ThreeInside, "ThreeInside");
node_candle_pattern!(ThreeOutsideNode, wc::ThreeOutside, "ThreeOutside");
// ============================== Microstructure: Order Book ==============================
//
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length arrays for one
// snapshot (bids best-first = descending price, asks best-first = ascending
// price); `batch` takes an array of `{ bidPx, bidSz, askPx, askSz }` snapshots
// and returns one value per snapshot.
/// One order-book depth snapshot for batch evaluation.
#[napi(object)]
pub struct ObSnapshot {
pub bid_px: Vec<f64>,
pub bid_sz: Vec<f64>,
pub ask_px: Vec<f64>,
pub ask_sz: Vec<f64>,
}
fn build_order_book(
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> napi::Result<wc::OrderBook> {
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
return Err(NapiError::from_reason(
"bid/ask price and size arrays must be equal length".to_string(),
));
}
let bids = bid_px
.iter()
.zip(bid_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
let asks = ask_px
.iter()
.zip(ask_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
wc::OrderBook::new(bids, asks).map_err(map_err)
}
macro_rules! node_ob_indicator {
($node:ident, $inner:ty, $js:literal) => {
#[napi(js_name = $js)]
pub struct $node {
inner: $inner,
}
impl Default for $node {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl $node {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: <$inner>::new(),
}
}
#[napi]
pub fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> napi::Result<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[napi]
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(snapshots.len());
for snap in &snapshots {
let book =
build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
};
}
node_ob_indicator!(
OrderBookImbalanceTop1Node,
wc::OrderBookImbalanceTop1,
"OrderBookImbalanceTop1"
);
node_ob_indicator!(
OrderBookImbalanceFullNode,
wc::OrderBookImbalanceFull,
"OrderBookImbalanceFull"
);
node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice");
node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread");
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
#[napi(js_name = "OrderBookImbalanceTopN")]
pub struct OrderBookImbalanceTopNNode {
inner: wc::OrderBookImbalanceTopN,
}
#[napi]
impl OrderBookImbalanceTopNNode {
#[napi(constructor)]
pub fn new(levels: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::OrderBookImbalanceTopN::new(levels as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> napi::Result<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[napi]
pub fn batch(&mut self, snapshots: Vec<ObSnapshot>) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(snapshots.len());
for snap in &snapshots {
let book = build_order_book(&snap.bid_px, &snap.bid_sz, &snap.ask_px, &snap.ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Microstructure: Trade Flow ==============================
//
// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming
// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a
// buyer-initiated trade); `batch` takes three equal-length arrays.
fn build_trade(price: f64, size: f64, is_buy: bool) -> napi::Result<wc::Trade> {
let side = if is_buy {
wc::Side::Buy
} else {
wc::Side::Sell
};
wc::Trade::new(price, size, side, 0).map_err(map_err)
}
macro_rules! node_trade_indicator {
($node:ident, $inner:ty, $js:literal) => {
#[napi(js_name = $js)]
pub struct $node {
inner: $inner,
}
impl Default for $node {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl $node {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: <$inner>::new(),
}
}
#[napi]
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> napi::Result<Vec<f64>> {
if price.len() != size.len() || size.len() != is_buy.len() {
return Err(NapiError::from_reason(
"price, size, is_buy must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let trade = build_trade(price[i], size[i], is_buy[i])?;
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
};
}
node_trade_indicator!(SignedVolumeNode, wc::SignedVolume, "SignedVolume");
node_trade_indicator!(
CumulativeVolumeDeltaNode,
wc::CumulativeVolumeDelta,
"CumulativeVolumeDelta"
);
// Trade imbalance carries a `window` parameter, so it is hand-written.
#[napi(js_name = "TradeImbalance")]
pub struct TradeImbalanceNode {
inner: wc::TradeImbalance,
}
#[napi]
impl TradeImbalanceNode {
#[napi(constructor)]
pub fn new(window: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TradeImbalance::new(window as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> napi::Result<Option<f64>> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
#[napi]
pub fn batch(
&mut self,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> napi::Result<Vec<f64>> {
if price.len() != size.len() || size.len() != is_buy.len() {
return Err(NapiError::from_reason(
"price, size, is_buy must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let trade = build_trade(price[i], size[i], is_buy[i])?;
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Family 15: Risk / Performance ==============================
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
+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.0"
version = "0.4.2"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
+30
View File
@@ -161,6 +161,11 @@ from ._wickra import (
HurstExponent,
PearsonCorrelation,
Beta,
PairwiseBeta,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
RelativeStrengthAB,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
@@ -235,6 +240,16 @@ from ._wickra import (
SpinningTop,
ThreeInside,
ThreeOutside,
# Microstructure: order book
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
Microprice,
QuotedSpread,
# Microstructure: trade flow
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -393,6 +408,11 @@ __all__ = [
"HurstExponent",
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
"RelativeStrengthAB",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
@@ -467,6 +487,16 @@ __all__ = [
"SpinningTop",
"ThreeInside",
"ThreeOutside",
# Microstructure: order book
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
# Microstructure: trade flow
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
+765 -4
View File
@@ -26,7 +26,9 @@ fn map_err(e: wc::Error) -> PyErr {
| wc::Error::NonPositiveMultiplier
| wc::Error::NonFiniteInput
| wc::Error::InvalidCandle { .. }
| wc::Error::InvalidTick { .. } => PyValueError::new_err(e.to_string()),
| wc::Error::InvalidTick { .. }
| wc::Error::InvalidOrderBook { .. }
| wc::Error::InvalidTrade { .. } => PyValueError::new_err(e.to_string()),
}
}
@@ -10751,6 +10753,383 @@ impl PyBeta {
}
}
// ============================== PairwiseBeta ==============================
#[pyclass(name = "PairwiseBeta", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPairwiseBeta {
inner: wc::PairwiseBeta,
}
#[pymethods]
impl PyPairwiseBeta {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("PairwiseBeta(period={})", self.inner.period())
}
}
// ============================== PairSpreadZScore ==============================
#[pyclass(
name = "PairSpreadZScore",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[pymethods]
impl PyPairSpreadZScore {
#[new]
#[pyo3(signature = (beta_period=20, z_period=20))]
fn new(beta_period: usize, z_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn beta_period(&self) -> usize {
self.inner.beta_period()
}
#[getter]
fn z_period(&self) -> usize {
self.inner.z_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"PairSpreadZScore(beta_period={}, z_period={})",
self.inner.beta_period(),
self.inner.z_period()
)
}
}
// ============================== LeadLagCrossCorrelation ==============================
#[pyclass(
name = "LeadLagCrossCorrelation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[pymethods]
impl PyLeadLagCrossCorrelation {
#[new]
#[pyo3(signature = (window=20, max_lag=10))]
fn new(window: usize, max_lag: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `(lag, correlation)` or `None` during warmup. A positive lag
/// means `a` leads `b`.
fn update(&mut self, a: f64, b: f64) -> Option<(i64, f64)> {
self.inner.update((a, b)).map(|o| (o.lag, o.correlation))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 2)` with columns `[lag, correlation]`. Warmup rows are NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn window(&self) -> usize {
self.inner.window()
}
#[getter]
fn max_lag(&self) -> usize {
self.inner.max_lag()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"LeadLagCrossCorrelation(window={}, max_lag={})",
self.inner.window(),
self.inner.max_lag()
)
}
}
// ============================== Cointegration ==============================
#[pyclass(name = "Cointegration", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCointegration {
inner: wc::Cointegration,
}
#[pymethods]
impl PyCointegration {
#[new]
#[pyo3(signature = (period=30, adf_lags=1))]
fn new(period: usize, adf_lags: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `(hedge_ratio, spread, adf_stat)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.hedge_ratio, o.spread, o.adf_stat))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[hedge_ratio, spread, adf_stat]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn adf_lags(&self) -> usize {
self.inner.adf_lags()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"Cointegration(period={}, adf_lags={})",
self.inner.period(),
self.inner.adf_lags()
)
}
}
// ============================== RelativeStrengthAB ==============================
#[pyclass(
name = "RelativeStrengthAB",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRelativeStrengthAB {
inner: wc::RelativeStrengthAB,
}
#[pymethods]
impl PyRelativeStrengthAB {
#[new]
#[pyo3(signature = (ma_period=20, rsi_period=14))]
fn new(ma_period: usize, rsi_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `(ratio, ratio_ma, ratio_rsi)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.ratio, o.ratio_ma, o.ratio_rsi))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[ratio, ratio_ma, ratio_rsi]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn ma_period(&self) -> usize {
self.inner.ma_period()
}
#[getter]
fn rsi_period(&self) -> usize {
self.inner.rsi_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"RelativeStrengthAB(ma_period={}, rsi_period={})",
self.inner.ma_period(),
self.inner.rsi_period()
)
}
}
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -11056,8 +11435,9 @@ impl PyOpeningRange {
// ============================== Candlestick Patterns ==============================
//
// All 15 patterns take Candles and emit a signed f64 signal per bar:
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less, so it
// uses +1.0 / 0.0 only.
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less by
// default (+1.0 / 0.0); construct it with `signed=True` for the
// dragonfly/gravestone signed +-1 encoding.
macro_rules! candle_pattern_no_param {
($name:ident, $inner:ty, $repr:expr) => {
@@ -11128,7 +11508,86 @@ macro_rules! candle_pattern_no_param {
};
}
candle_pattern_no_param!(PyDoji, wc::Doji, "Doji");
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `candle_pattern_no_param!`.
#[pyclass(name = "Doji", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyDoji {
inner: wc::Doji,
}
#[pymethods]
impl PyDoji {
#[new]
#[pyo3(signature = (signed = false))]
fn new(signed: bool) -> Self {
let inner = if signed {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"open, high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(o.len());
for i in 0..o.len() {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_signed(&self) -> bool {
self.inner.is_signed()
}
fn __repr__(&self) -> String {
format!(
"Doji(signed={})",
if self.inner.is_signed() {
"True"
} else {
"False"
}
)
}
}
candle_pattern_no_param!(PyHammer, wc::Hammer, "Hammer");
candle_pattern_no_param!(PyInvertedHammer, wc::InvertedHammer, "InvertedHammer");
candle_pattern_no_param!(PyHangingMan, wc::HangingMan, "HangingMan");
@@ -11156,6 +11615,293 @@ candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
// ============================== Microstructure: Order Book ==============================
//
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
// `update(bid_px, bid_sz, ask_px, ask_sz)` takes four equal-length sequences
// describing one snapshot (bids best-first = descending price, asks best-first
// = ascending price); `batch` takes a list of such `(bid_px, bid_sz, ask_px,
// ask_sz)` tuples and returns one value per snapshot.
fn build_order_book(
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> PyResult<wc::OrderBook> {
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
return Err(PyValueError::new_err(
"bid/ask price and size arrays must be equal length",
));
}
let bids = bid_px
.iter()
.zip(bid_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
let asks = ask_px
.iter()
.zip(ask_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
wc::OrderBook::new(bids, asks).map_err(map_err)
}
macro_rules! py_ob_indicator {
($name:ident, $inner:ty, $repr:expr) => {
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $name {
inner: $inner,
}
#[pymethods]
impl $name {
#[new]
fn new() -> Self {
Self {
inner: <$inner>::new(),
}
}
fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> PyResult<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[allow(clippy::type_complexity)]
fn batch<'py>(
&mut self,
py: Python<'py>,
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let mut out = Vec::with_capacity(snapshots.len());
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("{}()", $repr)
}
}
};
}
py_ob_indicator!(
PyOrderBookImbalanceTop1,
wc::OrderBookImbalanceTop1,
"OrderBookImbalanceTop1"
);
py_ob_indicator!(
PyOrderBookImbalanceFull,
wc::OrderBookImbalanceFull,
"OrderBookImbalanceFull"
);
py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice");
py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread");
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
#[pyclass(
name = "OrderBookImbalanceTopN",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyOrderBookImbalanceTopN {
inner: wc::OrderBookImbalanceTopN,
}
#[pymethods]
impl PyOrderBookImbalanceTopN {
#[new]
fn new(levels: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
})
}
fn update(
&mut self,
bid_px: Vec<f64>,
bid_sz: Vec<f64>,
ask_px: Vec<f64>,
ask_sz: Vec<f64>,
) -> PyResult<Option<f64>> {
let book = build_order_book(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
Ok(self.inner.update(book))
}
#[allow(clippy::type_complexity)]
fn batch<'py>(
&mut self,
py: Python<'py>,
snapshots: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>)>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let mut out = Vec::with_capacity(snapshots.len());
for (bid_px, bid_sz, ask_px, ask_sz) in &snapshots {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
out.push(self.inner.update(book).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("OrderBookImbalanceTopN(levels={})", self.inner.levels())
}
}
// ============================== Microstructure: Trade Flow ==============================
//
// Trade-flow indicators consume a trade tape rather than OHLCV. Streaming
// `update(price, size, is_buy)` takes one trade (`is_buy=True` for a
// buyer-initiated trade); `batch` takes three equal-length arrays.
fn build_trade(price: f64, size: f64, is_buy: bool) -> PyResult<wc::Trade> {
let side = if is_buy {
wc::Side::Buy
} else {
wc::Side::Sell
};
wc::Trade::new(price, size, side, 0).map_err(map_err)
}
macro_rules! py_trade_indicator {
($name:ident, $inner:ty, $repr:expr) => {
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $name {
inner: $inner,
}
#[pymethods]
impl $name {
#[new]
fn new() -> Self {
Self {
inner: <$inner>::new(),
}
}
fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult<Option<f64>> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if price.len() != size.len() || size.len() != is_buy.len() {
return Err(PyValueError::new_err(
"price, size, is_buy must be equal length",
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let trade = build_trade(price[i], size[i], is_buy[i])?;
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("{}()", $repr)
}
}
};
}
py_trade_indicator!(PySignedVolume, wc::SignedVolume, "SignedVolume");
py_trade_indicator!(
PyCumulativeVolumeDelta,
wc::CumulativeVolumeDelta,
"CumulativeVolumeDelta"
);
// Trade imbalance carries a `window` parameter, so it is hand-written.
#[pyclass(
name = "TradeImbalance",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTradeImbalance {
inner: wc::TradeImbalance,
}
#[pymethods]
impl PyTradeImbalance {
#[new]
fn new(window: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TradeImbalance::new(window).map_err(map_err)?,
})
}
fn update(&mut self, price: f64, size: f64, is_buy: bool) -> PyResult<Option<f64>> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
price: Vec<f64>,
size: Vec<f64>,
is_buy: Vec<bool>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if price.len() != size.len() || size.len() != is_buy.len() {
return Err(PyValueError::new_err(
"price, size, is_buy must be equal length",
));
}
let mut out = Vec::with_capacity(price.len());
for i in 0..price.len() {
let trade = build_trade(price[i], size[i], is_buy[i])?;
out.push(self.inner.update(trade).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TradeImbalance(window={})", self.inner.window())
}
}
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
@@ -12236,6 +12982,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHurstExponent>()?;
m.add_class::<PyPearsonCorrelation>()?;
m.add_class::<PyBeta>()?;
m.add_class::<PyPairwiseBeta>()?;
m.add_class::<PyPairSpreadZScore>()?;
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyInitialBalance>()?;
@@ -12256,6 +13007,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySpinningTop>()?;
m.add_class::<PyThreeInside>()?;
m.add_class::<PyThreeOutside>()?;
// Microstructure: order book.
m.add_class::<PyOrderBookImbalanceTop1>()?;
m.add_class::<PyOrderBookImbalanceTopN>()?;
m.add_class::<PyOrderBookImbalanceFull>()?;
m.add_class::<PyMicroprice>()?;
m.add_class::<PyQuotedSpread>()?;
// Microstructure: trade flow.
m.add_class::<PySignedVolume>()?;
m.add_class::<PyCumulativeVolumeDelta>()?;
m.add_class::<PyTradeImbalance>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
def test_pairwise_beta_rejects_bad_period():
with pytest.raises(ValueError):
ta.PairwiseBeta(0)
with pytest.raises(ValueError):
ta.PairwiseBeta(1)
def test_unequal_length_pair_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.PairwiseBeta(20).batch(a, b)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 20).batch(a, b)
def test_pair_spread_zscore_rejects_bad_periods():
with pytest.raises(ValueError):
ta.PairSpreadZScore(1, 20)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 1)
def test_lead_lag_rejects_bad_params():
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(1, 5)
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(10, 0)
def test_lead_lag_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
def test_cointegration_rejects_too_small_period():
# period must be >= 2*adf_lags + 4.
with pytest.raises(ValueError):
ta.Cointegration(3, 0)
with pytest.raises(ValueError):
ta.Cointegration(5, 1)
def test_cointegration_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.Cointegration(20, 1).batch(a, b)
def test_relative_strength_rejects_zero_periods():
with pytest.raises(ValueError):
ta.RelativeStrengthAB(0, 14)
with pytest.raises(ValueError):
ta.RelativeStrengthAB(20, 0)
def test_relative_strength_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.RelativeStrengthAB(10, 14).batch(a, b)
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
@@ -100,3 +166,48 @@ def test_family_10_ehlers_rejects_invalid_parameters():
ta.MAMA(0.05, 0.5)
with pytest.raises(ValueError):
ta.EmpiricalModeDecomposition(20, 0.0)
def test_orderbook_topn_zero_levels_raises():
with pytest.raises(ValueError):
ta.OrderBookImbalanceTopN(0)
def test_orderbook_unequal_price_size_lengths_raise():
# bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError.
with pytest.raises(ValueError):
ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0])
with pytest.raises(ValueError):
ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0])
def test_orderbook_crossed_book_raises():
# best_bid (102) >= best_ask (101) is a crossed book -> rejected.
with pytest.raises(ValueError):
ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0])
def test_orderbook_misordered_levels_raise():
# Bids must be strictly descending in price.
with pytest.raises(ValueError):
ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0])
def test_trade_imbalance_zero_window_raises():
with pytest.raises(ValueError):
ta.TradeImbalance(0)
def test_trade_negative_size_raises():
with pytest.raises(ValueError):
ta.SignedVolume().update(100.0, -1.0, True)
def test_trade_non_positive_price_raises():
with pytest.raises(ValueError):
ta.CumulativeVolumeDelta().update(0.0, 1.0, True)
def test_trade_batch_unequal_lengths_raise():
with pytest.raises(ValueError):
ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False])
+127
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,69 @@ def test_yang_zhang_zero_movement_yields_zero():
ready = out[~np.isnan(out)]
assert ready.size > 0
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_doji_default_is_directionless_flag():
# Default Doji is a direction-less detection flag: +1 on a doji, 0 else.
d = ta.Doji()
assert d.is_signed() is False
# body 0, range 2 -> doji.
assert d.update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# body 2 == range -> not a doji.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 1)) == pytest.approx(0.0)
def test_doji_signed_dragonfly_gravestone_neutral():
# Signed Doji classifies by body position within the range.
d = ta.Doji(signed=True)
assert d.is_signed() is True
# Dragonfly: body at the top, long lower shadow -> bullish +1.
assert d.update((10.0, 10.05, 6.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
# Gravestone: body at the bottom, long upper shadow -> bearish -1.
assert d.update((10.0, 14.0, 9.95, 10.0, 1.0, 1)) == pytest.approx(-1.0)
# Long-legged: body centred, symmetric shadows -> neutral 0.
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
# A large body is not a doji at all -> 0 regardless of position.
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
def test_orderbook_imbalance_reference_values():
# Top-1: (3 - 1) / (3 + 1) = 0.5.
assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5)
# Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
topn = ta.OrderBookImbalanceTopN(2)
assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2)
# Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5.
full = ta.OrderBookImbalanceFull()
assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5)
def test_microprice_reference_value():
# (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid.
mp = ta.Microprice()
assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25)
def test_quoted_spread_reference_value():
# spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
qs = ta.QuotedSpread()
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
def test_signed_volume_reference_values():
assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0)
assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0)
def test_cumulative_volume_delta_reference_values():
cvd = ta.CumulativeVolumeDelta()
assert cvd.update(100.0, 5.0, True) == pytest.approx(5.0)
assert cvd.update(100.0, 2.0, False) == pytest.approx(3.0)
assert cvd.update(100.0, 4.0, False) == pytest.approx(-1.0)
def test_trade_imbalance_reference_value():
ti = ta.TradeImbalance(2)
assert ti.update(100.0, 3.0, True) is None # warming up
# Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
assert ti.update(100.0, 1.0, False) == pytest.approx(0.5)
+43
View File
@@ -129,3 +129,46 @@ def test_ehlers_indicators_lifecycle():
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
def test_orderbook_lifecycle():
snapshot = ([100.0], [1.0], [101.0], [1.0])
for ind in [
ta.OrderBookImbalanceTop1(),
ta.OrderBookImbalanceTopN(3),
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
]:
assert ind.warmup_period() == 1
assert not ind.is_ready()
ind.update(*snapshot)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
def test_orderbook_topn_repr():
assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)"
def test_tradeflow_lifecycle():
for ind in [ta.SignedVolume(), ta.CumulativeVolumeDelta()]:
assert ind.warmup_period() == 1
assert not ind.is_ready()
ind.update(100.0, 1.0, True)
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
def test_trade_imbalance_lifecycle_and_repr():
ti = ta.TradeImbalance(3)
assert ti.warmup_period() == 3
assert not ti.is_ready()
for _ in range(3):
ti.update(100.0, 1.0, True)
assert ti.is_ready()
ti.reset()
assert not ti.is_ready()
assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)"
@@ -159,6 +159,8 @@ PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
(ta.PairwiseBeta, (20,)),
(ta.PairSpreadZScore, (20, 20)),
]
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
def _ll_signal(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
def test_lead_lag_detects_lead():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
b = np.array([_ll_signal(t - 3) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert out.shape == (n, 2)
assert int(out[-1, 0]) == 3
assert out[-1, 1] > 0.99
def test_lead_lag_streaming_matches_batch():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
b = np.array([_ll_signal(t - 2) for t in range(n)])
ind = ta.LeadLagCrossCorrelation(12, 5)
batch = ind.batch(a, b)
streamer = ta.LeadLagCrossCorrelation(12, 5)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
else:
lag, corr = v
assert int(batch[i, 0]) == lag
assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
def test_cointegration_detects_mean_reverting_pair():
n = 80
b = np.array([50.0 + 0.5 * t for t in range(n)])
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
out = ta.Cointegration(40, 1).batch(a, b)
assert out.shape == (n, 3)
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
def test_cointegration_streaming_matches_batch():
n = 70
b = np.array([30.0 + 0.7 * t for t in range(n)])
a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
batch = ta.Cointegration(25, 2).batch(a, b)
streamer = ta.Cointegration(25, 2)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
hr, sp, adf = v
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
b = np.full(n, 100.0) # ratio is a constant 2
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out.shape == (n, 3)
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
def test_relative_strength_streaming_matches_batch():
n = 60
tt = np.arange(n)
a = 100.0 + 5.0 * np.sin(tt * 0.3)
b = 100.0 + 2.0 * np.cos(tt * 0.2)
batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
streamer = ta.RelativeStrengthAB(10, 14)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
ratio, ma, rsi = v
assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
@@ -1772,3 +1863,58 @@ def test_new_indicators_expose_lifecycle():
assert ind.warmup_period() >= 1
ind.reset()
assert ind.is_ready() is False
def _orderbook_snapshots(n: int) -> list:
"""A deterministic varying sequence of order-book snapshots."""
snaps = []
for i in range(n):
bid_sz = 1.0 + (i % 5)
ask_sz = 1.0 + ((i + 2) % 4)
snaps.append(
(
[100.0, 99.0],
[bid_sz, 1.0],
[101.0, 102.0],
[ask_sz, 1.0],
)
)
return snaps
def test_orderbook_indicators_streaming_equals_batch():
snaps = _orderbook_snapshots(40)
for make in (
ta.OrderBookImbalanceTop1,
lambda: ta.OrderBookImbalanceTopN(2),
ta.OrderBookImbalanceFull,
ta.Microprice,
ta.QuotedSpread,
):
batch = make().batch(snaps)
streamer = make()
streamed = np.array(
[streamer.update(*snap) for snap in snaps], dtype=np.float64
)
assert batch.shape == (len(snaps),)
assert _eq_nan(batch, streamed)
def test_tradeflow_indicators_streaming_equals_batch():
n = 40
price = np.full(n, 100.0)
size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64)
is_buy = [i % 2 == 0 for i in range(n)]
for make in (
ta.SignedVolume,
ta.CumulativeVolumeDelta,
lambda: ta.TradeImbalance(5),
):
batch = make().batch(price, size, is_buy)
streamer = make()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
dtype=np.float64,
)
assert batch.shape == (n,)
assert _eq_nan(batch, streamed)
+38
View File
@@ -97,3 +97,41 @@ def test_ehlers_super_smoother_batch_shape(sine_prices):
def test_mama_batch_shape(sine_prices):
out = ta.MAMA().batch(sine_prices)
assert out.shape == (sine_prices.size, 2)
def test_orderbook_indicators_construct_and_emit():
# All five order-book indicators accept a four-array snapshot and emit a float.
snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0])
indicators = [
ta.OrderBookImbalanceTop1(),
ta.OrderBookImbalanceTopN(2),
ta.OrderBookImbalanceFull(),
ta.Microprice(),
ta.QuotedSpread(),
]
for ind in indicators:
out = ind.update(*snapshot)
assert isinstance(out, float)
def test_orderbook_batch_returns_one_value_per_snapshot():
snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5
out = ta.OrderBookImbalanceTop1().batch(snapshots)
assert out.shape == (5,)
assert out.dtype == np.float64
def test_tradeflow_indicators_construct_and_emit():
# SignedVolume and CVD emit from the first trade; TradeImbalance(1) too.
assert isinstance(ta.SignedVolume().update(100.0, 2.0, True), float)
assert isinstance(ta.CumulativeVolumeDelta().update(100.0, 2.0, True), float)
assert isinstance(ta.TradeImbalance(1).update(100.0, 2.0, True), float)
def test_tradeflow_batch_returns_one_value_per_trade():
price = np.full(6, 100.0)
size = np.array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0])
is_buy = [True, False, True, False, True, False]
out = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
assert out.shape == (6,)
assert out.dtype == np.float64
@@ -201,3 +201,33 @@ def test_opening_range_streaming_matches_batch(ohlc_series):
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_orderbook_streaming_matches_batch():
snaps = [
(
[100.0, 99.0],
[1.0 + (i % 5), 1.0],
[101.0, 102.0],
[1.0 + ((i + 1) % 3), 1.0],
)
for i in range(30)
]
batch = ta.Microprice().batch(snaps)
streamer = ta.Microprice()
streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_tradeflow_streaming_matches_batch():
n = 30
price = np.full(n, 100.0)
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
is_buy = [i % 3 != 0 for i in range(n)]
batch = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
streamer = ta.CumulativeVolumeDelta()
streamed = np.array(
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
dtype=np.float64,
)
assert _equal_with_nan(batch, streamed)
+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).
+658 -2
View File
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
wc::PearsonCorrelation
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
// ---------- PairSpreadZScore (two params) ----------
#[wasm_bindgen(js_name = "PairSpreadZScore")]
pub struct WasmPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[wasm_bindgen(js_class = "PairSpreadZScore")]
impl WasmPairSpreadZScore {
#[wasm_bindgen(constructor)]
pub fn new(beta_period: usize, z_period: usize) -> Result<WasmPairSpreadZScore, JsError> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
/// input position (`NaN` during warmup).
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- LeadLagCrossCorrelation (two params, object output) ----------
#[wasm_bindgen(js_name = "LeadLagCrossCorrelation")]
pub struct WasmLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[wasm_bindgen(js_class = "LeadLagCrossCorrelation")]
impl WasmLeadLagCrossCorrelation {
#[wasm_bindgen(constructor)]
pub fn new(window: usize, max_lag: usize) -> Result<WasmLeadLagCrossCorrelation, JsError> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `{ lag, correlation }`, or `null` during warmup. Positive lag
/// means `a` leads `b`.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"lag".into(), &(o.lag as f64).into()).ok();
Reflect::set(&obj, &"correlation".into(), &o.correlation.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `2 * n`: `[lag0, corr0, lag1, corr1, ...]`.
/// Warmup positions are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Cointegration (two params, object output) ----------
#[wasm_bindgen(js_name = "Cointegration")]
pub struct WasmCointegration {
inner: wc::Cointegration,
}
#[wasm_bindgen(js_class = "Cointegration")]
impl WasmCointegration {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, adf_lags: usize) -> Result<WasmCointegration, JsError> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `{ hedgeRatio, spread, adfStat }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
Reflect::set(&obj, &"adfStat".into(), &o.adf_stat.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[hedgeRatio0, spread0, adfStat0, hedgeRatio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- RelativeStrengthAB (two params, object output) ----------
#[wasm_bindgen(js_name = "RelativeStrengthAB")]
pub struct WasmRelativeStrengthAb {
inner: wc::RelativeStrengthAB,
}
#[wasm_bindgen(js_class = "RelativeStrengthAB")]
impl WasmRelativeStrengthAb {
#[wasm_bindgen(constructor)]
pub fn new(ma_period: usize, rsi_period: usize) -> Result<WasmRelativeStrengthAb, JsError> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `{ ratio, ratioMa, ratioRsi }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"ratio".into(), &o.ratio.into()).ok();
Reflect::set(&obj, &"ratioMa".into(), &o.ratio_ma.into()).ok();
Reflect::set(&obj, &"ratioRsi".into(), &o.ratio_rsi.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[ratio0, ratioMa0, ratioRsi0, ratio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
@@ -5950,7 +6167,8 @@ impl WasmOpeningRange {
//
// All 15 patterns take Candles (open, high, low, close) and emit a signed f64
// signal per bar: +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is
// direction-less and emits 0/+1 only.
// direction-less by default (0/+1); pass `signed = true` to its constructor for
// the dragonfly/gravestone signed +-1 encoding.
macro_rules! wasm_candle_pattern {
($wasm:ident, $inner:ty, $js:ident) => {
@@ -6017,7 +6235,75 @@ macro_rules! wasm_candle_pattern {
};
}
wasm_candle_pattern!(WasmDoji, wc::Doji, Doji);
// Doji is the one pattern with an opt-in signed mode, so it is hand-written
// rather than generated by `wasm_candle_pattern!`.
#[wasm_bindgen(js_name = Doji)]
pub struct WasmDoji {
inner: wc::Doji,
}
impl Default for WasmDoji {
fn default() -> Self {
Self::new(None)
}
}
#[wasm_bindgen(js_class = Doji)]
impl WasmDoji {
#[wasm_bindgen(constructor)]
pub fn new(signed: Option<bool>) -> WasmDoji {
let inner = if signed.unwrap_or(false) {
wc::Doji::new().signed()
} else {
wc::Doji::new()
};
Self { inner }
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = isSigned)]
pub fn is_signed(&self) -> bool {
self.inner.is_signed()
}
}
wasm_candle_pattern!(WasmHammer, wc::Hammer, Hammer);
wasm_candle_pattern!(WasmInvertedHammer, wc::InvertedHammer, InvertedHammer);
wasm_candle_pattern!(WasmHangingMan, wc::HangingMan, HangingMan);
@@ -6045,6 +6331,230 @@ wasm_candle_pattern!(WasmSpinningTop, wc::SpinningTop, SpinningTop);
wasm_candle_pattern!(WasmThreeInside, wc::ThreeInside, ThreeInside);
wasm_candle_pattern!(WasmThreeOutside, wc::ThreeOutside, ThreeOutside);
// ============================== Microstructure: Order Book ==============================
//
// Order-book indicators consume a depth snapshot rather than OHLCV. Each
// `update(bidPx, bidSz, askPx, askSz)` takes four equal-length typed arrays for
// one snapshot (bids best-first = descending price, asks best-first = ascending
// price) — the streaming model that fits a live browser book feed. Batch over a
// ragged depth history is provided by the Python and Node bindings.
fn build_order_book(
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<wc::OrderBook, JsError> {
if bid_px.len() != bid_sz.len() || ask_px.len() != ask_sz.len() {
return Err(JsError::new(
"bid/ask price and size arrays must be equal length",
));
}
let bids = bid_px
.iter()
.zip(bid_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
let asks = ask_px
.iter()
.zip(ask_sz)
.map(|(&p, &s)| wc::Level::new_unchecked(p, s))
.collect();
wc::OrderBook::new(bids, asks).map_err(map_err)
}
macro_rules! wasm_ob_indicator {
($wasm:ident, $inner:ty, $js:ident) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wasm {
inner: $inner,
}
impl Default for $wasm {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = $js)]
impl $wasm {
#[wasm_bindgen(constructor)]
pub fn new() -> $wasm {
Self {
inner: <$inner>::new(),
}
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
};
}
wasm_ob_indicator!(
WasmOrderBookImbalanceTop1,
wc::OrderBookImbalanceTop1,
OrderBookImbalanceTop1
);
wasm_ob_indicator!(
WasmOrderBookImbalanceFull,
wc::OrderBookImbalanceFull,
OrderBookImbalanceFull
);
wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice);
wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread);
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
#[wasm_bindgen(js_name = OrderBookImbalanceTopN)]
pub struct WasmOrderBookImbalanceTopN {
inner: wc::OrderBookImbalanceTopN,
}
#[wasm_bindgen(js_class = OrderBookImbalanceTopN)]
impl WasmOrderBookImbalanceTopN {
#[wasm_bindgen(constructor)]
pub fn new(levels: usize) -> Result<WasmOrderBookImbalanceTopN, JsError> {
Ok(Self {
inner: wc::OrderBookImbalanceTopN::new(levels).map_err(map_err)?,
})
}
pub fn update(
&mut self,
bid_px: &[f64],
bid_sz: &[f64],
ask_px: &[f64],
ask_sz: &[f64],
) -> Result<Option<f64>, JsError> {
let book = build_order_book(bid_px, bid_sz, ask_px, ask_sz)?;
Ok(self.inner.update(book))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ============================== Microstructure: Trade Flow ==============================
//
// Trade-flow indicators consume a trade tape rather than OHLCV. Each
// `update(price, size, isBuy)` takes one trade (`isBuy=true` for a
// buyer-initiated trade) — the streaming model for a live browser trade feed.
fn build_trade(price: f64, size: f64, is_buy: bool) -> Result<wc::Trade, JsError> {
let side = if is_buy {
wc::Side::Buy
} else {
wc::Side::Sell
};
wc::Trade::new(price, size, side, 0).map_err(map_err)
}
macro_rules! wasm_trade_indicator {
($wasm:ident, $inner:ty, $js:ident) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wasm {
inner: $inner,
}
impl Default for $wasm {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = $js)]
impl $wasm {
#[wasm_bindgen(constructor)]
pub fn new() -> $wasm {
Self {
inner: <$inner>::new(),
}
}
pub fn update(
&mut self,
price: f64,
size: f64,
is_buy: bool,
) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
};
}
wasm_trade_indicator!(WasmSignedVolume, wc::SignedVolume, SignedVolume);
wasm_trade_indicator!(
WasmCumulativeVolumeDelta,
wc::CumulativeVolumeDelta,
CumulativeVolumeDelta
);
// Trade imbalance carries a `window` parameter, so it is hand-written.
#[wasm_bindgen(js_name = TradeImbalance)]
pub struct WasmTradeImbalance {
inner: wc::TradeImbalance,
}
#[wasm_bindgen(js_class = TradeImbalance)]
impl WasmTradeImbalance {
#[wasm_bindgen(constructor)]
pub fn new(window: usize) -> Result<WasmTradeImbalance, JsError> {
Ok(Self {
inner: wc::TradeImbalance::new(window).map_err(map_err)?,
})
}
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(build_trade(price, size, is_buy)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -6139,6 +6649,12 @@ mod tests {
fn close_enough(a: f64, b: f64) -> bool {
if a.is_nan() {
b.is_nan()
} else if a == b {
// Exact equality, including matching infinities (e.g. ProfitFactor
// with no losing trades is +inf in both the streaming and batch
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
// would otherwise reject two equal infinities.
true
} else {
(a - b).abs() < 1e-9
}
@@ -6630,6 +7146,146 @@ mod tests {
"ready after 5 finite inputs even with prior NaNs"
);
}
// Streaming `update` must reproduce `batch` value-for-value for every scalar
// indicator — the core O(1) state-machine invariant. Each entry builds a
// fresh instance for the batch pass and another for the streaming pass. The
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
// factories, so they are known-valid.
macro_rules! assert_scalar_stream_eq {
($ctor:expr, $prices:expr) => {{
let prices: &[f64] = $prices;
let batch = { $ctor }.batch(prices);
let mut streaming = { $ctor };
for (i, &p) in prices.iter().enumerate() {
let b = batch.get_index(i as u32);
match streaming.update(p) {
Some(v) => assert!(
close_enough(v, b),
"{} streaming != batch at {i}: {v} vs {b}",
stringify!($ctor)
),
None => assert!(
b.is_nan(),
"{} expected NaN warmup at {i}",
stringify!($ctor)
),
}
}
}};
}
#[allow(clippy::too_many_lines)]
#[wasm_bindgen_test]
fn scalar_streaming_matches_batch_broad() {
let prices: Vec<f64> = (0..120)
.map(|i| {
let t = f64::from(i);
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
})
.collect();
let p = prices.as_slice();
// Moving averages.
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
// Momentum / oscillators.
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
// Volatility / statistics / regression.
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
// Ehlers / cycle.
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
assert_scalar_stream_eq!(
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
p
);
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
// Risk / performance (scalar f64 input).
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
}
// Additional invalid-constructor coverage. These wrap the same fallible core
// `new` as the Python / Node bindings, where the equivalent calls are
// confirmed to error.
#[wasm_bindgen_test]
fn additional_invalid_constructors_are_rejected() {
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
}
}
// ============================== Family 15: Risk / Performance ==============================
+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));
}
}
+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));
}
}
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// body exists to engulf. Pattern-shape check only — no trend filter is
/// applied; combine with a trend indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Hammer is bullish by definition, so under the uniform candlestick sign
/// convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits `+1.0`
/// when the shape matches and `0.0` otherwise — it never emits `1.0`. The
/// same geometry read at the top of an uptrend is the bearish `HangingMan`,
/// which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Hanging Man is bearish by definition, so under the uniform candlestick
/// sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits
/// `1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
/// The same geometry read at the bottom of a downtrend is the bullish
/// `Hammer`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -23,6 +23,13 @@ use crate::traits::Indicator;
/// no trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// An Inverted Hammer is bullish by definition, so under the uniform
/// candlestick sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it
/// emits `+1.0` when the shape matches and `0.0` otherwise — it never emits
/// `1.0`. The same geometry read at the top of an uptrend is the bearish
/// `ShootingStar`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -0,0 +1,324 @@
//! 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());
}
}
+40 -1
View File
@@ -43,9 +43,11 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cvd;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
@@ -99,6 +101,7 @@ mod kst;
mod kurtosis;
mod kvo;
mod laguerre_rsi;
mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
@@ -114,14 +117,20 @@ mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mom;
mod morning_evening_star;
mod natr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
mod ob_imbalance_topn;
mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -133,8 +142,10 @@ mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod quoted_spread;
mod r_squared;
mod recovery_factor;
mod relative_strength_ab;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -145,6 +156,7 @@ mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod signed_volume;
mod sine_wave;
mod skewness;
mod sma;
@@ -181,6 +193,7 @@ mod three_inside;
mod three_outside;
mod three_soldiers_or_crows;
mod tii;
mod trade_imbalance;
mod treynor_ratio;
mod trima;
mod trix;
@@ -257,9 +270,11 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
@@ -313,6 +328,7 @@ pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use laguerre_rsi::LaguerreRsi;
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
@@ -328,14 +344,20 @@ pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mom::Mom;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
pub use ob_imbalance_topn::OrderBookImbalanceTopN;
pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -347,8 +369,10 @@ pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -359,6 +383,7 @@ pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use skewness::Skewness;
pub use sma::Sma;
@@ -395,6 +420,7 @@ pub use three_inside::ThreeInside;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use tii::Tii;
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
@@ -697,6 +723,19 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"ThreeOutside",
],
),
(
"Microstructure",
&[
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
],
),
(
"Market Profile",
&["ValueArea", "InitialBalance", "OpeningRange"],
@@ -751,6 +790,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 214, "FAMILIES total drifted from indicator count");
assert_eq!(total, 222, "FAMILIES total drifted from indicator count");
}
}
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
/// trend filter is applied; combine with a trend indicator for actionable
/// signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,157 @@
//! Order-Book Imbalance over the full visible depth.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance aggregated over the full visible depth of each side.
///
/// Sums the resting size of every bid level and every ask level in the
/// snapshot and compares them:
///
/// ```text
/// bidDepth = Σ size of all bids
/// askDepth = Σ size of all asks
/// imbalance = (bidDepth askDepth) / (bidDepth + askDepth)
/// ```
///
/// The output lies in `[1, +1]`. A book with zero total size yields `0`. Use
/// [`crate::OrderBookImbalanceTopN`] to bound the depth to the most relevant
/// near-touch levels instead of the full visible book.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceFull};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 0.5).unwrap(), Level::new(102.0, 0.5).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceFull::new();
/// assert_eq!(obi.update(book), Some(0.5)); // (3 1) / (3 + 1)
/// ```
#[derive(Debug, Clone, Default)]
pub struct OrderBookImbalanceFull {
has_emitted: bool,
}
impl OrderBookImbalanceFull {
/// Construct a new full-depth imbalance indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for OrderBookImbalanceFull {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum();
let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum();
let total = bid_depth + ask_depth;
if total <= 0.0 {
return Some(0.0);
}
Some((bid_depth - ask_depth) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceFull"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceFull::new();
assert_eq!(obi.name(), "OrderBookImbalanceFull");
assert_eq!(obi.warmup_period(), 1);
assert!(!obi.is_ready());
}
#[test]
fn sums_full_depth() {
let mut obi = OrderBookImbalanceFull::new();
let b = book(&[(100.0, 2.0), (99.0, 2.0)], &[(101.0, 1.0), (102.0, 1.0)]);
// bidDepth 4, askDepth 2 -> (4 - 2) / 6 = 1/3.
assert_eq!(obi.update(b), Some(1.0 / 3.0));
assert!(obi.is_ready());
}
#[test]
fn ask_heavy_full_depth_is_negative() {
let mut obi = OrderBookImbalanceFull::new();
let b = book(&[(100.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)]);
// (1 - 3) / 4 = -0.5.
assert_eq!(obi.update(b), Some(-0.5));
}
#[test]
fn zero_size_is_zero() {
let mut obi = OrderBookImbalanceFull::new();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let bid = 1.0 + f64::from(i % 3);
book(&[(100.0, bid), (99.0, 1.0)], &[(101.0, 2.0), (102.0, 1.0)])
})
.collect();
let mut a = OrderBookImbalanceFull::new();
let mut b = OrderBookImbalanceFull::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceFull::new();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,176 @@
//! Order-Book Imbalance at the top of book.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance (top-of-book).
///
/// Measures the pressure between the best bid and best ask by comparing their
/// resting sizes:
///
/// ```text
/// imbalance = (bidSize₁ askSize₁) / (bidSize₁ + askSize₁)
/// ```
///
/// The output lies in `[1, +1]`: `+1` means all size sits on the bid (buy
/// pressure), `1` means all size sits on the ask (sell pressure), `0` means a
/// balanced top of book. A book with zero size on both top levels yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. The indicator is stateless and ready
/// after the first snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTop1};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 3.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceTop1::new();
/// assert_eq!(obi.update(book), Some(0.5)); // (3 1) / (3 + 1)
/// ```
#[derive(Debug, Clone, Default)]
pub struct OrderBookImbalanceTop1 {
has_emitted: bool,
}
impl OrderBookImbalanceTop1 {
/// Construct a new top-of-book imbalance indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for OrderBookImbalanceTop1 {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let total = bid.size + ask.size;
if total <= 0.0 {
return Some(0.0);
}
Some((bid.size - ask.size) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceTop1"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceTop1::new();
assert_eq!(obi.name(), "OrderBookImbalanceTop1");
assert_eq!(obi.warmup_period(), 1);
assert!(!obi.is_ready());
}
#[test]
fn balanced_top_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 2.0)], &[(101.0, 2.0)])),
Some(0.0)
);
assert!(obi.is_ready());
}
#[test]
fn bid_heavy_is_positive() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
Some(0.5)
);
}
#[test]
fn ask_heavy_is_negative() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 1.0)], &[(101.0, 3.0)])),
Some(-0.5)
);
}
#[test]
fn zero_size_top_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn empty_book_is_zero() {
let mut obi = OrderBookImbalanceTop1::new();
assert_eq!(
obi.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let bid = 1.0 + f64::from(i % 5);
book(&[(100.0, bid)], &[(101.0, 2.0)])
})
.collect();
let mut a = OrderBookImbalanceTop1::new();
let mut b = OrderBookImbalanceTop1::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceTop1::new();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,186 @@
//! Order-Book Imbalance over the top-N levels.
use crate::error::{Error, Result};
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Order-Book Imbalance aggregated over the top-N levels of each side.
///
/// Generalises [`crate::OrderBookImbalanceTop1`] to a configurable depth: it
/// sums the resting size of the best `levels` bids and the best `levels` asks
/// and compares them:
///
/// ```text
/// bidDepth = Σ size of the best `levels` bids
/// askDepth = Σ size of the best `levels` asks
/// imbalance = (bidDepth askDepth) / (bidDepth + askDepth)
/// ```
///
/// If a side has fewer than `levels` levels, all available levels are summed.
/// The output lies in `[1, +1]`; a book with zero size across the summed
/// levels yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderBookImbalanceTopN};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 2.0).unwrap(), Level::new(99.0, 1.0).unwrap()],
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
/// assert_eq!(obi.update(book), Some(0.2)); // (3 2) / (3 + 2)
/// ```
#[derive(Debug, Clone)]
pub struct OrderBookImbalanceTopN {
levels: usize,
has_emitted: bool,
}
impl OrderBookImbalanceTopN {
/// Construct a top-N imbalance indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `levels` is zero.
pub fn new(levels: usize) -> Result<Self> {
if levels == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
levels,
has_emitted: false,
})
}
/// The configured number of levels summed per side.
pub fn levels(&self) -> usize {
self.levels
}
}
impl Indicator for OrderBookImbalanceTopN {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let bid_depth: f64 = book.bids.iter().take(self.levels).map(|l| l.size).sum();
let ask_depth: f64 = book.asks.iter().take(self.levels).map(|l| l.size).sum();
let total = bid_depth + ask_depth;
if total <= 0.0 {
return Some(0.0);
}
Some((bid_depth - ask_depth) / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OrderBookImbalanceTopN"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn rejects_zero_levels() {
assert!(matches!(
OrderBookImbalanceTopN::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let obi = OrderBookImbalanceTopN::new(3).unwrap();
assert_eq!(obi.name(), "OrderBookImbalanceTopN");
assert_eq!(obi.warmup_period(), 1);
assert_eq!(obi.levels(), 3);
assert!(!obi.is_ready());
}
#[test]
fn sums_top_two_levels() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
let b = book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)]);
// bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
assert_eq!(obi.update(b), Some(0.2));
assert!(obi.is_ready());
}
#[test]
fn caps_at_available_depth() {
// Only one level per side, N = 5 -> uses what exists.
let mut obi = OrderBookImbalanceTopN::new(5).unwrap();
assert_eq!(
obi.update(book(&[(100.0, 3.0)], &[(101.0, 1.0)])),
Some(0.5)
);
}
#[test]
fn zero_size_is_zero() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
assert_eq!(
obi.update(book(&[(100.0, 0.0)], &[(101.0, 0.0)])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 1.0 + f64::from(i % 4);
book(&[(100.0, 2.0), (99.0, 1.0)], &[(101.0, ask), (102.0, 1.0)])
})
.collect();
let mut a = OrderBookImbalanceTopN::new(2).unwrap();
let mut b = OrderBookImbalanceTopN::new(2).unwrap();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obi = OrderBookImbalanceTopN::new(2).unwrap();
obi.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(obi.is_ready());
obi.reset();
assert!(!obi.is_ready());
}
}
@@ -0,0 +1,299 @@
//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Z-score of the log-spread `ln(a) β·ln(b)` between two assets.
///
/// This is the canonical mean-reversion / statistical-arbitrage signal for a
/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the
/// indicator does two things:
///
/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of
/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the
/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the
/// residual against the origin, `s = ln(a) β·ln(b)`.
/// 2. **Standardisation.** The spread is then z-scored over the trailing
/// `z_period` spreads: `z = (s mean_s) / std_s`.
///
/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a
/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means
/// the pair is at its typical relationship. The two windows are independent:
/// `beta_period` controls how much history the hedge ratio adapts over, and
/// `z_period` controls the look-back for the mean and dispersion of the spread.
///
/// Each `update` is O(1): five running sums maintain the rolling OLS and two
/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has
/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`,
/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion)
/// yields a z-score of `0` rather than `NaN`.
///
/// Prices must be strictly positive and finite for the logarithm to be
/// defined; a non-positive or non-finite price is skipped (it does not enter
/// either window), exactly as a real feed would discard a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairSpreadZScore};
///
/// let mut zs = PairSpreadZScore::new(2, 2).unwrap();
/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with
/// // a 2-sample z-window the z-score collapses to the sign of the last move.
/// let mut last = None;
/// for a in [100.0, 100.0, 110.0, 120.0] {
/// last = zs.update((a, 100.0));
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairSpreadZScore {
beta_period: usize,
z_period: usize,
// Rolling OLS of y = ln(a) on x = ln(b).
reg: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xx: f64,
sum_xy: f64,
// Rolling mean/variance of the spread.
spreads: VecDeque<f64>,
sum_s: f64,
sum_ss: f64,
}
impl PairSpreadZScore {
/// Construct a new pair spread z-score.
///
/// `beta_period` is the look-back for the rolling hedge ratio; `z_period`
/// is the look-back for standardising the spread.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if either period is below `2`
/// (variance needs at least two points).
pub fn new(beta_period: usize, z_period: usize) -> Result<Self> {
if beta_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs beta_period >= 2",
});
}
if z_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs z_period >= 2",
});
}
Ok(Self {
beta_period,
z_period,
reg: VecDeque::with_capacity(beta_period),
sum_x: 0.0,
sum_y: 0.0,
sum_xx: 0.0,
sum_xy: 0.0,
spreads: VecDeque::with_capacity(z_period),
sum_s: 0.0,
sum_ss: 0.0,
})
}
/// Look-back of the rolling hedge-ratio regression.
pub const fn beta_period(&self) -> usize {
self.beta_period
}
/// Look-back of the rolling spread standardisation.
pub const fn z_period(&self) -> usize {
self.z_period
}
/// The current hedge ratio `β`, or `None` while the regression is warming
/// up. A flat `ln(b)` window reports `0`.
fn hedge_ratio(&self) -> Option<f64> {
if self.reg.len() < self.beta_period {
return None;
}
let n = self.beta_period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
if var_x == 0.0 {
return Some(0.0);
}
let cov = self.sum_xy / n - mean_x * mean_y;
Some(cov / var_x)
}
fn push_spread(&mut self, s: f64) -> Option<f64> {
if self.spreads.len() == self.z_period {
let old = self.spreads.pop_front().expect("non-empty");
self.sum_s -= old;
self.sum_ss -= old * old;
}
self.spreads.push_back(s);
self.sum_s += s;
self.sum_ss += s * s;
if self.spreads.len() < self.z_period {
return None;
}
let m = self.z_period as f64;
let mean_s = self.sum_s / m;
let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0);
let std_s = var_s.sqrt();
if std_s == 0.0 {
// A flat spread window has no dispersion to standardise against.
return Some(0.0);
}
Some((s - mean_s) / std_s)
}
}
impl Indicator for PairSpreadZScore {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: skip it without disturbing either window.
return None;
}
let x = b.ln();
let y = a.ln();
if self.reg.len() == self.beta_period {
let (ox, oy) = self.reg.pop_front().expect("non-empty");
self.sum_x -= ox;
self.sum_y -= oy;
self.sum_xx -= ox * ox;
self.sum_xy -= ox * oy;
}
self.reg.push_back((x, y));
self.sum_x += x;
self.sum_y += y;
self.sum_xx += x * x;
self.sum_xy += x * y;
let beta = self.hedge_ratio()?;
let spread = y - beta * x;
self.push_spread(spread)
}
fn reset(&mut self) {
self.reg.clear();
self.sum_x = 0.0;
self.sum_y = 0.0;
self.sum_xx = 0.0;
self.sum_xy = 0.0;
self.spreads.clear();
self.sum_s = 0.0;
self.sum_ss = 0.0;
}
fn warmup_period(&self) -> usize {
// `beta_period` samples to define the hedge ratio (and the first
// spread), then `z_period 1` more to fill the spread window.
self.beta_period + self.z_period - 1
}
fn is_ready(&self) -> bool {
self.spreads.len() == self.z_period
}
fn name(&self) -> &'static str {
"PairSpreadZScore"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_periods_below_two() {
assert!(PairSpreadZScore::new(1, 5).is_err());
assert!(PairSpreadZScore::new(5, 1).is_err());
assert!(PairSpreadZScore::new(2, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let z = PairSpreadZScore::new(10, 20).unwrap();
assert_eq!(z.beta_period(), 10);
assert_eq!(z.z_period(), 20);
assert_eq!(z.warmup_period(), 29);
assert_eq!(z.name(), "PairSpreadZScore");
}
#[test]
fn flat_benchmark_two_sample_window_is_sign_of_move() {
// Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move.
let mut z = PairSpreadZScore::new(2, 2).unwrap();
assert_eq!(z.update((100.0, 100.0)), None);
assert_eq!(z.update((100.0, 100.0)), None);
// The ±1 result is exact in real arithmetic; the variance is computed
// via Σs²−mean² so a few ulps of cancellation error remain.
assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn constant_spread_yields_zero() {
// Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect();
let last = PairSpreadZScore::new(3, 4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_is_skipped() {
let mut z = PairSpreadZScore::new(2, 2).unwrap();
// A non-positive or non-finite price never enters the windows.
assert_eq!(z.update((0.0, 100.0)), None);
assert_eq!(z.update((100.0, f64::NAN)), None);
assert!(!z.is_ready());
// Valid ticks then warm the indicator normally.
z.update((100.0, 100.0));
z.update((100.0, 100.0));
z.update((110.0, 100.0));
assert!(z.is_ready());
}
#[test]
fn reset_clears_state() {
let mut z = PairSpreadZScore::new(3, 3).unwrap();
for i in 0..10 {
let b = 100.0 + 5.0 * f64::from(i).sin();
z.update((b * 1.5, b));
}
assert!(z.is_ready());
z.reset();
assert!(!z.is_ready());
assert_eq!(z.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 10.0 * (t * 0.2).sin();
let a = b * (1.0 + 0.05 * (t * 0.5).cos());
(a, b)
})
.collect();
let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs);
let mut z = PairSpreadZScore::new(14, 10).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,292 @@
//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
///
/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
/// indicator differences consecutive prices into log-returns
/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
/// `a`'s returns on `b`'s returns over the trailing window of `period` return
/// pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ rₐ·r_b r̄ₐ·r̄_b
/// var_b = (1/n) · Σ r_b² r̄_b²
/// Beta = cov_ab / var_b
/// ```
///
/// This is the slope of the OLS line and measures how much asset `a` moves, in
/// return space, for a unit return of asset `b`. A reading of `1.0` means the
/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
/// moves; negative readings signal an inverse relationship and the basis for a
/// hedge.
///
/// This differs from [`crate::Beta`], which regresses the raw inputs it is
/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
/// and it computes the returns for you, which is the conventional way to
/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
/// shared trend and rarely what you want).
///
/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
/// window has zero return variance and Beta is undefined; the indicator
/// returns `0` in that case rather than producing `NaN`.
///
/// Prices must be strictly positive and finite for the log-return to be
/// defined. A non-positive or non-finite price breaks the return chain: that
/// sample is dropped and the next valid price re-seeds the previous-price
/// reference, exactly as a real feed would resume after a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairwiseBeta};
///
/// let mut indicator = PairwiseBeta::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// // A varying (non-constant-return) positive price path.
/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
/// // `a = b²`, so a's log-returns are exactly twice b's.
/// last = indicator.update((b * b, b));
/// }
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairwiseBeta {
period: usize,
prev: Option<(f64, f64)>,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl PairwiseBeta {
/// Construct a new rolling pairwise Beta over `period` return pairs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "pairwise beta needs period >= 2",
});
}
Ok(Self {
period,
prev: None,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured period (number of return pairs in the rolling window).
pub const fn period(&self) -> usize {
self.period
}
fn push_return(&mut self, ra: f64, rb: f64) -> Option<f64> {
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((ra, rb));
self.sum_a += ra;
self.sum_b += rb;
self.sum_bb += rb * rb;
self.sum_ab += ra * rb;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let cov = self.sum_ab / n - mean_a * mean_b;
if var_b == 0.0 {
// A flat benchmark-return window has no defined beta.
return Some(0.0);
}
Some(cov / var_b)
}
}
impl Indicator for PairwiseBeta {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: drop it and restart the return chain.
self.prev = None;
return None;
}
let Some((pa, pb)) = self.prev else {
self.prev = Some((a, b));
return None;
};
self.prev = Some((a, b));
let ra = (a / pa).ln();
let rb = (b / pb).ln();
self.push_return(ra, rb)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
// One prior price to seed, then `period` return pairs.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"PairwiseBeta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(PairwiseBeta::new(0).is_err());
assert!(PairwiseBeta::new(1).is_err());
assert!(PairwiseBeta::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let b = PairwiseBeta::new(14).unwrap();
assert_eq!(b.period(), 14);
assert_eq!(b.warmup_period(), 15);
assert_eq!(b.name(), "PairwiseBeta");
}
#[test]
fn squared_price_gives_beta_two() {
// a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(b * b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
}
#[test]
fn inverse_price_gives_beta_minus_one() {
// a = 1/b ⇒ a's log-returns are 1× b's ⇒ beta = 1.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(1.0 / b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_returns_zero() {
// b constant ⇒ zero return variance ⇒ beta defined as 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_breaks_return_chain() {
let mut b = PairwiseBeta::new(3).unwrap();
// Seed, one good return, then a non-positive price drops the chain.
assert_eq!(b.update((100.0, 100.0)), None);
assert_eq!(b.update((101.0, 101.0)), None);
assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
assert!(!b.is_ready());
// A non-finite price is rejected the same way.
assert_eq!(b.update((f64::NAN, 50.0)), None);
assert!(!b.is_ready());
// Recovery: subsequent valid prices rebuild the window cleanly.
for i in 0..5 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
}
#[test]
fn reset_clears_state() {
let mut b = PairwiseBeta::new(3).unwrap();
for i in 0..6 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
b.reset();
assert!(!b.is_ready());
assert_eq!(b.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 5.0 * t.sin();
let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
(a, b)
})
.collect();
let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
let mut b = PairwiseBeta::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -26,6 +26,13 @@ use crate::traits::Indicator;
/// only — no trend filter is applied; combine with a trend indicator for
/// actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,153 @@
//! Quoted Spread — top-of-book spread in basis points.
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
/// Quoted Spread — the top-of-book bid-ask spread expressed in basis points of
/// the mid price.
///
/// ```text
/// mid = (bidPrice₁ + askPrice₁) / 2
/// quotedSpread = (askPrice₁ bidPrice₁) / mid · 10_000 (bps)
/// ```
///
/// This is the round-trip cost of crossing the spread at the touch, normalised
/// by price so it is comparable across instruments. For a valid (uncrossed)
/// book the result is non-negative. An empty book yields `0`.
///
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
/// snapshot.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, QuotedSpread};
///
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 1.0).unwrap()],
/// vec![Level::new(100.5, 1.0).unwrap()],
/// )
/// .unwrap();
/// let mut qs = QuotedSpread::new();
/// // spread 0.5, mid 100.25 -> 0.5 / 100.25 * 10_000 ≈ 49.875 bps.
/// let bps = qs.update(book).unwrap();
/// assert!((bps - 49.875_311_72).abs() < 1e-6);
/// ```
#[derive(Debug, Clone, Default)]
pub struct QuotedSpread {
has_emitted: bool,
}
impl QuotedSpread {
/// Construct a new quoted-spread indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for QuotedSpread {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
self.has_emitted = true;
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return Some(0.0);
};
let mid = f64::midpoint(bid.price, ask.price);
Some((ask.price - bid.price) / mid * 10_000.0)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"QuotedSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
let to_levels = |xs: &[(f64, f64)]| {
xs.iter()
.map(|&(p, s)| Level::new(p, s).unwrap())
.collect::<Vec<_>>()
};
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
}
#[test]
fn accessors_and_metadata() {
let qs = QuotedSpread::new();
assert_eq!(qs.name(), "QuotedSpread");
assert_eq!(qs.warmup_period(), 1);
assert!(!qs.is_ready());
}
#[test]
fn known_value_in_bps() {
let mut qs = QuotedSpread::new();
// spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
let bps = qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)])).unwrap();
assert!((bps - 99.502_487_56).abs() < 1e-6);
assert!(qs.is_ready());
}
#[test]
fn tight_book_is_small() {
let mut qs = QuotedSpread::new();
let bps = qs.update(book(&[(100.0, 1.0)], &[(100.01, 1.0)])).unwrap();
assert!(bps > 0.0 && bps < 2.0);
}
#[test]
fn empty_book_is_zero() {
let mut qs = QuotedSpread::new();
assert_eq!(
qs.update(OrderBook::new_unchecked(vec![], vec![])),
Some(0.0)
);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..20)
.map(|i| {
let ask = 100.5 + f64::from(i % 4) * 0.1;
book(&[(100.0, 1.0)], &[(ask, 1.0)])
})
.collect();
let mut a = QuotedSpread::new();
let mut b = QuotedSpread::new();
assert_eq!(
a.batch(&books),
books
.iter()
.map(|x| b.update(x.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut qs = QuotedSpread::new();
qs.update(book(&[(100.0, 1.0)], &[(101.0, 1.0)]));
assert!(qs.is_ready());
qs.reset();
assert!(!qs.is_ready());
}
}
@@ -0,0 +1,233 @@
//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
use crate::error::Result;
use crate::indicators::{Rsi, Sma};
use crate::traits::Indicator;
/// Output of [`RelativeStrengthAB`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RelativeStrengthOutput {
/// The raw relative-strength ratio `a / b`.
pub ratio: f64,
/// Simple moving average of the ratio over `ma_period`.
pub ratio_ma: f64,
/// Relative Strength Index of the ratio over `rsi_period`.
pub ratio_rsi: f64,
}
/// Comparative relative strength of asset `a` against asset `b`.
///
/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
/// `a / b`. The ratio is then smoothed with a simple moving average and run
/// through an RSI, so a single indicator gives you the relative-strength level,
/// its trend, and whether that trend is overbought or oversold:
///
/// ```text
/// ratio = a / b
/// ratio_ma = SMA(ratio, ma_period)
/// ratio_rsi = RSI(ratio, rsi_period)
/// ```
///
/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
/// "asset-vs-index" rotation screen.
///
/// The first output appears once both the moving average and the RSI have
/// warmed up; the ratio itself is computed from the first valid pair. A
/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
/// and is skipped, leaving the internal averages untouched.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RelativeStrengthAB};
///
/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
/// let mut last = None;
/// for _ in 0..20 {
/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
/// }
/// let out = last.unwrap();
/// assert!((out.ratio - 2.0).abs() < 1e-12);
/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RelativeStrengthAB {
ma_period: usize,
rsi_period: usize,
ma: Sma,
rsi: Rsi,
}
impl RelativeStrengthAB {
/// Construct a new comparative relative-strength indicator.
///
/// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
/// is the RSI look-back of the ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
/// is zero.
pub fn new(ma_period: usize, rsi_period: usize) -> Result<Self> {
Ok(Self {
ma_period,
rsi_period,
ma: Sma::new(ma_period)?,
rsi: Rsi::new(rsi_period)?,
})
}
/// Moving-average look-back of the ratio.
pub const fn ma_period(&self) -> usize {
self.ma_period
}
/// RSI look-back of the ratio.
pub const fn rsi_period(&self) -> usize {
self.rsi_period
}
}
impl Indicator for RelativeStrengthAB {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = RelativeStrengthOutput;
fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
let (a, b) = input;
if b == 0.0 || !a.is_finite() || !b.is_finite() {
// Undefined ratio: skip without disturbing the internal averages.
return None;
}
let ratio = a / b;
let ma = self.ma.update(ratio);
let rsi = self.rsi.update(ratio);
match (ma, rsi) {
(Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
ratio,
ratio_ma,
ratio_rsi,
}),
_ => None,
}
}
fn reset(&mut self) {
self.ma.reset();
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.ma.warmup_period().max(self.rsi.warmup_period())
}
fn is_ready(&self) -> bool {
self.ma.is_ready() && self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"RelativeStrengthAB"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(RelativeStrengthAB::new(0, 5).is_err());
assert!(RelativeStrengthAB::new(5, 0).is_err());
assert!(RelativeStrengthAB::new(5, 5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rs = RelativeStrengthAB::new(10, 14).unwrap();
assert_eq!(rs.ma_period(), 10);
assert_eq!(rs.rsi_period(), 14);
// SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
assert_eq!(rs.warmup_period(), 15);
assert_eq!(rs.name(), "RelativeStrengthAB");
}
#[test]
fn constant_ratio_is_flat() {
// a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
}
#[test]
fn rising_ratio_is_overbought() {
// a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
.collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.ratio > 1.0);
assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
}
#[test]
fn zero_denominator_is_skipped() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
// b == 0 and non-finite inputs never reach the internal averages.
assert_eq!(rs.update((100.0, 0.0)), None);
assert_eq!(rs.update((f64::NAN, 100.0)), None);
assert!(!rs.is_ready());
for _ in 0..8 {
rs.update((150.0, 100.0));
}
assert!(rs.is_ready());
}
#[test]
fn reset_clears_state() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
for t in 0..10 {
rs.update((100.0 + f64::from(t), 100.0));
}
assert!(rs.is_ready());
rs.reset();
assert!(!rs.is_ready());
assert_eq!(rs.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let tt = f64::from(t);
(
100.0 + 5.0 * (tt * 0.3).sin(),
100.0 + 2.0 * (tt * 0.2).cos(),
)
})
.collect();
let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -22,6 +22,14 @@ use crate::traits::Indicator;
/// check only — no trend filter is applied; combine with a trend indicator
/// for actionable signals.
///
/// # Signed ±1 encoding
///
/// A Shooting Star is bearish by definition, so under the uniform candlestick
/// sign convention (`+1.0` bullish, `1.0` bearish, `0.0` none) it emits
/// `1.0` when the shape matches and `0.0` otherwise — it never emits `+1.0`.
/// The same geometry read at the bottom of a downtrend is the bullish
/// `InvertedHammer`, which carries the opposite sign.
///
/// # Example
///
/// ```
@@ -0,0 +1,128 @@
//! Signed Volume — per-trade volume signed by aggressor side.
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Signed Volume — the size of each trade signed by its aggressor side.
///
/// ```text
/// signedVolume = size · (+1 if buy, 1 if sell)
/// ```
///
/// A positive value is buyer-initiated flow, a negative value seller-initiated.
/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and
/// trade-flow imbalance.
///
/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SignedVolume, Side, Trade};
///
/// let mut sv = SignedVolume::new();
/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap();
/// assert_eq!(sv.update(buy), Some(2.0));
/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap();
/// assert_eq!(sv.update(sell), Some(-3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct SignedVolume {
has_emitted: bool,
}
impl SignedVolume {
/// Construct a new signed-volume indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for SignedVolume {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
self.has_emitted = true;
Some(trade.size * trade.side.sign())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SignedVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let sv = SignedVolume::new();
assert_eq!(sv.name(), "SignedVolume");
assert_eq!(sv.warmup_period(), 1);
assert!(!sv.is_ready());
}
#[test]
fn buy_is_positive() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0));
assert!(sv.is_ready());
}
#[test]
fn sell_is_negative() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0));
}
#[test]
fn zero_size_is_zero() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..20)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 4) as f64, side, i)
})
.collect();
let mut a = SignedVolume::new();
let mut b = SignedVolume::new();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut sv = SignedVolume::new();
sv.update(trade(1.0, Side::Buy, 0));
assert!(sv.is_ready());
sv.reset();
assert!(!sv.is_ready());
}
}
@@ -24,6 +24,13 @@ use crate::traits::Indicator;
///
/// `body_threshold` defaults to `0.3` and must lie in `(0, 1]`.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -18,6 +18,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -17,6 +17,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -20,6 +20,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
@@ -0,0 +1,193 @@
//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing
/// window of `window` trades.
///
/// ```text
/// buyVol = Σ size of buyer-initiated trades in the window
/// sellVol = Σ size of seller-initiated trades in the window
/// imbalance = (buyVol sellVol) / (buyVol + sellVol)
/// ```
///
/// The output lies in `[1, +1]`: `+1` means the window was all aggressive
/// buying, `1` all aggressive selling, `0` balanced (or no volume). The
/// indicator warms up for `window` trades — `update` returns `None` until the
/// window is full — then emits the rolling imbalance, maintained in O(1) per
/// trade.
///
/// `Input = Trade`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, TradeImbalance};
///
/// let mut ti = TradeImbalance::new(2).unwrap();
/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None);
/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap());
/// assert_eq!(out, Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct TradeImbalance {
window: usize,
history: VecDeque<(f64, f64)>,
buy_sum: f64,
sell_sum: f64,
}
impl TradeImbalance {
/// Construct a trade-imbalance indicator over a window of `window` trades.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window` is zero.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
history: VecDeque::with_capacity(window),
buy_sum: 0.0,
sell_sum: 0.0,
})
}
/// The configured window length, in trades.
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for TradeImbalance {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let (buy, sell) = if trade.side.sign() > 0.0 {
(trade.size, 0.0)
} else {
(0.0, trade.size)
};
self.history.push_back((buy, sell));
self.buy_sum += buy;
self.sell_sum += sell;
if self.history.len() > self.window {
let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
self.buy_sum -= old_buy;
self.sell_sum -= old_sell;
}
if self.history.len() < self.window {
return None;
}
let total = self.buy_sum + self.sell_sum;
if total <= 0.0 {
return Some(0.0);
}
Some((self.buy_sum - self.sell_sum) / total)
}
fn reset(&mut self) {
self.history.clear();
self.buy_sum = 0.0;
self.sell_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.history.len() >= self.window
}
fn name(&self) -> &'static str {
"TradeImbalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn rejects_zero_window() {
assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let ti = TradeImbalance::new(5).unwrap();
assert_eq!(ti.name(), "TradeImbalance");
assert_eq!(ti.warmup_period(), 5);
assert_eq!(ti.window(), 5);
assert!(!ti.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut ti = TradeImbalance::new(2).unwrap();
assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
assert!(!ti.is_ready());
// Window full: buyVol 3, sellVol 1 -> 0.5.
assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
assert!(ti.is_ready());
}
#[test]
fn rolls_off_old_trades() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
// Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
assert!((out - (4.0 / 6.0)).abs() < 1e-12);
}
#[test]
fn zero_volume_window_is_zero() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(0.0, Side::Buy, 0));
assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..30)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 5) as f64, side, i)
})
.collect();
let mut a = TradeImbalance::new(5).unwrap();
let mut b = TradeImbalance::new(5).unwrap();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1));
assert!(ti.is_ready());
ti.reset();
assert!(!ti.is_ready());
assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
}
}
@@ -22,6 +22,13 @@ use crate::traits::Indicator;
/// Pattern-shape check only — no trend filter is applied; combine with a trend
/// indicator for actionable signals.
///
/// # Signed ±1 encoding
///
/// This detector already emits the uniform candlestick sign convention shared
/// across the pattern family — `+1.0` bullish, `1.0` bearish, `0.0` no
/// pattern — so it drops straight into a machine-learning feature matrix where
/// the bullish and bearish variants of the pattern occupy a single dimension.
///
/// # Example
///
/// ```
+33 -27
View File
@@ -37,6 +37,7 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
mod error;
mod microstructure;
mod ohlcv;
mod traits;
@@ -52,38 +53,43 @@ pub use indicators::{
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput,
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo,
DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio,
GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput,
HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, InitialBalance,
InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma,
Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi,
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom,
MorningEveningStar, Natr, Nvi, Obv, OmegaRatio, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi,
QuotedSpread, RSquared, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi,
RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness,
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
+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
+92 -6
View File
@@ -34,12 +34,13 @@ use std::hint::black_box;
use wickra::{
Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci,
ClassicPivots, ConnorsRsi, Ema, EmpiricalModeDecomposition, Engulfing, Frama,
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma,
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Obv,
ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Sma, Stc, SuperTrend,
SuperTrendOutput, TdSequential, TdSequentialOutput, TtmSqueeze, TtmSqueezeOutput, ValueArea,
ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend,
YangZhangVolatility, T3,
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level,
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv,
OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar,
RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput,
TdSequential, TdSequentialOutput, Trade, TradeImbalance, TtmSqueeze, TtmSqueezeOutput,
ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
WaveTrend, YangZhangVolatility, T3,
};
use wickra_data::csv::CandleReader;
@@ -114,6 +115,50 @@ where
group.finish();
}
fn bench_orderbook_input<I, F, O>(c: &mut Criterion, name: &str, books: &[OrderBook], make: F)
where
F: Fn() -> I,
I: Indicator<Input = OrderBook, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(books.len());
let series = &books[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| {
b.iter(|| {
let mut ind = make();
for book in books {
black_box(ind.update(book.clone()));
}
});
});
}
group.finish();
}
fn bench_trade_input<I, F, O>(c: &mut Criterion, name: &str, trades: &[Trade], make: F)
where
F: Fn() -> I,
I: Indicator<Input = Trade, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(trades.len());
let series = &trades[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, trades| {
b.iter(|| {
let mut ind = make();
for t in trades {
black_box(ind.update(*t));
}
});
});
}
group.finish();
}
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
where
F: Fn() -> I,
@@ -265,6 +310,47 @@ fn benches(c: &mut Criterion) {
bench_scalar(c, "value_at_risk", &closes, || {
ValueAtRisk::new(50, 0.95).unwrap()
});
// === Family — Microstructure ===
// No order-book dataset ships with the repo, so synthesise a five-level
// book around each candle close. Benches the cheapest (top-of-book) and the
// most-expensive (full-depth sum) representatives of the family.
let books: Vec<OrderBook> = candles
.iter()
.map(|candle| {
let mid = candle.close;
let tick = (mid * 0.0001).max(0.01);
let bids = (0..5u32)
.map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i)))
.collect();
let asks = (0..5u32)
.map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i)))
.collect();
OrderBook::new_unchecked(bids, asks)
})
.collect();
bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new);
bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new);
bench_orderbook_input(c, "microprice", &books, Microprice::new);
// Synthesise a trade tape from candles: one trade per bar, sided by the
// candle's direction. SignedVolume is the cheapest; TradeImbalance carries
// a rolling window and is the most expensive.
let trades: Vec<Trade> = candles
.iter()
.map(|candle| {
let side = if candle.close >= candle.open {
Side::Buy
} else {
Side::Sell
};
Trade::new_unchecked(candle.close, candle.volume, side, candle.timestamp)
})
.collect();
bench_trade_input(c, "signed_volume", &trades, SignedVolume::new);
bench_trade_input(c, "trade_imbalance", &trades, || {
TradeImbalance::new(50).unwrap()
});
}
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
+18 -17
View File
@@ -1,30 +1,31 @@
# Documentation
Wickra's full documentation lives in the **[GitHub Wiki](https://github.com/wickra-lib/wickra/wiki)**.
Wickra's full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**.
That includes:
- **Quickstarts** for [Rust](https://github.com/wickra-lib/wickra/wiki/Quickstart-Rust.md),
[Python](https://github.com/wickra-lib/wickra/wiki/Quickstart-Python.md),
[Node](https://github.com/wickra-lib/wickra/wiki/Quickstart-Node.md), and
[WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md).
- **Quickstarts** for [Rust](https://docs.wickra.org/Quickstart-Rust),
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **214 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
Risk / Performance) — see the
[indicators overview](https://github.com/wickra-lib/wickra/wiki/Indicators-Overview.md).
- **Reference pages**: [warmup periods](https://github.com/wickra-lib/wickra/wiki/Warmup-Periods.md),
[streaming vs batch](https://github.com/wickra-lib/wickra/wiki/Streaming-vs-Batch.md),
[indicator chaining](https://github.com/wickra-lib/wickra/wiki/Indicator-Chaining.md), and the
[data layer](https://github.com/wickra-lib/wickra/wiki/Data-Layer.md).
- **Guides**: [Cookbook](https://github.com/wickra-lib/wickra/wiki/Cookbook.md),
[TA-Lib migration](https://github.com/wickra-lib/wickra/wiki/TA-Lib-Migration.md),
[FAQ](https://github.com/wickra-lib/wickra/wiki/FAQ.md).
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference pages**: [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), and the
[data layer](https://docs.wickra.org/Data-Layer).
- **Guides**: [Cookbook](https://docs.wickra.org/Cookbook),
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Editing the wiki
## Editing the docs
The wiki is a separate git repository at `https://github.com/wickra-lib/wickra.wiki.git`.
Clone it locally if you want to bulk-edit; otherwise the GitHub web UI's "Edit" button on any
wiki page is fine for one-off changes.
The documentation site is a separate git repository at
`https://github.com/wickra-lib/wickra-docs`. Open a pull request there to
propose changes; the site is built with VitePress and deploys to
`docs.wickra.org`.
+6
View File
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
## WebAssembly — `examples/wasm/`
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long &lt; 30, exit &gt; 70); prints a PnL / Sharpe / max-DD summary table. |
| `strategy_macd_adx.html` | Hourly BTCUSDT MACD crossover gated by ADX(14) &gt; 20, with the same summary table. |
| `strategy_bollinger_squeeze.html` | Daily BTCUSDT Bollinger-squeeze breakout with a 2&times;ATR(14) stop and summary table. |
## Example datasets
+64
View File
@@ -0,0 +1,64 @@
{
"name": "wickra-examples-node",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra-examples-node",
"version": "0.0.0",
"license": "SEE LICENSE IN ../../LICENSE",
"dependencies": {
"wickra": "file:../../bindings/node"
},
"devDependencies": {
"ws": "^8.18.0"
}
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.2",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
"engines": {
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.2",
"wickra-darwin-x64": "0.4.2",
"wickra-linux-arm64-gnu": "0.4.2",
"wickra-linux-x64-gnu": "0.4.2",
"wickra-win32-arm64-msvc": "0.4.2",
"wickra-win32-x64-msvc": "0.4.2"
}
},
"node_modules/wickra": {
"resolved": "../../bindings/node",
"link": true
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
// Strategy example: Bollinger-Squeeze breakout with ATR-based stop.
//
// Enters long when the Bollinger Bandwidth has just printed a fresh 6-month low
// (the squeeze) and price closes above the upper band (the release). Exits when
// price closes below entry minus 2 * ATR(14), or when the upper band rolls back
// under the entry price. 0.1% fees per trade.
//
// Educational example. NOT a live trading recommendation. The Node counterpart
// of `examples/python/strategy_bollinger_squeeze.py` and the Rust
// `examples/rust/src/bin/strategy_bollinger_squeeze.rs`, printing the same
// summary.
//
// Build the native binding once, then run it:
//
// cd bindings/node && npm install && npx napi build --platform --release
// cd ../../examples/node && npm install
// node strategy_bollinger_squeeze.js
//
// Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because daily bars
// give an interpretable 6-month-low lookback (~180 bars).
const fs = require('node:fs');
const path = require('node:path');
const wickra = require('wickra');
const FEE = 0.001;
const BB_PERIOD = 20;
const BB_K = 2.0;
const ATR_PERIOD = 14;
const ATR_STOP_MULT = 2.0;
const SQUEEZE_LOOKBACK = 180;
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
function loadCandles(csvPath) {
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
const candles = [];
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
const candle = {};
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
const raw = cells[idx[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
candle[col] = value;
}
candles.push(candle);
}
return candles;
}
function signed(value, digits) {
return (value >= 0 ? '+' : '') + value.toFixed(digits);
}
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
const buyHold = lastPrice / firstPrice;
const stratReturn = finalEquity - 1.0;
const bhReturn = buyHold - 1.0;
const wins = closedTrades.filter((r) => r > 0).length;
const losses = closedTrades.filter((r) => r < 0).length;
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
const n = closedTrades.length;
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
const varRet =
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
const stddev = Math.sqrt(varRet);
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
let peak = equityCurve.length ? equityCurve[0] : 1.0;
let maxDd = 0.0;
for (const eq of equityCurve) {
if (eq > peak) peak = eq;
const dd = (peak - eq) / peak;
if (dd > maxDd) maxDd = dd;
}
const label = (s) => s.padEnd(23);
console.log(`=== ${name} ===`);
console.log(`${label('Bars:')}${bars}`);
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
console.log(
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
);
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
console.log();
console.log(
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
'are simplified or omitted. Past performance is not indicative of future results.',
);
}
function main() {
const csvPath = process.argv[2] || DEFAULT_CSV;
let candles;
try {
candles = loadCandles(csvPath);
} catch (err) {
console.error(`error: ${err.message}`);
process.exit(1);
}
if (candles.length < SQUEEZE_LOOKBACK + BB_PERIOD) {
console.error(
`error: dataset has only ${candles.length} bars; need at least ` +
`${SQUEEZE_LOOKBACK + BB_PERIOD}`,
);
process.exit(1);
}
const bb = new wickra.BollingerBands(BB_PERIOD, BB_K);
const atr = new wickra.ATR(ATR_PERIOD);
// Rolling window of the last SQUEEZE_LOOKBACK bandwidths (Python deque(maxlen)).
const bwWindow = [];
let inPosition = false;
let entryPrice = 0.0;
let stopLevel = 0.0;
const closedTrades = [];
let equity = 1.0;
const equityCurve = [];
for (const c of candles) {
const bbOut = bb.update(c.close);
const atrVal = atr.update(c.high, c.low, c.close);
const price = c.close;
const mtm = inPosition ? equity * (price / entryPrice) : equity;
equityCurve.push(mtm);
if (bbOut == null || atrVal == null) continue;
const { upper, middle, lower } = bbOut;
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
if (Number.isNaN(bandwidth)) continue;
bwWindow.push(bandwidth);
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
if (inPosition) {
const stopHit = price < stopLevel;
const upperCollapse = upper < entryPrice;
if (stopHit || upperCollapse) {
const tradeRet = price / entryPrice - 1.0;
closedTrades.push(tradeRet);
equity *= (1.0 + tradeRet) * (1.0 - FEE);
inPosition = false;
}
} else {
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
const breakout = price > upper;
if (isNewLow && breakout) {
entryPrice = price;
stopLevel = price - ATR_STOP_MULT * atrVal;
equity *= 1.0 - FEE;
inPosition = true;
}
}
}
if (inPosition) {
const lastPrice = candles[candles.length - 1].close;
const tradeRet = lastPrice / entryPrice - 1.0;
closedTrades.push(tradeRet);
equity *= (1.0 + tradeRet) * (1.0 - FEE);
}
printSummary(
'Bollinger Squeeze Breakout (1d, BTCUSDT)',
candles[0].close,
candles[candles.length - 1].close,
candles.length,
closedTrades,
equity,
equityCurve,
);
}
main();

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