Compare commits

..

16 Commits

Author SHA1 Message Date
kingchenc d7cb771a28 release: bump 0.8.4 -> 0.8.5 (#258)
Version bump `0.8.4` → `0.8.5`.

### Fixed
- The R binding's golden-fixture parity test now skips gracefully when the shared `testdata/golden` fixtures are not bundled with the package — standalone r-universe / CRAN builds package only `bindings/r`, so the repo-root fixtures are unreachable there (this was failing the r-universe build of 0.8.4). The parity stays enforced by the repository CI, where the fixtures are present. (#257)

Bump touches the manual release touchpoints only (`Cargo.toml`/`Cargo.lock`, Python/Node/Java/C#/R manifests, lockfiles, `SECURITY.md`, `CHANGELOG.md`). docs/webpage version strings are left to `sync-about.yml` on the tag.
2026-06-11 17:13:52 +02:00
kingchenc 3e5a19c94a fix(r): skip golden-fixture test in standalone package builds + document parity (#257)
## Problem
The r-universe build of `wickra` 0.8.4 fails (`R CMD check` ERROR): the golden-fixture parity test added in #255 walks up from the working directory looking for `testdata/golden` and `stop()`s when it cannot find it. Standalone package builds (r-universe / CRAN) package only `bindings/r`, so the repo-root `testdata/golden` fixtures are unreachable there — whereas the monorepo CI checks out the full repo, so the walk-up succeeds and the test passes.

Run: https://github.com/r-universe/wickra-lib/actions/runs/27354800361

## Fix
- **`bindings/r/tests/testthat/test-golden.R`** — the fixture-directory lookup now returns `NULL` instead of erroring when the fixtures are absent, and each test starts with `skip_if(is.null(golden_dir), ...)`. The repository CI (full repo present) still runs the parity checks; standalone builds skip them. The per-test `golden_input` read moved inside the (post-skip) test bodies so nothing runs at source time when the fixtures are missing.
- **`CHANGELOG.md`** — `[Unreleased]` Fixed entry.

## Docs (B6)
- **`README.md` `## Testing`** — the four C-ABI bindings (C#, Go, Java, R) were described as covering one indicator per FFI archetype; document that they additionally replay the shared golden fixture and assert exact parity with the Rust reference outputs.
2026-06-11 17:08:33 +02:00
kingchenc 8bfe24ac1d test(golden): language-neutral fixtures + per-binding parity runners (#255)
**Task 5 — golden-fixture parity for the C-ABI bindings.** Lifts the C#/Go/Java/R tests from *one indicator per archetype* toward reference-value parity, catching FFI wiring bugs (swapped params, wrong multi-output field) the math-only core tests cannot see.

## What's here
- **`examples/rust/src/bin/gen_golden.rs`** + **`testdata/golden/*.csv`** — a Rust generator computing a deterministic OHLCV series plus the core's reference outputs for a curated archetype-spanning set: scalar (`Sma`/`Ema`/`Rsi`), candle (`Atr`), scalar multi-output (`MACD`), candle multi-output (`ADX`), pairwise (`Beta`). `nan` marks warmup. Regenerate with `cargo run -p wickra-examples --bin gen_golden`.
- **Parity runners** replaying the identical fixtures through each FFI (rel-tol 1e-6), each a standard test in the binding's existing suite (no `ci.yml` change — rides `dotnet test` / `go test` / `mvn install` / `R CMD`+testthat). A walk-up search locates `testdata/golden` regardless of run dir.
  - **C#** (`bindings/csharp/.../GoldenTests.cs`) —  validated locally, 7/7 pass.
  - **Go** (`bindings/go/golden_test.go`) —  validated locally, pass.
  - **Java** (`bindings/java/.../GoldenTests.java`) — modeled on the archetype API; validated by CI (no local mvn).
  - **R** (`bindings/r/tests/testthat/test-golden.R`) — modeled on the archetype API; validated by CI (no local Rscript).

## Notes
- The curated set spans every marshalling archetype; extending the indicator list is mechanical (add to the generator + regenerate). Bars/profile archetypes can be added next.
- No new CI jobs.
2026-06-11 15:22:29 +02:00
kingchenc 395a2289f4 release: bump 0.8.3 -> 0.8.4 (#256)
Version bump `0.8.3` → `0.8.4`.

Ships the work merged via #254:

### Fixed
- A single non-finite (NaN/inf) tick no longer poisons indicator state — 38 more scalar/pairwise indicators (linear-regression family, rolling quantiles/IQR, `Variance`/`StdDev`-derived stats, `Kurtosis`/`Skewness`, trailing stops, `KalmanHedgeRatio`, `SpreadBollingerBands`, …) now reject non-finite input and return `None`, joining the 16 pairwise indicators fixed earlier.

### Added
- Catalogue-wide property-based invariant harness (`crates/wickra-core/tests/invariants.rs`) asserting `batch == streaming`, `reset == fresh`, and non-finite-input rejection for every indicator and bar-builder.

### Changed
- CI: every job now has a runtime cap and the flaky Node test step auto-retries.
- Documentation accuracy fixes in `SECURITY.md`, `ARCHITECTURE.md`, and `THREAT_MODEL.md`.

Bump touches the manual release touchpoints only (`Cargo.toml`/`Cargo.lock`, Python/Node/Java/C#/R manifests, lockfiles, `SECURITY.md`, `CHANGELOG.md`). docs/webpage version strings are left to `sync-about.yml` on the tag.
2026-06-11 14:57:15 +02:00
kingchenc 9973d1a6bf test(core): catalogue-wide invariant harness + guard non-finite input in 38 indicators (#254)
Adds `crates/wickra-core/tests/invariants.rs` — a property-based (`proptest`) harness asserting three invariants for **every** indicator and bar-builder in the catalogue (every module entry implementing `Indicator` or `BarBuilder`; the lone non-indicator helper `pattern_swing`/`SwingTracker` is excluded by design) — and fixes the non-finite bugs the harness surfaced.

## Invariants
1. **batch == streaming** — `batch()` must replay `update()` exactly.
2. **reset == fresh** — after `reset()`, re-feeding the same data matches a fresh instance.
3. **non-finite rejected without poisoning** (`f64` / `(f64, f64)` families) — a NaN/inf tick returns `None` and leaves state identical to never having seen it.

## How it works
- A single generic `check_seq<I: Indicator>` covers **all** input families — `f64`, `Candle`, `(f64, f64)`, and the exotic `CrossSection`, `Trade`, `DerivativesTick`, `OrderBook`, `TradeQuote` — via per-family `proptest` generators that produce *valid* inputs (e.g. order books are strictly monotonic and uncrossed).
- `check_bars<B: BarBuilder>` covers the bar-builder trait.
- Outputs are compared by `Debug` string so bit-identical `NaN` outputs count as equal — these properties test **determinism**, not NaN-freeness.

## The 38 non-finite fixes
The harness surfaced **38 more** scalar/pairwise indicators that let a NaN/inf tick poison their state — the same class as the 16 pairwise indicators fixed earlier (#251), but missed by the grep-based audit (`kalman_hedge_ratio` and `spread_bollinger_bands` carried `is_finite` on their **constructor** params, not the update input). All 38 now reject non-finite input via the established first-statement guard; signatures unchanged, so every binding inherits the fix. With these in, the non-finite invariant is enforced for every `f64`/`(f64,f64)` indicator going forward — the permanent regression net that would have caught #251.

Warmup-exactness was evaluated and **left out** (not universal — many multi-component/candlestick indicators emit before `warmup_period` by design).

## Verification
- `cargo test -p wickra-core`: 4225 unit + harness + 464 doc/integration, all green.
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean.
- Coverage runs integration tests, so the new guard branches are exercised by the harness's NaN feed.

Also documents the harness in README's Testing section and adds the pending 0.8.4 entries to CHANGELOG `[Unreleased]`.
2026-06-11 14:51:46 +02:00
kingchenc cb216668ee docs: fix accuracy drift in SECURITY, ARCHITECTURE, THREAT_MODEL (#253)
Documentation-only accuracy fixes from the codebase audit (no code changes).

## SECURITY.md
- Supported-version policy was stale at `0.5.x` while `0.8.3` is published. Bump to the exact `0.8.3` (prose + table `0.8.3 (latest)` / `< 0.8.3`). The exact `x.y.z` form lets `bump_version.py` keep it current automatically (now wired as a touchpoint).

## ARCHITECTURE.md
- `three` → **four** binding crates (the C ABI crate was added).
- workspace diagram `214` → **514** indicators (matches the `mod`-count and `lib.rs` public-type count; now wired into the indicator-wiring automation so it self-heals).
- WASM "does not have automated tests yet" → corrected: `bindings/wasm/src/lib.rs` carries **21** `wasm-bindgen-test` cases.
- **Numerical-stability notes rewritten to match the code:** the sliding-window variance family (`StdDev`, `Variance`, `ZScore`, `Bollinger`) uses running `Σx²−mean²` with clamping (and periodic reseed for `Bollinger`), **not** Welford. True Welford is used only by `IntradayVolatilityProfile` and `SeasonalZScore` (it does not transfer cleanly to a sliding window). The **Kahan-summation** bullet is removed — no Kahan summation exists in the crate.

## THREAT_MODEL.md
- The C ABI is built with `panic = "abort"` and has no `catch_unwind`. Replace the false "catches panics so none cross the boundary" claim with the honest abort strategy (terminates deterministically instead of unwinding across the FFI boundary, which would be UB).
2026-06-11 03:28:19 +02:00
kingchenc 20c0002f8e ci: cap job runtimes and auto-retry the flaky Node test step (#252)
## Problem

A Node test job wedged on a macOS runner: the **Run Node tests** step (`node --test`) hung for **over an hour** while the identical tests passed in ~1.5 min on every other runner (Node 20 macOS same run: 1m46s; Node 18 macOS on a sibling PR: 1m34s). It is a one-off stuck process — the macOS analogue of the documented `setup-node` Windows CDN flake — not a real Node 18 vs 20 difference.

No job in `ci.yml` set any `timeout-minutes`, so a hung step runs toward GitHub's **6h default** before the platform kills it.

## Changes

- **Job-level `timeout-minutes: 20` backstop on all 15 jobs.** The slowest real job is ~5 min, so 20 min is generous headroom (survives cold-cache spikes) while turning a 6h hang into a 20-min fail. The clock counts execution time only — queued/waiting time does not count against it.
- **`Run Node tests` wrapped in `nick-fields/retry` (SHA-pinned, v4.0.0):** a hung attempt is killed after 6 min and retried once (`timeout_minutes: 6`, `max_attempts: 2`), so a one-off wedge self-heals without a manual re-run. Normal run is well under a minute; worst case 2×6 min stays under the 20-min job backstop.

## Notes

- New SHA-pinned third-party action (`nick-fields/retry@ad98453…` = v4.0.0) — satisfies the SHA-pin convention; `zizmor` runs on this PR.
- `ci.yml` validated as well-formed YAML; all 15 jobs confirmed to carry a job-level timeout.
2026-06-11 03:27:05 +02:00
kingchenc 99497eb062 fix(core): reject non-finite input in 16 pairwise indicators (#251)
## Problem

16 of the 24 pairwise indicators (`type Input = (f64, f64)`) accepted non-finite input unchecked. A single NaN/Inf tick produced a NaN reading, contradicting the streaming-robustness guarantee that *a single bad tick cannot silently poison state* (README, `ARCHITECTURE.md`).

The other 8 pairwise indicators (`Alpha`, `InformationRatio`, `KalmanHedgeRatio`, `PairSpreadZScore`, `PairwiseBeta`, `RelativeStrengthAb`, `SpreadBollingerBands`, `TreynorRatio`) already guard finite input and are untouched. Scalar (169 files) and candle (`Candle::new`) inputs were already protected.

## Severity split

- **7 running-sum indicators** \(permanent corruption — NaN entered `Σ` and stayed until `reset()`\): `Beta`, `BetaNeutralSpread`, `Cointegration`, `HasbrouckInformationShare`, `PearsonCorrelation`, `RollingCorrelation`, `RollingCovariance`.
- **9 buffer-recompute indicators** \(transient — NaN cleared once evicted, but still surfaced in the reading\): `DistanceSsd`, `GrangerCausality`, `KendallTau`, `LeadLagCrossCorrelation`, `OuHalfLife`, `SpearmanCorrelation`, `SpreadAr1Coefficient`, `SpreadHurst`, `VarianceRatio`.

## Fix

Add the established finite-input guard (the same pattern `Alpha` already uses) as the first step of `update()`, before any window or sum mutation. Signature unchanged → bindings re-export unchanged, no binding regen needed; core-only.

Each indicator gains a `non_finite_input_returns_none` test that exercises the guard (NaN and Inf, covering both `||` operands) and proves a clean warmup is unaffected by the rejected ticks.

Split into two commits by severity class.

## Verification

- `cargo fmt --all` clean
- `cargo test --workspace --all-features` — 4225 core tests pass (+16 new)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
2026-06-11 03:01:24 +02:00
kingchenc 7e51f31f02 sync-about: propagate published version into Java install snippets (#250)
## Problem

Java is the only target whose install snippet pins a version (the Maven coordinate). Every other language exposes its version through an api/*.md `Latest:` line or the docs published-versions table, both of which `sync-about.yml` already rewrites on each `v*` tag. The Java `<version>` tag and the `org.wickra:wickra:<v>` Gradle coordinate were never wired in, so they drifted:

- `webpage/index.md` install tab → stuck on `0.8.0`
- `webpage/api/java.md` → stuck on `0.7.9`
- `wickra-docs/Quickstart-Java.md` (Maven `<version>` + Gradle coord) → stuck on `0.7.9`

while the published version is already `0.8.3`.

## Fix

Extend the webpage and docs version-sync steps to rewrite the Maven `<version>` tag and the Gradle coordinate to the released `${version}`, and add `index.md` / `Quickstart-Java.md` to the respective commits. Java publishes on every release, so it tracks the same version as the rest — future releases self-heal these snippets.

Patterns dry-run-verified against the live files (all three resolve to `0.8.3`). The current drift is corrected directly in companion PRs on the webpage and wickra-docs repos.
2026-06-11 01:38:54 +02:00
kingchenc 3ee3fb67ec release: bump 0.8.2 -> 0.8.3 (#249)
Version bump 0.8.2 → 0.8.3, releasing the per-binding throughput benchmark work
(merged in #246).

Bumped via `ScriptHelpers/bump_version.py` across all manual touchpoints —
`Cargo.toml`, `pyproject.toml`, the Node `package.json` + 6 platform packages +
both lockfiles, the Java `pom.xml`s + README, the C# `.csproj`, the R
`DESCRIPTION` — plus `Cargo.lock` (via `cargo build`) and the `CHANGELOG.md`
`[0.8.3]` section and compare URLs.

CHANGELOG `[0.8.3]`:
- Per-binding throughput benchmarks for all 9 targets (BENCHMARKS.md §3).
- C ABI archetype test (`examples/c/archetypes.c`).

`cargo fmt`, `cargo test --workspace --all-features` (all green) and
`cargo clippy --workspace --all-targets --all-features -D warnings` pass. The
docs/webpage version strings are bumped by `sync-about.yml` on the `v*` tag —
not touched here.
2026-06-10 03:55:39 +02:00
kingchenc 3ebcb3f758 Per-binding throughput benchmarks + test-coverage gaps (#246)
Adds a `throughput` benchmark to every target and closes two small
test-coverage documentation/QA gaps. One PR, no merge of binding code beyond
the additive benchmarks and one C test.

## 1. Per-binding throughput benchmarks (all 9 targets)

Each benchmark feeds a deterministic synthetic OHLCV series through three
indicators chosen by **FFI call-signature archetype** (not algorithm — the same
Rust core runs underneath all bindings):

- `SMA(20)` — 1-in → 1-out (baseline boundary cost)
- `ATR(14)` — multi-in → 1-out (input marshalling)
- `MACD(12,26,9)` — 1-in → multi-out (output marshalling)

Streaming is timed for all three; batch for the single-output SMA and ATR
(median of 3 runs, after a warmup pass).

New: Python (PyO3), WASM, C (CMake), C# (Stopwatch), Go, Java (FFM), R, and the
Rust core baseline (`examples/rust/.../throughput.rs`, **no FFI** — the ceiling
the bindings are measured against and the value their batch paths converge
towards). Node already had `throughput.js`.

**Not a speed claim:** there is no comparable streaming TA library for C, C#,
Go, Java, R or WASM to compare against, so these are raw per-binding throughput
numbers documenting each language's FFI overhead — see BENCHMARKS.md §3. The
"Wickra is fast" claim still lives in §1/§2 (Rust core + the Python/Rust
cross-library runs).

## 2. README `## Testing`: C# and C bullets

The section listed every layer except C# and C, even though both have suites.
Adds the two missing bullets.

## 3. C archetype ctest

`examples/c/archetypes.c` drives one indicator per FFI archetype through the
real C boundary (scalar + batch==streaming, multi-output, bars, profile, array
input) plus reset, invalid-parameter and NULL-safety — the C counterpart of the
Go/R/Java archetype suites. Runs on three OSes via the existing CMake/ctest.

## Notes

- Benchmarks are not CI-gated (manual-run scripts, like the existing
  `throughput.js`); no `ci.yml`/`release.yml` changes.
- Docs: BENCHMARKS.md §3, a `## Benchmark` section in every binding README, a
  CHANGELOG entry.
- Verified locally by running: Rust, Python, C, C#, Go, Java (real numbers); the
  C archetype ctest with `-Wall -Wextra -Wpedantic -Werror`. WASM and R are
  API-correct and syntax-checked but need their own toolchains to run.
2026-06-10 03:46:38 +02:00
kingchenc b31a9a3624 release: bump 0.8.1 -> 0.8.2 (#245)
Patch release shipping the R WebAssembly build fix (#244): `bindings/r/configure` builds the C ABI from source for `wasm32-unknown-emscripten`, so r-universe's webR build stops failing. Also carries the shared Go badge in `bindings/go/README.md`. Version bumped across all manual touchpoints via `bump_version.py` (incl. DESCRIPTION + csproj, which had drifted before).
2026-06-10 01:57:10 +02:00
kingchenc 6433c9b3de bindings/r: build the C ABI from source for the WebAssembly target (#244)
## Problem
r-universe builds every package to WebAssembly (webR) in addition to the native platforms, calling `./configure --host=wasm32-unknown-emscripten`. `bindings/r/configure` only knew Linux/macOS/Windows and exited with `unsupported OS 'Emscripten'`, so the **WASM job was the single red check** on an otherwise fully-published r-universe build (all native platforms + deploy are green; the package installs fine everywhere).

## Fix
The r-universe wasm build image ships **cargo** (`/usr/local/cargo/bin`) and **emscripten** (`EMSDK` on PATH). So instead of needing a prebuilt wasm lib (which would risk an emscripten ABI/version mismatch), `configure` now detects the Emscripten host and **builds the C ABI staticlib from source** for `wasm32-unknown-emscripten` in-place — compiled with the image's own emscripten, so the ABI always matches. The static `libwickra.a` is linked into the package object (no shared lib, no rpath).

`wickra-core`'s rayon batch needs threads (absent on wasm), so the wasm build drops it via `--no-default-features`. `wickra-c` now takes `wickra-core` as a direct path dep with `default-features = false` plus a default `parallel` feature that re-enables it for native builds (a member-level `default-features = false` is ignored when inheriting a workspace dep — that was the trap). 

## Validated locally
- `cargo build -p wickra-c` (default) → rayon present, builds.
- `cargo build -p wickra-c --no-default-features` (the wasm feature path) → rayon **gone**, builds.
- `cargo build --workspace`, clippy, fmt all clean; `configure` passes `sh -n`.

## Cannot be validated locally
No Rust/emscripten wasm toolchain here. The wasm build only runs in r-universe, and `configure` downloads the matching `v${version}` source tag — so this takes effect from the **first release that includes it** (the tagged source must contain the feature toggle). Open risks: whether the image has the `wasm32-unknown-emscripten` Rust target pre-installed (configure runs `rustup target add` best-effort) and the wasm build time. Worth one r-universe rebuild to confirm.

Not merging — review first.
2026-06-10 01:44:48 +02:00
kingchenc 447bbc8220 bindings/csharp: sync the static csproj version to 0.8.1 (#243)
The static `<Version>` in `bindings/csharp/Wickra/Wickra.csproj` had drifted to 0.7.9 — it was missed in the 0.8.0/0.8.1 bumps (wrongly assumed to be purely tag-injected). The published NuGet package was correct (the release packs with `-p:Version=`), but the static field lagged. Resync to 0.8.1; bump_version.py now covers it.
2026-06-10 01:08:37 +02:00
kingchenc c9ef2fc037 bindings/r: bump DESCRIPTION version to 0.8.1 (#242)
The R package DESCRIPTION was missed in the 0.8.0/0.8.1 version bumps (it stayed at 0.7.9), so r-universe published wickra 0.7.9 while everything else is 0.8.1. Resync it; r-universe rebuilds from the main HEAD on its next ~hourly sync.
2026-06-10 00:47:13 +02:00
kingchenc 49c2872ad4 sync-about: sync the version for every registry row (#241)
The published-versions table sync only matched crates.io/PyPI/npm rows, so the NuGet, Maven Central, Go and r-universe rows added to the docs would stay stale on each release. Extend the regex to all seven registries and match the registry name whether plain or wrapped in a markdown link.
2026-06-10 00:26:49 +02:00
116 changed files with 4605 additions and 90 deletions
+25 -2
View File
@@ -34,6 +34,7 @@ jobs:
rust:
name: Rust ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -104,6 +105,7 @@ jobs:
examples-smoke:
name: Examples (syntax smoke)
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -193,6 +195,7 @@ jobs:
clippy-bindings:
name: Clippy bindings
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -276,6 +279,7 @@ jobs:
msrv:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -324,6 +328,7 @@ jobs:
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -365,6 +370,7 @@ jobs:
supply-chain:
name: Supply-chain (cargo-deny)
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -383,6 +389,7 @@ jobs:
fuzz-smoke:
name: Fuzz (smoke)
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -436,6 +443,7 @@ jobs:
python:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -517,6 +525,7 @@ jobs:
wasm:
name: WASM build
runs-on: ubuntu-latest
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -560,6 +569,7 @@ jobs:
node:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -618,13 +628,22 @@ jobs:
# exist yet for win32-x64-msvc).
run: npx napi build --platform --release
# The Node test process has wedged on macOS runners (the step hung for
# 1h+ while the same tests passed in ~1.5 min elsewhere). Wrap it so a
# hung attempt is killed after 6 min and retried once, rather than
# running up to the job-level backstop. A normal run is under a minute.
- name: Run Node tests
working-directory: bindings/node
run: node --test __tests__/
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 6
max_attempts: 2
command: cd bindings/node && node --test __tests__/
shell: bash
c-abi:
name: C ABI on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -680,6 +699,7 @@ jobs:
csharp:
name: C# on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -730,6 +750,7 @@ jobs:
go:
name: Go on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -852,6 +873,7 @@ jobs:
r:
name: R on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
@@ -923,6 +945,7 @@ jobs:
java:
name: Java on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20 # backstop: cap a wedged job instead of GitHub's 6h default (slowest real job ~5 min)
strategy:
fail-fast: false
matrix:
+24 -8
View File
@@ -332,20 +332,28 @@ jobs:
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
# Published-versions table rows (all registries): replace only the
# version number, leaving the trailing padding + pipe intact. The
# registry name is matched whether plain or wrapped in a markdown link
# (\[?...\]?). 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|NuGet|Maven Central|Go|r-universe)\]?.*\| )[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
# Quickstart-Java is the only quickstart whose install block pins a
# version (the Maven `<version>` tag and the `org.wickra:wickra:<v>`
# Gradle coordinate). Java publishes on every release, so it tracks the
# same ${version}.
sed -i -E "s|<version>[0-9]+\.[0-9]+\.[0-9]+</version>|<version>${version}</version>|" Quickstart-Java.md
sed -i -E "s|(org\.wickra:wickra:)[0-9]+\.[0-9]+\.[0-9]+|\1${version}|" Quickstart-Java.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 add overview.md Quickstart-Rust.md Quickstart-Java.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)."
@@ -431,6 +439,14 @@ jobs:
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
# Java is the only target whose install snippet pins a version: the
# Maven `<version>` tag in the api/java.md dependency block and the
# home-page install tab in index.md, plus any `org.wickra:wickra:<v>`
# Gradle coordinate. Java publishes on every release, so it tracks the
# same ${version} as the rest. (Other languages' api/*.md carry a
# "Latest:" line handled above; Java uses the XML snippet instead.)
sed -i -E "s|<version>[0-9]+\.[0-9]+\.[0-9]+</version>|<version>${version}</version>|" api/java.md index.md
sed -i -E "s|(org\.wickra:wickra:)[0-9]+\.[0-9]+\.[0-9]+|\1${version}|" api/java.md index.md
# 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 —
@@ -449,7 +465,7 @@ jobs:
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 add api/*.md index.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)."
+1
View File
@@ -55,6 +55,7 @@ bindings/node/npm-debug.log*
# WASM build output
bindings/wasm/pkg/
bindings/wasm/pkg-node/
# Python venv
.venv/
+16 -13
View File
@@ -7,7 +7,7 @@ for the day-to-day workflow.
## Workspace layout
Wickra is a Cargo workspace of three Rust crates plus three binding crates.
Wickra is a Cargo workspace of three Rust crates plus four binding crates.
The split is deliberate: every concern that one user might want to disable
or replace lives behind a separate crate boundary.
@@ -20,7 +20,7 @@ or replace lives behind a separate crate boundary.
┌───────────▼──────────┐ ┌──────────▼─────────┐
│ wickra-core │ │ wickra-data │
│ indicator engine │ │ i/o + aggregation │
│ • 214 indicators │ │ • CSV reader │
│ • 514 indicators │ │ • CSV reader │
│ • Indicator trait │ │ • Tick aggregator │
│ • BatchExt impl │ │ • Resampler │
│ • OHLCV / Candle │ │ • Live feeds │
@@ -203,14 +203,17 @@ typed object arrays for Node/WASM).
A handful of indicators need care beyond naive accumulation:
- **Welford's online variance** is used in `StdDev`, `Variance`, `ZScore`,
`BollingerBands`, and several others. Standard sum-of-squares is
catastrophically lossy for low-variance inputs; Welford's recurrence
keeps O(eps) error.
- **Kahan summation** is used wherever rolling sums could span > 1e6
elements without resetting — currently only Hurst-exponent's R/S
chunks. Most rolling sums are bounded by the window size and don't need
it.
- **Rolling variance is running-sum, not Welford.** The sliding-window
variance family — `StdDev`, `Variance`, `ZScore`, `Bollinger` — keeps
running `Σx` and `Σx²` over the window and reports `var = Σx²/n mean²`,
clamping to zero the tiny negative values floating-point cancellation can
produce. `Bollinger` periodically reseeds its `Σx²` from the live window
so error cannot accumulate over a long stream. Welford's online algorithm
(an incremental `M2` accumulator) does **not** transfer cleanly to a
sliding window — removing the oldest point from `M2` is numerically
unstable — so it is used only where the statistic is *not* a fixed
window: `IntradayVolatilityProfile` and `SeasonalZScore` accumulate
per-bucket variance that way.
- **Logarithm bases** matter for some indicators (Hurst, MFI). Wickra
uses natural log everywhere unless the reference math explicitly
requires `log10` or `log2` — and then it documents the choice in the
@@ -327,9 +330,9 @@ re-discovering them.
- **`FAMILIES` (from PR #60) is hand-maintained.** Adding a new
indicator requires a separate entry in `FAMILIES`. The
`total_count_matches_expected` test will fail if you forget.
- **WASM does not have automated tests yet.** Smoke-validated only
through the manual examples. Adding `wasm-bindgen-test` coverage is
on the roadmap.
- **WASM is covered by `wasm-bindgen-test`.** `bindings/wasm/src/lib.rs`
carries 21 in-crate tests (run under `wasm-pack test` in CI), in
addition to the manual browser examples.
For the high-level project goals see [`ROADMAP.md`](ROADMAP.md); for
day-to-day contribution mechanics see [`CONTRIBUTING.md`](CONTRIBUTING.md).
+72
View File
@@ -94,3 +94,75 @@ cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
pip install -e bindings/python[bench] # Python peers
python -m benchmarks.compare_libraries
```
## 3. Per-binding throughput — the cost of the boundary
The sections above compare Wickra against other libraries, which only exists for
Python and Rust (there is no comparable streaming TA library for C, C#, Go, Java,
R or WebAssembly to benchmark against). Every binding calls the **same** Rust
core, so these per-binding benchmarks are **not** a speed claim and **not** a
cross-library ratio — they document the raw cost of crossing each language's FFI
boundary, in million updates per second (Mupd/s).
Each binding ships a small `throughput` benchmark that feeds a synthetic OHLCV
series through three indicators chosen by call-signature archetype — `SMA(20)`
(1-in → 1-out), `ATR(14)` (multi-in → 1-out) and `MACD(12,26,9)` (1-in →
multi-out). Two things fall out of the numbers:
- **Batch converges.** A `batch` call crosses the boundary once and the Rust core
computes the whole series internally, so batch throughput is roughly the same
in every binding — close to the core speed.
- **Streaming reveals the boundary.** A per-tick `update` crosses the boundary
once per value, so streaming throughput is where the bindings differ: the raw C
ABI and P/Invoke-style calls are nearly free, while managed or interpreted
per-call marshalling (cgo, FFM, the R/WASM boundary) costs more per tick.
The Rust core ships the same benchmark with **no** FFI boundary
(`examples/rust/.../throughput.rs`) — it is the ceiling each binding is measured
against and the value the batch paths converge towards.
`SMA(20)`, 200 000 bars, median of 3 runs, on the reference machine (Windows 11,
AMD Ryzen 9 9950X):
| Target | streaming (Mupd/s) | batch (Mupd/s) |
|----------------------|-------------------:|---------------:|
| Rust core (no FFI) | 391 | 500 |
| C | 383 | 330 |
| C# / .NET | 337 | 244 |
| Python | 33 | 488 |
| Java | 28 | 175 |
| Go | 24 | 400 |
| WebAssembly | 19 | 167 |
| Node.js | 17 | 10 |
| R | 0.1 | 193 |
Streaming spans more than three orders of magnitude — the raw C ABI (383) is
nearly the FFI-free Rust ceiling (391), while R's per-call interpreter overhead
(0.1) makes streaming ~2000× slower than its own batch. Batch converges near the
core speed for the zero-copy bindings (numpy, slices, typed arrays); the two
outliers are Node — whose napi `batch` boxes every element into a JS `Array`
and R. These are machine-dependent and reflect FFI overhead, not algorithm
speed.
These are throughput numbers, not competitive numbers — the "Wickra is fast"
claim lives in sections 1 and 2 (Rust core + the Python/Rust cross-library runs).
Run any target's benchmark (build the C ABI library first where it links one):
```bash
cargo run -p wickra-examples --release --bin throughput # Rust core baseline (no FFI)
node bindings/node/benchmarks/throughput.js # native napi-rs
( cd bindings/python && python -m benchmarks.throughput ) # native PyO3
( cd bindings/wasm && wasm-pack build --target nodejs --out-dir pkg-node --release ) \
&& node bindings/wasm/benchmarks/throughput.mjs # wasm boundary
cargo build -p wickra-c --release # the C ABI hub
cmake -S bindings/c/benchmarks -B build/cbench && cmake --build build/cbench \
&& ./build/cbench/throughput # raw C ABI
dotnet run -c Release --project bindings/csharp/benchmarks # C# / .NET (P/Invoke)
( cd bindings/go/benchmarks && go run . ) # Go (cgo)
mvn -q -f bindings/java install -DskipTests \
&& mvn -q -f bindings/java/benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput
Rscript bindings/r/benchmarks/throughput.R # R (.Call)
```
+70 -1
View File
@@ -5,6 +5,71 @@ All notable changes to Wickra are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.8.5] - 2026-06-11
### Fixed
- The R binding's golden-fixture parity test now skips gracefully when the shared
`testdata/golden` fixtures are not bundled with the package — standalone
r-universe / CRAN builds package only `bindings/r`, so the repo-root fixtures
are unreachable there. The parity stays enforced by the repository CI, where
the fixtures are present.
## [0.8.4] - 2026-06-11
### Fixed
- A single non-finite (NaN/inf) tick no longer poisons indicator state.
The 16 pairwise running-sum/buffer indicators fixed first (`Beta`,
`BetaNeutralSpread`, `Cointegration`, `HasbrouckInformationShare`,
`PearsonCorrelation`, `RollingCorrelation`, `RollingCovariance`,
`DistanceSsd`, `GrangerCausality`, `KendallTau`, `LeadLagCrossCorrelation`,
`OuHalfLife`, `SpearmanCorrelation`, `SpreadAr1Coefficient`, `SpreadHurst`,
`VarianceRatio`) were joined by 38 more scalar/pairwise indicators the new
property harness surfaced (the linear-regression family, rolling quantiles
and IQR, `Variance`/`StdDev`-derived stats, `Kurtosis`/`Skewness`, the
trailing stops, `KalmanHedgeRatio`, `SpreadBollingerBands`, and more). Every
`f64` / `(f64, f64)` indicator now rejects non-finite input and returns
`None`, matching the streaming-robustness guarantee — and the harness enforces
it going forward.
### Added
- Catalogue-wide property-based invariant harness
(`crates/wickra-core/tests/invariants.rs`) asserting `batch == streaming`,
`reset == fresh`, and non-finite-input rejection for every indicator and
bar-builder.
### Changed
- CI: every job now has a runtime cap and the historically flaky Node test step
auto-retries, so a wedged runner fails fast instead of hanging for hours.
- Documentation accuracy fixes in `SECURITY.md`, `ARCHITECTURE.md`, and
`THREAT_MODEL.md` (supported version, indicator count, WASM test coverage,
numerical-stability notes, and the C-ABI panic strategy).
## [0.8.3] - 2026-06-10
### Added
- **Per-binding throughput benchmarks** — every target now ships a `throughput`
benchmark mirroring the Node `throughput.js`: streaming and batch
updates-per-second for `SMA(20)`, `ATR(14)` and `MACD(12,26,9)` over a
synthetic OHLCV series. New for Python (`bindings/python/benchmarks/`), C
(`bindings/c/benchmarks/`), C# (`bindings/csharp/benchmarks/`), Go
(`bindings/go/benchmarks/`), Java (`bindings/java/benchmarks/`), R
(`bindings/r/benchmarks/`), WebAssembly (`bindings/wasm/benchmarks/`) and the
Rust core baseline (`examples/rust/.../throughput.rs`, no FFI). They measure
each binding's FFI overhead — the same Rust core runs underneath all of them —
and are documented in [BENCHMARKS.md](BENCHMARKS.md) §3, not a cross-library
speed claim.
- **C ABI archetype test** — `examples/c/archetypes.c` exercises one indicator
per FFI archetype (scalar, multi-output, bars, profile, array input) through
the C boundary, matching the Go/R/Java suites.
## [0.8.2] - 2026-06-10
### Fixed
- **R binding builds for WebAssembly** — `bindings/r/configure` now builds the
C ABI from source for the `wasm32-unknown-emscripten` target (r-universe /
webR) using the build image's cargo + emscripten, instead of failing with
"unsupported OS Emscripten". rayon is dropped on wasm via
`--no-default-features`; the indicators are pure computation, so the serial
path is functionally identical.
## [0.8.1] - 2026-06-10
### Fixed
- **`wickra-go` license** — the release-time Go module mirror now ships the dual
@@ -1485,7 +1550,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.8.1...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.8.5...HEAD
[0.8.5]: https://github.com/wickra-lib/wickra/compare/v0.8.4...v0.8.5
[0.8.4]: https://github.com/wickra-lib/wickra/compare/v0.8.3...v0.8.4
[0.8.3]: https://github.com/wickra-lib/wickra/compare/v0.8.2...v0.8.3
[0.8.2]: https://github.com/wickra-lib/wickra/compare/v0.8.1...v0.8.2
[0.8.1]: https://github.com/wickra-lib/wickra/compare/v0.8.0...v0.8.1
[0.8.0]: https://github.com/wickra-lib/wickra/compare/v0.7.9...v0.8.0
[0.7.9]: https://github.com/wickra-lib/wickra/compare/v0.7.8...v0.7.9
Generated
+9 -9
View File
@@ -1944,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"approx",
"criterion",
@@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "wickra-bench"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"criterion",
"kand",
@@ -1967,14 +1967,14 @@ dependencies = [
[[package]]
name = "wickra-c"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"wickra-core",
]
[[package]]
name = "wickra-core"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"approx",
"proptest",
@@ -1984,7 +1984,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"approx",
"csv",
@@ -2001,7 +2001,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"serde_json",
"tokio",
@@ -2011,7 +2011,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"napi",
"napi-build",
@@ -2021,7 +2021,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"numpy",
"pyo3",
@@ -2030,7 +2030,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.8.1"
version = "0.8.5"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -14,7 +14,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.8.1"
version = "0.8.5"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -26,7 +26,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.8.1" }
wickra-core = { path = "crates/wickra-core", version = "0.8.5" }
thiserror = "2"
rayon = "1.10"
+14 -1
View File
@@ -334,7 +334,10 @@ Every layer is covered; run the suites with the commands in
- `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.
equivalence, `reset` semantics, NaN/Inf handling, and property tests. A
catalogue-wide property harness (`tests/invariants.rs`) additionally asserts
`batch == streaming`, `reset == fresh`, and non-finite-input rejection for
**every** indicator and bar-builder.
- `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
@@ -344,6 +347,10 @@ Every layer is covered; run the suites with the commands in
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
- `bindings/c`: Rust unit tests over the FFI boundary, plus C and C++ smoke
tests and offline example `ctest`s run on the three OSes.
- `bindings/csharp`: `dotnet test` cases covering one indicator per FFI archetype
(scalar/batch, multi-output, bars, profile, array input) plus SMA reference values.
- `bindings/go`: `go test` cases covering one indicator per FFI archetype
(scalar/batch, multi-output, bars, profile, array input), reset, and lifecycle.
- `bindings/r`: `testthat` cases covering one indicator per FFI archetype
@@ -351,6 +358,12 @@ Every layer is covered; run the suites with the commands in
- `bindings/java`: JUnit cases covering one indicator per FFI archetype
(scalar/batch, multi-output, bars, profile, array input) plus batch equivalence.
The four C-ABI bindings (C#, Go, Java, R) additionally replay a shared,
language-neutral golden fixture (`testdata/golden/*.csv`, generated by
`cargo run -p wickra-examples --bin gen_golden`) and assert exact parity with the
Rust reference outputs across every archetype (SMA, EMA, RSI, ATR, MACD, ADX,
Beta), catching FFI wiring bugs the math-only core tests cannot see.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
+3 -3
View File
@@ -2,13 +2,13 @@
## Supported versions
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
Wickra is pre-1.0. Security fixes are applied to the latest released `0.8.5`
version only; please upgrade to the newest release before reporting an issue.
| Version | Supported |
| --- | --- |
| 0.5.x (latest) | :white_check_mark: |
| older 0.5.x | :x: |
| 0.8.5 (latest) | :white_check_mark: |
| < 0.8.5 | :x: |
## Reporting a vulnerability
+1 -1
View File
@@ -33,7 +33,7 @@ small.
| Threat | Mitigation |
| --- | --- |
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
| Misuse of the C ABI FFI boundary (invalid/dangling handle, undersized batch buffer) | The C ABI (`bindings/c`) is the sole `unsafe` surface. Its shim adds no logic, NULL-checks every handle (returning `NaN`/no-op), writes only into caller-sized buffers, and catches panics so none cross the boundary. A caller passing a non-NULL but dangling pointer is undefined behaviour by C's own contract — out of scope, the same as any C library. |
| Misuse of the C ABI FFI boundary (invalid/dangling handle, undersized batch buffer) | The C ABI (`bindings/c`) is the sole `unsafe` surface. Its shim adds no logic, NULL-checks every handle (returning `NaN`/no-op), writes only into caller-sized buffers, and is built with `panic = "abort"` so a panic terminates the process deterministically instead of unwinding across the FFI boundary (which would be undefined behaviour). A caller passing a non-NULL but dangling pointer is undefined behaviour by C's own contract — out of scope, the same as any C library. |
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
+14 -1
View File
@@ -42,5 +42,18 @@ cast_sign_loss = "allow"
similar_names = "allow"
float_cmp = "allow"
[features]
# `parallel` (rayon-backed batch in wickra-core) is on by default for native
# builds. The wasm32-unknown-emscripten target has no threads, so the R
# package's wasm build (r-universe / webR) compiles this crate with
# --no-default-features to drop rayon; wickra-core falls back to its serial
# batch path, which is cfg-gated behind the same feature.
default = ["parallel"]
parallel = ["wickra-core/parallel"]
[dependencies]
wickra-core = { workspace = true }
# Direct path dep rather than `workspace = true`: a member-level
# `default-features = false` is ignored when inheriting a workspace dep that
# does not set it, which would leave rayon in the wasm build. wickra-c is
# `publish = false`, so no version pin is needed (and none to keep in sync).
wickra-core = { path = "../../crates/wickra-core", default-features = false }
+14
View File
@@ -54,6 +54,20 @@ Multi-output indicators (MACD, Bollinger, ADX, …) take a pointer to a `#[repr(
struct and return a `bool`. The optional `wickra.hpp` wraps any handle in a
move-only `wickra::Handle` for exception-safe C++ lifetimes.
## Benchmark
`benchmarks/throughput.c` reports streaming and batch updates-per-second for
`SMA`, `ATR` and `MACD`. As the thinnest binding it is the floor of the
per-binding FFI overhead — not a cross-library ratio (the same Rust core runs
under every binding); see the repository
[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
cargo build -p wickra-c --release
cmake -S benchmarks -B build && cmake --build build
./build/throughput
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+40
View File
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.15)
project(wickra_c_benchmarks C)
# Directory holding the compiled Wickra C library (cargo output), e.g.
# <workspace>/target/release. Override with -DWICKRA_LIB_DIR=/path/to/target/release.
if(NOT DEFINED WICKRA_LIB_DIR)
set(WICKRA_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../target/release")
endif()
set(WICKRA_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../include")
# Pick the right link target per platform/toolchain.
# - MSVC links the generated import library (wickra.dll.lib).
# - MinGW/gcc on Windows links the DLL directly.
# - Unix links the shared object / dylib.
if(WIN32)
set(WICKRA_RUNTIME "${WICKRA_LIB_DIR}/wickra.dll")
if(MSVC)
set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/wickra.dll.lib")
else()
set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/wickra.dll")
endif()
elseif(APPLE)
set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/libwickra.dylib")
else()
set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/libwickra.so")
endif()
add_executable(throughput throughput.c)
target_include_directories(throughput PRIVATE "${WICKRA_INCLUDE_DIR}")
target_link_libraries(throughput PRIVATE "${WICKRA_LINK_LIB}")
if(UNIX AND NOT APPLE)
target_link_libraries(throughput PRIVATE m)
endif()
# On Windows copy the DLL next to the executable so the loader finds it.
if(WIN32)
add_custom_command(TARGET throughput POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${WICKRA_RUNTIME}" "$<TARGET_FILE_DIR:throughput>")
endif()
+167
View File
@@ -0,0 +1,167 @@
/*
* Throughput benchmark for the Wickra C ABI.
*
* Measures how many indicator updates per second the C ABI sustains, both
* per-tick (streaming `_update`) and bulk (`_batch`), over a synthetic OHLCV
* series. It is the C counterpart of the Node throughput.js and the Rust
* criterion benches: it benchmarks Wickra's own O(1) streaming engine through
* the raw C boundary (there is no comparable streaming TA library to compare
* against), so the headline number is raw throughput, not a cross-library
* ratio. C is the thinnest binding, so these numbers are the floor of the
* per-binding FFI overhead the higher-level bindings build on.
*
* Three indicators are timed, chosen by call-signature archetype rather than
* algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD
* (1-in -> multi-out). Streaming is timed for all three; batch only for the
* single-output SMA and ATR (the C ABI has no MACD batch entry point).
*
* Build the C ABI library first, then build and run the benchmark:
*
* cargo build -p wickra-c --release
* cmake -S bindings/c/benchmarks -B build/cbench -DCMAKE_BUILD_TYPE=Release
* cmake --build build/cbench
* ./build/cbench/throughput # 200k bars (default)
* ./build/cbench/throughput 1000000
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <stdint.h>
#include "wickra.h"
#ifdef _WIN32
#include <windows.h>
static double now_ns(void) {
static LARGE_INTEGER freq;
static int init = 0;
LARGE_INTEGER counter;
if (!init) {
QueryPerformanceFrequency(&freq);
init = 1;
}
QueryPerformanceCounter(&counter);
return (double)counter.QuadPart * 1e9 / (double)freq.QuadPart;
}
#else
#include <time.h>
static double now_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}
#endif
static double median3(double a, double b, double c) {
if ((a <= b && b <= c) || (c <= b && b <= a)) return b;
if ((b <= a && a <= c) || (c <= a && a <= b)) return a;
return c;
}
/* Run `body` once as warmup, then time three repetitions and store the median
* elapsed nanoseconds in `dst`. `body` is a brace-enclosed statement block. */
#define MEASURE(dst, body) \
do { \
body; \
double s0, s1, s2, t0; \
t0 = now_ns(); body; s0 = now_ns() - t0; \
t0 = now_ns(); body; s1 = now_ns() - t0; \
t0 = now_ns(); body; s2 = now_ns() - t0; \
(dst) = median3(s0, s1, s2); \
} while (0)
int main(int argc, char **argv) {
size_t bars = 200000;
if (argc > 1) {
long n = strtol(argv[1], NULL, 10);
if (n >= 1000) {
bars = (size_t)n;
}
}
const size_t n = bars;
/* Deterministic synthetic OHLCV (no RNG, so runs are comparable). */
double *open = malloc(n * sizeof(double));
double *high = malloc(n * sizeof(double));
double *low = malloc(n * sizeof(double));
double *close = malloc(n * sizeof(double));
double *volume = malloc(n * sizeof(double));
int64_t *timestamp = malloc(n * sizeof(int64_t));
double *out = malloc(n * sizeof(double)); /* reused batch scratch buffer */
if (!open || !high || !low || !close || !volume || !timestamp || !out) {
fprintf(stderr, "allocation failed\n");
return 1;
}
for (size_t i = 0; i < n; i++) {
double mid = 100 + sin((double)i * 0.001) * 20 + (double)i * 1e-4;
double c = mid + sin((double)i * 0.05) * 2;
close[i] = c;
open[i] = mid;
high[i] = fmax(c, mid) + 1.5;
low[i] = fmin(c, mid) - 1.5;
volume[i] = 1000 + (double)(i % 97) * 13;
timestamp[i] = (int64_t)i;
}
double ns;
double sma_stream, sma_batch, atr_stream, atr_batch, macd_stream;
MEASURE(ns, {
struct Sma *ind = wickra_sma_new(20);
for (size_t i = 0; i < n; i++) wickra_sma_update(ind, close[i]);
wickra_sma_free(ind);
});
sma_stream = (double)n / (ns / 1e9) / 1e6;
MEASURE(ns, {
struct Sma *ind = wickra_sma_new(20);
wickra_sma_batch(ind, close, out, n);
wickra_sma_free(ind);
});
sma_batch = (double)n / (ns / 1e9) / 1e6;
MEASURE(ns, {
struct Atr *ind = wickra_atr_new(14);
for (size_t i = 0; i < n; i++)
wickra_atr_update(ind, open[i], high[i], low[i], close[i], volume[i], timestamp[i]);
wickra_atr_free(ind);
});
atr_stream = (double)n / (ns / 1e9) / 1e6;
MEASURE(ns, {
struct Atr *ind = wickra_atr_new(14);
wickra_atr_batch(ind, open, high, low, close, volume, timestamp, out, n);
wickra_atr_free(ind);
});
atr_batch = (double)n / (ns / 1e9) / 1e6;
MEASURE(ns, {
struct MacdIndicator *ind = wickra_macd_indicator_new(12, 26, 9);
struct WickraMacdOutput value;
for (size_t i = 0; i < n; i++) wickra_macd_indicator_update(ind, close[i], &value);
wickra_macd_indicator_free(ind);
});
macd_stream = (double)n / (ns / 1e9) / 1e6;
printf("Wickra C throughput - %zu bars (median of 3 runs)\n\n", n);
printf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)");
printf("------------------------------------------------------------\n");
printf("%-22s%20.1f%18.1f\n", "SMA(20)", sma_stream, sma_batch);
printf("%-22s%20.1f%18.1f\n", "ATR(14)", atr_stream, atr_batch);
printf("%-22s%20.1f%18s\n", "MACD(12,26,9)", macd_stream, "-");
printf("\nMupd/s = million indicator updates per second. Streaming is the per-tick\n"
"`_update` path (one C call per value); batch is the bulk array path (one\n"
"C call). Higher is better. Numbers are machine-dependent - use them for\n"
"relative comparison, not as a speed claim.\n");
free(open);
free(high);
free(low);
free(close);
free(volume);
free(timestamp);
free(out);
return 0;
}
+12
View File
@@ -52,6 +52,18 @@ foreach (var price in liveFeed)
values — the equivalence is enforced by the test suite. Multi-output indicators
(MACD, Bollinger, ADX, …) return a nullable `record struct`, `null` while warming up.
## Benchmark
`benchmarks/` reports streaming and batch updates-per-second for `SMA`, `ATR`
and `MACD`. It measures this binding's FFI overhead, not a cross-library ratio
(the same Rust core runs under every binding) — see the repository
[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
cargo build -p wickra-c --release
dotnet run -c Release --project benchmarks
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+162
View File
@@ -0,0 +1,162 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using Wickra;
using Xunit;
namespace Wickra.Tests;
/// <summary>
/// Golden-fixture parity: replay the shared <c>testdata/golden</c> input series
/// through the C# FFI and assert every value matches the Rust reference output.
/// Where the archetype tests only check finiteness, this pins exact values, so a
/// wiring bug (swapped parameter, wrong multi-output field) is caught.
/// Fixtures are generated by <c>cargo run -p wickra-examples --bin gen_golden</c>.
/// </summary>
public class GoldenTests
{
private const double Tol = 1e-6;
private static string GoldenDir([CallerFilePath] string file = "") =>
Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, "..", "..", "..", "testdata", "golden"));
private static List<string[]> ReadCsv(string name)
{
var path = Path.Combine(GoldenDir(), name + ".csv");
return File.ReadAllLines(path)
.Skip(1) // header
.Where(l => l.Length > 0)
.Select(l => l.Split(','))
.ToList();
}
private static double[][] Input()
{
return ReadCsv("input")
.Select(r => r.Select(c => double.Parse(c, CultureInfo.InvariantCulture)).ToArray())
.ToArray();
}
private static double Cell(string s) =>
s == "nan" ? double.NaN : double.Parse(s, CultureInfo.InvariantCulture);
private static void AssertClose(double got, double want, int row, string field)
{
if (double.IsNaN(want))
{
Assert.True(double.IsNaN(got), $"row {row} {field}: expected warmup/NaN, got {got}");
return;
}
var tol = Tol * Math.Max(1.0, Math.Abs(want));
Assert.True(Math.Abs(got - want) <= tol, $"row {row} {field}: got {got}, want {want}");
}
// --- scalar (close-driven) ------------------------------------------------
[Theory]
[InlineData("sma")]
[InlineData("ema")]
[InlineData("rsi")]
public void Scalar_MatchesGolden(string name)
{
var input = Input();
var expected = ReadCsv(name);
using var ind = (IDisposable)(name switch
{
"sma" => new Sma(14),
"ema" => new Ema(14),
"rsi" => new Rsi(14),
_ => throw new ArgumentOutOfRangeException(nameof(name)),
});
for (var i = 0; i < input.Length; i++)
{
var close = input[i][3];
double got = ind switch
{
Sma s => s.Update(close),
Ema e => e.Update(close),
Rsi r => r.Update(close),
_ => double.NaN,
};
AssertClose(got, Cell(expected[i][0]), i, name);
}
}
// --- candle, single output ------------------------------------------------
[Fact]
public void Candle_Atr_MatchesGolden()
{
var input = Input();
var expected = ReadCsv("atr");
using var atr = new Atr(14);
for (var i = 0; i < input.Length; i++)
{
var (o, h, l, c, v) = (input[i][0], input[i][1], input[i][2], input[i][3], input[i][4]);
AssertClose(atr.Update(o, h, l, c, v, i), Cell(expected[i][0]), i, "atr");
}
}
// --- pairwise -------------------------------------------------------------
[Fact]
public void Pairwise_Beta_MatchesGolden()
{
var input = Input();
var expected = ReadCsv("beta");
using var beta = new Beta(20);
for (var i = 0; i < input.Length; i++)
{
// generator fed (close, open)
AssertClose(beta.Update(input[i][3], input[i][0]), Cell(expected[i][0]), i, "beta");
}
}
// --- scalar multi-output: MACD -------------------------------------------
[Fact]
public void MultiOutput_Macd_MatchesGolden()
{
var input = Input();
var expected = ReadCsv("macd");
using var macd = new MacdIndicator(12, 26, 9);
for (var i = 0; i < input.Length; i++)
{
MacdOutput? got = macd.Update(input[i][3]);
var e = expected[i];
if (e[0] == "nan")
{
Assert.Null(got);
continue;
}
Assert.NotNull(got);
AssertClose(got!.Value.Macd, Cell(e[0]), i, "macd.macd");
AssertClose(got.Value.Signal, Cell(e[1]), i, "macd.signal");
AssertClose(got.Value.Histogram, Cell(e[2]), i, "macd.histogram");
}
}
// --- candle multi-output: ADX --------------------------------------------
[Fact]
public void MultiOutput_Adx_MatchesGolden()
{
var input = Input();
var expected = ReadCsv("adx");
using var adx = new Adx(14);
for (var i = 0; i < input.Length; i++)
{
var (o, h, l, c, v) = (input[i][0], input[i][1], input[i][2], input[i][3], input[i][4]);
AdxOutput? got = adx.Update(o, h, l, c, v, i);
var e = expected[i];
if (e[0] == "nan")
{
Assert.Null(got);
continue;
}
Assert.NotNull(got);
AssertClose(got!.Value.PlusDi, Cell(e[0]), i, "adx.plus_di");
AssertClose(got.Value.MinusDi, Cell(e[1]), i, "adx.minus_di");
AssertClose(got.Value.Adx, Cell(e[2]), i, "adx.adx");
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
<!-- NuGet package metadata -->
<PackageId>Wickra</PackageId>
<Version>0.7.9</Version>
<Version>0.8.5</Version>
<Authors>kingchenc</Authors>
<Description>High-performance streaming technical-analysis indicators (514 indicators) for .NET, backed by the native Rust core via the Wickra C ABI.</Description>
<PackageLicenseExpression>MIT OR Apache-2.0</PackageLicenseExpression>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Standalone throughput benchmark for the Wickra C# binding.
See Program.cs for run instructions. Requires the C ABI library; the
binding's dev DllImportResolver falls back to target/release. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<AssemblyName>Wickra.Benchmarks</AssemblyName>
<RootNamespace>Wickra.Benchmarks</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Wickra\Wickra.csproj" />
</ItemGroup>
</Project>
+101
View File
@@ -0,0 +1,101 @@
// Throughput benchmark for the Wickra C# binding.
//
// Measures how many indicator updates per second the binding sustains, both
// per-tick (streaming Update) and bulk (Batch), over a synthetic OHLCV series.
// It is the C# counterpart of the Node throughput.js and the Rust criterion
// benches: it benchmarks Wickra's own O(1) streaming engine across the
// managed<->C-ABI boundary (there is no comparable streaming TA library on
// NuGet to compare against), so the headline number is raw per-binding
// throughput / FFI overhead, not a cross-library ratio.
//
// Three indicators are timed, chosen by FFI call-signature archetype rather
// than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD
// (1-in -> multi-out). Streaming is timed for all three; batch only for the
// single-output SMA and ATR (multi-output batch is not exposed uniformly).
//
// cargo build -p wickra-c --release
// dotnet run -c Release --project bindings/csharp/benchmarks # 200k bars
// dotnet run -c Release --project bindings/csharp/benchmarks -- --bars 1000000
using System.Diagnostics;
using System.Globalization;
using Wickra;
// Deterministic, locale-independent number formatting for the report.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
int bars = 200_000;
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--bars" && int.TryParse(args[i + 1], out var n) && n >= 1000)
{
bars = n;
}
}
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
var open = new double[bars];
var high = new double[bars];
var low = new double[bars];
var close = new double[bars];
var volume = new double[bars];
var timestamp = new long[bars];
for (int i = 0; i < bars; i++)
{
double mid = 100 + Math.Sin(i * 0.001) * 20 + i * 1e-4;
double c = mid + Math.Sin(i * 0.05) * 2;
close[i] = c;
open[i] = mid;
high[i] = Math.Max(c, mid) + 1.5;
low[i] = Math.Min(c, mid) - 1.5;
volume[i] = 1000 + (i % 97) * 13;
timestamp[i] = i;
}
double Mups(double ns) => bars / (ns / 1e9) / 1e6;
// Median elapsed-ns over a few repetitions, after one warmup pass.
double TimeNs(Action fn, int reps = 3)
{
fn(); // warmup (JIT + cache)
var samples = new double[reps];
for (int r = 0; r < reps; r++)
{
long t0 = Stopwatch.GetTimestamp();
fn();
samples[r] = (Stopwatch.GetTimestamp() - t0) * (1e9 / Stopwatch.Frequency);
}
Array.Sort(samples);
return samples[reps / 2];
}
// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
var indicators = new (string Name, Action Stream, Action? Batch)[]
{
("SMA(20)",
() => { using var ind = new Sma(20); for (int i = 0; i < bars; i++) ind.Update(close[i]); },
() => { using var ind = new Sma(20); ind.Batch(close); }),
("ATR(14)",
() => { using var ind = new Atr(14); for (int i = 0; i < bars; i++) ind.Update(open[i], high[i], low[i], close[i], volume[i], timestamp[i]); },
() => { using var ind = new Atr(14); ind.Batch(open, high, low, close, volume, timestamp); }),
("MACD(12,26,9)",
() => { using var ind = new MacdIndicator(12, 26, 9); for (int i = 0; i < bars; i++) ind.Update(close[i]); },
null), // multi-output: streaming only
};
Console.WriteLine($"Wickra C# throughput - {bars:N0} bars (median of 3 runs)\n");
Console.WriteLine($"{"Indicator",-22}{"streaming (Mupd/s)",20}{"batch (Mupd/s)",18}");
Console.WriteLine(new string('-', 60));
foreach (var (name, stream, batch) in indicators)
{
string streamMups = Mups(TimeNs(stream)).ToString("F1");
string batchMups = batch is null ? "-" : Mups(TimeNs(batch)).ToString("F1");
Console.WriteLine($"{name,-22}{streamMups,20}{batchMups,18}");
}
Console.WriteLine(
"\nMupd/s = million indicator updates per second. Streaming is the per-tick\n" +
"Update path crossing the managed<->C-ABI boundary once per value; batch is\n" +
"the bulk array path (one boundary crossing). Higher is better. Numbers are\n" +
"machine-dependent - use them for relative comparison, not as a speed claim.");
+12 -1
View File
@@ -2,7 +2,7 @@
[![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)
[![Go Reference](https://pkg.go.dev/badge/github.com/wickra-lib/wickra/bindings/go.svg)](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
[![Go module](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/go.svg)](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators for Go, over the Wickra C ABI hub via cgo.**
@@ -88,6 +88,17 @@ values — the equivalence is enforced by the test suite. Multi-output indicator
Every indicator owns a native handle freed by `Close()`; a finalizer is wired as
a backstop, but call `Close()` (e.g. with `defer`) to release memory promptly.
## Benchmark
`benchmarks/throughput.go` reports streaming and batch updates-per-second for
`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a
cross-library ratio (the same Rust core runs under every binding) — see the
repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
cd benchmarks && go run .
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in the
+145
View File
@@ -0,0 +1,145 @@
// Throughput benchmark for the Wickra Go bindings.
//
// Measures how many indicator updates per second the cgo binding sustains,
// both per-tick (streaming Update) and bulk (Batch), over a synthetic OHLCV
// series. It is the Go counterpart of the Node throughput.js and the Rust
// criterion benches: it benchmarks Wickra's own O(1) streaming engine across
// the Go<->C-ABI boundary (there is no comparable streaming TA library to
// compare against), so the headline number is raw per-binding throughput /
// FFI overhead, not a cross-library ratio.
//
// Three indicators are timed, chosen by FFI call-signature archetype rather
// than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD
// (1-in -> multi-out). Streaming is timed for all three; batch only for the
// single-output SMA and ATR (multi-output batch is not exposed uniformly).
//
// Provision the C ABI library first (see bindings/go/README.md), then run:
//
// cd bindings/go/benchmarks
// go run . # 200k bars (default)
// go run . -bars 1000000
package main
import (
"flag"
"fmt"
"math"
"sort"
"time"
wickra "github.com/wickra-lib/wickra/bindings/go"
)
func main() {
bars := flag.Int("bars", 200_000, "number of synthetic bars to feed")
flag.Parse()
n := *bars
if n < 1000 {
fmt.Println("-bars must be >= 1000")
return
}
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
open := make([]float64, n)
high := make([]float64, n)
low := make([]float64, n)
closeP := make([]float64, n)
volume := make([]float64, n)
timestamp := make([]int64, n)
for i := 0; i < n; i++ {
mid := 100 + math.Sin(float64(i)*0.001)*20 + float64(i)*1e-4
c := mid + math.Sin(float64(i)*0.05)*2
closeP[i] = c
open[i] = mid
high[i] = math.Max(c, mid) + 1.5
low[i] = math.Min(c, mid) - 1.5
volume[i] = 1000 + float64(i%97)*13
timestamp[i] = int64(i)
}
mups := func(d time.Duration) float64 {
return float64(n) / d.Seconds() / 1e6
}
// Median elapsed over a few repetitions, after one warmup pass.
timeFn := func(fn func()) time.Duration {
fn() // warmup
const reps = 3
samples := make([]time.Duration, reps)
for r := 0; r < reps; r++ {
t0 := time.Now()
fn()
samples[r] = time.Since(t0)
}
sort.Slice(samples, func(a, b int) bool { return samples[a] < samples[b] })
return samples[reps/2]
}
type indicator struct {
name string
stream func()
batch func() // nil -> streaming only
}
indicators := []indicator{
{
name: "SMA(20)",
stream: func() {
ind, _ := wickra.NewSma(20)
for i := 0; i < n; i++ {
ind.Update(closeP[i])
}
ind.Close()
},
batch: func() {
ind, _ := wickra.NewSma(20)
ind.Batch(closeP)
ind.Close()
},
},
{
name: "ATR(14)",
stream: func() {
ind, _ := wickra.NewAtr(14)
for i := 0; i < n; i++ {
ind.Update(open[i], high[i], low[i], closeP[i], volume[i], timestamp[i])
}
ind.Close()
},
batch: func() {
ind, _ := wickra.NewAtr(14)
ind.Batch(open, high, low, closeP, volume, timestamp)
ind.Close()
},
},
{
name: "MACD(12,26,9)",
stream: func() {
ind, _ := wickra.NewMacdIndicator(12, 26, 9)
for i := 0; i < n; i++ {
ind.Update(closeP[i])
}
ind.Close()
},
batch: nil, // multi-output: streaming only
},
}
fmt.Printf("Wickra Go throughput - %d bars (median of 3 runs)\n\n", n)
fmt.Printf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)")
fmt.Println("------------------------------------------------------------")
for _, ind := range indicators {
streamMups := fmt.Sprintf("%.1f", mups(timeFn(ind.stream)))
batchMups := "-"
if ind.batch != nil {
batchMups = fmt.Sprintf("%.1f", mups(timeFn(ind.batch)))
}
fmt.Printf("%-22s%20s%18s\n", ind.name, streamMups, batchMups)
}
fmt.Print("\nMupd/s = million indicator updates per second. Streaming is the per-tick\n",
"Update path crossing the Go<->C-ABI boundary once per value; batch is the\n",
"bulk slice path (one boundary crossing). Higher is better. Numbers are\n",
"machine-dependent - use them for relative comparison, not as a speed claim.\n")
}
+191
View File
@@ -0,0 +1,191 @@
package wickra
import (
"bufio"
"math"
"os"
"strconv"
"strings"
"testing"
)
// Golden-fixture parity: replay the shared testdata/golden input series through
// the Go FFI and assert every value matches the Rust reference output. Where the
// archetype test only checks finiteness, this pins exact values, catching wiring
// bugs (swapped params, wrong multi-output field). Fixtures are generated by
// `cargo run -p wickra-examples --bin gen_golden`.
const goldenTol = 1e-6
func readGolden(t *testing.T, name string) [][]string {
t.Helper()
f, err := os.Open("../../testdata/golden/" + name + ".csv")
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
defer f.Close()
var rows [][]string
sc := bufio.NewScanner(f)
first := true
for sc.Scan() {
line := sc.Text()
if first {
first = false
continue
}
if line == "" {
continue
}
rows = append(rows, strings.Split(line, ","))
}
return rows
}
func goldenCell(s string) float64 {
if s == "nan" {
return math.NaN()
}
v, _ := strconv.ParseFloat(s, 64)
return v
}
func goldenInput(t *testing.T) [][]float64 {
rows := readGolden(t, "input")
out := make([][]float64, len(rows))
for i, r := range rows {
vals := make([]float64, len(r))
for j, c := range r {
vals[j] = goldenCell(c)
}
out[i] = vals
}
return out
}
func assertGoldenClose(t *testing.T, got, want float64, row int, field string) {
t.Helper()
if math.IsNaN(want) {
if !math.IsNaN(got) {
t.Errorf("row %d %s: expected warmup/NaN, got %v", row, field, got)
}
return
}
tol := goldenTol * math.Max(1.0, math.Abs(want))
if math.Abs(got-want) > tol {
t.Errorf("row %d %s: got %v want %v", row, field, got, want)
}
}
func TestGoldenScalar(t *testing.T) {
input := goldenInput(t)
sma, err := NewSma(14)
if err != nil {
t.Fatal(err)
}
defer sma.Close()
ema, err := NewEma(14)
if err != nil {
t.Fatal(err)
}
defer ema.Close()
rsi, err := NewRsi(14)
if err != nil {
t.Fatal(err)
}
defer rsi.Close()
cases := []struct {
name string
upd func(close float64) float64
}{
{"sma", sma.Update},
{"ema", ema.Update},
{"rsi", rsi.Update},
}
for _, tc := range cases {
exp := readGolden(t, tc.name)
for i := range input {
assertGoldenClose(t, tc.upd(input[i][3]), goldenCell(exp[i][0]), i, tc.name)
}
}
}
func TestGoldenAtr(t *testing.T) {
input := goldenInput(t)
exp := readGolden(t, "atr")
atr, err := NewAtr(14)
if err != nil {
t.Fatal(err)
}
defer atr.Close()
for i := range input {
got := atr.Update(input[i][0], input[i][1], input[i][2], input[i][3], input[i][4], int64(i))
assertGoldenClose(t, got, goldenCell(exp[i][0]), i, "atr")
}
}
func TestGoldenBeta(t *testing.T) {
input := goldenInput(t)
exp := readGolden(t, "beta")
beta, err := NewBeta(20)
if err != nil {
t.Fatal(err)
}
defer beta.Close()
for i := range input {
// generator fed (close, open)
assertGoldenClose(t, beta.Update(input[i][3], input[i][0]), goldenCell(exp[i][0]), i, "beta")
}
}
func TestGoldenMacd(t *testing.T) {
input := goldenInput(t)
exp := readGolden(t, "macd")
macd, err := NewMacdIndicator(12, 26, 9)
if err != nil {
t.Fatal(err)
}
defer macd.Close()
for i := range input {
out, ok := macd.Update(input[i][3])
if exp[i][0] == "nan" {
if ok {
t.Errorf("row %d macd: expected warmup, got %+v", i, out)
}
continue
}
if !ok {
t.Errorf("row %d macd: expected value, got warmup", i)
continue
}
assertGoldenClose(t, out.Macd, goldenCell(exp[i][0]), i, "macd.macd")
assertGoldenClose(t, out.Signal, goldenCell(exp[i][1]), i, "macd.signal")
assertGoldenClose(t, out.Histogram, goldenCell(exp[i][2]), i, "macd.histogram")
}
}
func TestGoldenAdx(t *testing.T) {
input := goldenInput(t)
exp := readGolden(t, "adx")
adx, err := NewAdx(14)
if err != nil {
t.Fatal(err)
}
defer adx.Close()
for i := range input {
out, ok := adx.Update(input[i][0], input[i][1], input[i][2], input[i][3], input[i][4], int64(i))
if exp[i][0] == "nan" {
if ok {
t.Errorf("row %d adx: expected warmup, got %+v", i, out)
}
continue
}
if !ok {
t.Errorf("row %d adx: expected value, got warmup", i)
continue
}
assertGoldenClose(t, out.PlusDi, goldenCell(exp[i][0]), i, "adx.plus_di")
assertGoldenClose(t, out.MinusDi, goldenCell(exp[i][1]), i, "adx.minus_di")
assertGoldenClose(t, out.Adx, goldenCell(exp[i][2]), i, "adx.adx")
}
}
+15 -2
View File
@@ -30,14 +30,14 @@ Maven:
<dependency>
<groupId>org.wickra</groupId>
<artifactId>wickra</artifactId>
<version>0.8.1</version>
<version>0.8.5</version>
</dependency>
```
Gradle:
```kotlin
implementation("org.wickra:wickra:0.8.1")
implementation("org.wickra:wickra:0.8.5")
```
The native library ships prebuilt per platform (Linux, macOS, Windows — x64 and
@@ -76,6 +76,19 @@ values — the equivalence is enforced by the test suite. Multi-output indicator
indicator owns a native handle freed by a `Cleaner`; `close()` releases it
eagerly (use try-with-resources).
## Benchmark
`benchmarks/` reports streaming and batch updates-per-second for `SMA`, `ATR`
and `MACD`. It measures this binding's FFI overhead, not a cross-library ratio
(the same Rust core runs under every binding) — see the repository
[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
cargo build -p wickra-c --release
mvn -q install -DskipTests
mvn -q -f benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.wickra.benchmarks</groupId>
<artifactId>wickra-benchmarks</artifactId>
<version>0.8.2</version>
<packaging>jar</packaging>
<name>Wickra Java benchmarks</name>
<description>Throughput benchmark for the Wickra Java binding.</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>22</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.wickra</groupId>
<artifactId>wickra</artifactId>
<version>0.8.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<!--
Run the benchmark in a forked JVM with native access granted:
mvn exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput
Requires the C ABI library and the installed binding; see Throughput.java.
-->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<executable>${java.home}/bin/java</executable>
<arguments>
<argument>--enable-native-access=ALL-UNNAMED</argument>
<argument>-classpath</argument>
<classpath/>
<argument>${exec.mainClass}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,149 @@
package org.wickra.benchmarks;
import java.util.Arrays;
import java.util.Locale;
import org.wickra.Atr;
import org.wickra.MacdIndicator;
import org.wickra.Sma;
/**
* Throughput benchmark for the Wickra Java binding.
*
* <p>Measures how many indicator updates per second the binding sustains, both
* per-tick (streaming {@code update}) and bulk ({@code batch}), over a synthetic
* OHLCV series. It is the Java counterpart of the Node {@code throughput.js} and
* the Rust criterion benches: it benchmarks Wickra's own O(1) streaming engine
* across the Java FFM &lt;-&gt; C-ABI boundary (there is no comparable streaming
* TA library on Maven Central to compare against), so the headline number is raw
* per-binding throughput / FFI overhead, not a cross-library ratio.
*
* <p>Three indicators are timed, chosen by FFI call-signature archetype rather
* than algorithm: SMA (1-in -&gt; 1-out), ATR (multi-in -&gt; 1-out), and MACD
* (1-in -&gt; multi-out). Streaming is timed for all three; batch only for the
* single-output SMA and ATR (multi-output batch is not exposed uniformly).
*
* <p>Install the binding and build the C ABI library first, then run from the
* repo root:
*
* <pre>
* cargo build -p wickra-c --release
* mvn -q -f bindings/java install -DskipTests
* mvn -q -f bindings/java/benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput
* </pre>
*/
public final class Throughput {
private Throughput() {}
public static void main(String[] args) {
int bars = 200_000;
for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals("--bars")) {
try {
int n = Integer.parseInt(args[i + 1]);
if (n >= 1000) {
bars = n;
}
} catch (NumberFormatException ignored) {
// keep default
}
}
}
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
double[] open = new double[bars];
double[] high = new double[bars];
double[] low = new double[bars];
double[] close = new double[bars];
double[] volume = new double[bars];
double[] timestamp = new double[bars];
for (int i = 0; i < bars; i++) {
double mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
double c = mid + Math.sin(i * 0.05) * 2;
close[i] = c;
open[i] = mid;
high[i] = Math.max(c, mid) + 1.5;
low[i] = Math.min(c, mid) - 1.5;
volume[i] = 1000 + (i % 97) * 13;
timestamp[i] = i;
}
final int n = bars;
// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
Indicator[] indicators = {
new Indicator("SMA(20)",
() -> {
try (Sma ind = new Sma(20)) {
for (int i = 0; i < n; i++) {
ind.update(close[i]);
}
}
},
() -> {
try (Sma ind = new Sma(20)) {
ind.batch(close);
}
}),
new Indicator("ATR(14)",
() -> {
try (Atr ind = new Atr(14)) {
for (int i = 0; i < n; i++) {
ind.update(open[i], high[i], low[i], close[i], volume[i], (long) timestamp[i]);
}
}
},
() -> {
try (Atr ind = new Atr(14)) {
ind.batch(open, high, low, close, volume, timestamp);
}
}),
new Indicator("MACD(12,26,9)",
() -> {
try (MacdIndicator ind = new MacdIndicator(12, 26, 9)) {
for (int i = 0; i < n; i++) {
ind.update(close[i]);
}
}
},
null), // multi-output: streaming only
};
System.out.printf(Locale.ROOT, "Wickra Java throughput - %,d bars (median of 3 runs)%n%n", bars);
System.out.printf(Locale.ROOT, "%-22s%20s%18s%n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)");
System.out.println("------------------------------------------------------------");
for (Indicator ind : indicators) {
String streamMups = String.format(Locale.ROOT, "%.1f", mups(bars, timeNs(ind.stream)));
String batchMups = ind.batch == null
? "-"
: String.format(Locale.ROOT, "%.1f", mups(bars, timeNs(ind.batch)));
System.out.printf(Locale.ROOT, "%-22s%20s%18s%n", ind.name, streamMups, batchMups);
}
System.out.println(
"\nMupd/s = million indicator updates per second. Streaming is the per-tick\n"
+ "update path crossing the Java FFM<->C-ABI boundary once per value; batch is\n"
+ "the bulk array path (one boundary crossing). Higher is better. Numbers are\n"
+ "machine-dependent - use them for relative comparison, not as a speed claim.");
}
private static double mups(int bars, double ns) {
return bars / (ns / 1e9) / 1e6;
}
// Median elapsed-ns over a few repetitions, after one warmup pass.
private static double timeNs(Runnable fn) {
fn.run(); // warmup (JIT + cache)
final int reps = 3;
double[] samples = new double[reps];
for (int r = 0; r < reps; r++) {
long t0 = System.nanoTime();
fn.run();
samples[r] = System.nanoTime() - t0;
}
Arrays.sort(samples);
return samples[reps / 2];
}
private record Indicator(String name, Runnable stream, Runnable batch) {}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>org.wickra</groupId>
<artifactId>wickra</artifactId>
<version>0.8.1</version>
<version>0.8.5</version>
<packaging>jar</packaging>
<name>Wickra</name>
@@ -0,0 +1,160 @@
package org.wickra;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Golden-fixture parity: replay the shared {@code testdata/golden} input series
* through the Java FFI and assert every value matches the Rust reference output.
* Where the archetype tests only check finiteness, this pins exact values, so a
* wiring bug (swapped parameter, wrong multi-output field) is caught. Fixtures
* are generated by {@code cargo run -p wickra-examples --bin gen_golden}.
*/
class GoldenTests {
private static final double TOL = 1e-6;
private static Path goldenDir() {
File d = new File("").getAbsoluteFile();
while (d != null) {
File g = new File(d, "testdata/golden");
if (g.isDirectory()) {
return g.toPath();
}
d = d.getParentFile();
}
throw new IllegalStateException("testdata/golden not found from " + new File("").getAbsolutePath());
}
private static List<String[]> readCsv(String name) throws Exception {
List<String> lines = Files.readAllLines(goldenDir().resolve(name + ".csv"));
List<String[]> rows = new ArrayList<>();
for (int i = 1; i < lines.size(); i++) { // skip header
if (!lines.get(i).isEmpty()) {
rows.add(lines.get(i).split(","));
}
}
return rows;
}
private static double cell(String s) {
return s.equals("nan") ? Double.NaN : Double.parseDouble(s);
}
private static double[][] input() throws Exception {
List<String[]> rows = readCsv("input");
double[][] out = new double[rows.size()][];
for (int i = 0; i < rows.size(); i++) {
String[] r = rows.get(i);
double[] v = new double[r.length];
for (int j = 0; j < r.length; j++) {
v[j] = Double.parseDouble(r[j]);
}
out[i] = v;
}
return out;
}
private static void close(double got, double want, int row, String field) {
if (Double.isNaN(want)) {
assertTrue(Double.isNaN(got), "row " + row + " " + field + ": expected warmup/NaN, got " + got);
return;
}
double tol = TOL * Math.max(1.0, Math.abs(want));
assertTrue(Math.abs(got - want) <= tol, "row " + row + " " + field + ": got " + got + " want " + want);
}
@Test
void scalarMatchesGolden() throws Exception {
double[][] in = input();
try (Sma sma = new Sma(14)) {
List<String[]> e = readCsv("sma");
for (int i = 0; i < in.length; i++) {
close(sma.update(in[i][3]), cell(e.get(i)[0]), i, "sma");
}
}
try (Ema ema = new Ema(14)) {
List<String[]> e = readCsv("ema");
for (int i = 0; i < in.length; i++) {
close(ema.update(in[i][3]), cell(e.get(i)[0]), i, "ema");
}
}
try (Rsi rsi = new Rsi(14)) {
List<String[]> e = readCsv("rsi");
for (int i = 0; i < in.length; i++) {
close(rsi.update(in[i][3]), cell(e.get(i)[0]), i, "rsi");
}
}
}
@Test
void candleAtrMatchesGolden() throws Exception {
double[][] in = input();
List<String[]> e = readCsv("atr");
try (Atr atr = new Atr(14)) {
for (int i = 0; i < in.length; i++) {
close(atr.update(in[i][0], in[i][1], in[i][2], in[i][3], in[i][4], i), cell(e.get(i)[0]), i, "atr");
}
}
}
@Test
void pairwiseBetaMatchesGolden() throws Exception {
double[][] in = input();
List<String[]> e = readCsv("beta");
try (Beta beta = new Beta(20)) {
for (int i = 0; i < in.length; i++) {
// generator fed (close, open)
close(beta.update(in[i][3], in[i][0]), cell(e.get(i)[0]), i, "beta");
}
}
}
@Test
void macdMatchesGolden() throws Exception {
double[][] in = input();
List<String[]> e = readCsv("macd");
try (MacdIndicator macd = new MacdIndicator(12, 26, 9)) {
for (int i = 0; i < in.length; i++) {
MacdOutput o = macd.update(in[i][3]);
String[] row = e.get(i);
if (row[0].equals("nan")) {
assertNull(o, "row " + i + " macd: expected warmup");
continue;
}
assertNotNull(o, "row " + i + " macd: expected value");
close(o.macd(), cell(row[0]), i, "macd.macd");
close(o.signal(), cell(row[1]), i, "macd.signal");
close(o.histogram(), cell(row[2]), i, "macd.histogram");
}
}
}
@Test
void adxMatchesGolden() throws Exception {
double[][] in = input();
List<String[]> e = readCsv("adx");
try (Adx adx = new Adx(14)) {
for (int i = 0; i < in.length; i++) {
AdxOutput o = adx.update(in[i][0], in[i][1], in[i][2], in[i][3], in[i][4], i);
String[] row = e.get(i);
if (row[0].equals("nan")) {
assertNull(o, "row " + i + " adx: expected warmup");
continue;
}
assertNotNull(o, "row " + i + " adx: expected value");
close(o.plusDi(), cell(row[0]), i, "adx.plus_di");
close(o.minusDi(), cell(row[1]), i, "adx.minus_di");
close(o.adx(), cell(row[2]), i, "adx.adx");
}
}
}
}
+12
View File
@@ -47,6 +47,18 @@ for (const price of liveFeed) {
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
## Benchmark
`benchmarks/throughput.js` reports streaming and batch updates-per-second for
`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a
cross-library ratio (the same Rust core runs under every binding) — see the
repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
npx napi build --platform --release
node benchmarks/throughput.js
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.8.1",
"version": "0.8.5",
"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": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.8.1",
"version": "0.8.5",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.8.1",
"version": "0.8.5",
"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": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.8.1",
"version": "0.8.5",
"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": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.8.1",
"version": "0.8.5",
"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": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.8.1",
"version": "0.8.5",
"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": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.8.1",
"version": "0.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.8.1",
"version": "0.8.5",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.8.1",
"wickra-darwin-x64": "0.8.1",
"wickra-linux-arm64-gnu": "0.8.1",
"wickra-linux-x64-gnu": "0.8.1",
"wickra-win32-arm64-msvc": "0.8.1",
"wickra-win32-x64-msvc": "0.8.1"
"wickra-darwin-arm64": "0.8.5",
"wickra-darwin-x64": "0.8.5",
"wickra-linux-arm64-gnu": "0.8.5",
"wickra-linux-x64-gnu": "0.8.5",
"wickra-win32-arm64-msvc": "0.8.5",
"wickra-win32-x64-msvc": "0.8.5"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.8.5.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.8.5.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.8.5.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.8.5.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.8.5.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.8.1.tgz",
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.8.5.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.8.1",
"version": "0.8.5",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.8.1",
"wickra-linux-arm64-gnu": "0.8.1",
"wickra-darwin-x64": "0.8.1",
"wickra-darwin-arm64": "0.8.1",
"wickra-win32-x64-msvc": "0.8.1",
"wickra-win32-arm64-msvc": "0.8.1"
"wickra-linux-x64-gnu": "0.8.5",
"wickra-linux-arm64-gnu": "0.8.5",
"wickra-darwin-x64": "0.8.5",
"wickra-darwin-arm64": "0.8.5",
"wickra-win32-x64-msvc": "0.8.5",
"wickra-win32-arm64-msvc": "0.8.5"
},
"scripts": {
"build": "napi build --platform --release",
+18
View File
@@ -46,6 +46,24 @@ for price in live_feed:
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
## Benchmark
Two benchmarks ship with the binding:
- `benchmarks/throughput.py` — streaming and batch updates-per-second for `SMA`,
`ATR` and `MACD`. This is per-binding FFI overhead (the same Rust core runs
under every binding), not a cross-library ratio.
- `benchmarks/compare_libraries.py` — the cross-library comparison against
TA-Lib, pandas-ta, tulipy and finta that backs the headline speedups.
```bash
maturin develop --release
python -m benchmarks.throughput
python -m benchmarks.compare_libraries # cross-library; auto-detects installed peers
```
See the repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md).
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+116
View File
@@ -0,0 +1,116 @@
"""Throughput benchmark for the Wickra Python binding.
Measures how many indicator updates per second the binding sustains, both
per-tick (streaming ``update``) and bulk (``batch``), over a synthetic OHLCV
series. It is the Python counterpart of the Node ``throughput.js`` and the Rust
criterion benches: it benchmarks Wickra's own O(1) streaming engine across the
Python<->Rust boundary, so the headline number is raw per-binding throughput /
FFI overhead, not a cross-library ratio.
For the cross-library comparison against TA-Lib, pandas-ta, tulipy and finta,
see ``benchmarks/compare_libraries.py`` instead.
Three indicators are timed, chosen by call-signature archetype rather than
algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out) and MACD (1-in ->
multi-out). Streaming is timed for all three; batch only for the single-output
SMA and ATR (multi-output batch returns a 2-D array and is not compared here).
Install the binding first (``maturin develop --release`` in bindings/python),
then run from bindings/python::
python -m benchmarks.throughput # 200k bars (default)
python -m benchmarks.throughput --bars 1000000
"""
from __future__ import annotations
import argparse
import time
import numpy as np
import wickra as ta
def _time_ns(fn, reps: int = 3) -> float:
"""Median elapsed-ns over a few repetitions, after one warmup pass."""
fn() # warmup
samples = []
for _ in range(reps):
t0 = time.perf_counter_ns()
fn()
samples.append(time.perf_counter_ns() - t0)
samples.sort()
return samples[len(samples) // 2]
def main() -> None:
parser = argparse.ArgumentParser(description="Wickra Python throughput benchmark")
parser.add_argument("--bars", type=int, default=200_000, help="number of synthetic bars")
args = parser.parse_args()
bars = args.bars if args.bars >= 1000 else 200_000
# Deterministic synthetic OHLCV (no RNG, so runs are comparable).
idx = np.arange(bars, dtype=np.float64)
mid = 100 + np.sin(idx * 0.001) * 20 + idx * 1e-4
close = mid + np.sin(idx * 0.05) * 2
high = np.maximum(close, mid) + 1.5
low = np.minimum(close, mid) - 1.5
open_ = mid
volume = 1000 + (idx % 97) * 13
close_list = close.tolist()
# ATR streams a 6-tuple (open, high, low, close, volume, timestamp) per tick.
candles = list(
zip(open_.tolist(), high.tolist(), low.tolist(), close_list, volume.tolist(), range(bars))
)
def mups(ns: float) -> float:
return bars / (ns / 1e9) / 1e6
def sma_stream() -> None:
ind = ta.SMA(20)
for value in close_list:
ind.update(value)
def sma_batch() -> None:
ta.SMA(20).batch(close)
def atr_stream() -> None:
ind = ta.ATR(14)
for candle in candles:
ind.update(candle)
def atr_batch() -> None:
ta.ATR(14).batch(high, low, close)
def macd_stream() -> None:
ind = ta.MACD(12, 26, 9)
for value in close_list:
ind.update(value)
# SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
indicators = [
("SMA(20)", sma_stream, sma_batch),
("ATR(14)", atr_stream, atr_batch),
("MACD(12,26,9)", macd_stream, None), # multi-output: streaming only
]
print(f"Wickra Python throughput - {bars:,} bars (median of 3 runs)\n")
print(f"{'Indicator':<22}{'streaming (Mupd/s)':>20}{'batch (Mupd/s)':>18}")
print("-" * 60)
for name, stream, batch in indicators:
stream_mups = f"{mups(_time_ns(stream)):.1f}"
batch_mups = "-" if batch is None else f"{mups(_time_ns(batch)):.1f}"
print(f"{name:<22}{stream_mups:>20}{batch_mups:>18}")
print(
"\nMupd/s = million indicator updates per second. Streaming is the per-tick\n"
"`update` path crossing the Python<->Rust boundary once per value; batch is\n"
"the bulk numpy path (one boundary crossing). Higher is better. Numbers are\n"
"machine-dependent - use them for relative comparison, not as a speed claim."
)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.8.1"
version = "0.8.5"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -1,7 +1,7 @@
Package: wickra
Type: Package
Title: Streaming-First Technical Indicators
Version: 0.7.9
Version: 0.8.5
Authors@R: person("Wickra contributors", role = c("aut", "cre"), email = "support@wickra.org")
Description: R bindings for the Wickra technical-analysis library over its C ABI
hub. Exposes 514 indicators, each an O(1) streaming state machine shared with
+11
View File
@@ -59,6 +59,17 @@ indicators take the OHLCV fields plus a timestamp, e.g.
`update(atr, open, high, low, close, volume, timestamp)`. The native handle is
freed automatically when the object is garbage-collected.
## Benchmark
`benchmarks/throughput.R` reports streaming and batch updates-per-second for
`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a
cross-library ratio (the same Rust core runs under every binding) — see the
repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
Rscript benchmarks/throughput.R
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in the
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env Rscript
#
# Throughput benchmark for the Wickra R bindings.
#
# Measures how many indicator updates per second the R binding sustains, both
# per-tick (streaming `update`) and bulk (`batch`), over a synthetic OHLCV
# series. It is the R counterpart of the Node `throughput.js` and the Rust
# criterion benches: it benchmarks Wickra's own O(1) streaming engine across
# the R<->C-ABI boundary (there is no comparable streaming TA library on CRAN
# to compare against), so the headline number is raw per-binding throughput /
# FFI overhead, not a cross-library ratio.
#
# Three indicators are timed, chosen by FFI call-signature archetype rather
# than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD
# (1-in -> multi-out). Streaming is timed for all three; batch only for the
# single-output SMA and ATR (multi-output batch is not exposed uniformly).
#
# Install the package first (it links the C ABI; see bindings/r/README.md),
# then run:
#
# Rscript bindings/r/benchmarks/throughput.R # 200k bars (default)
# Rscript bindings/r/benchmarks/throughput.R --bars 1000000
suppressMessages(library(wickra))
parse_bars <- function() {
args <- commandArgs(trailingOnly = TRUE)
i <- match("--bars", args)
if (!is.na(i) && length(args) >= i + 1L) {
n <- suppressWarnings(as.integer(args[i + 1L]))
if (!is.na(n) && n >= 1000L) {
return(n)
}
stop("--bars must be an integer >= 1000")
}
200000L
}
bars <- parse_bars()
# Deterministic synthetic OHLCV (no RNG, so runs are comparable).
idx <- seq.int(0L, bars - 1L)
mid <- 100 + sin(idx * 0.001) * 20 + idx * 1e-4
close <- mid + sin(idx * 0.05) * 2
high <- pmax(close, mid) + 1.5
low <- pmin(close, mid) - 1.5
open <- mid
volume <- 1000 + (idx %% 97L) * 13
# `numeric` (double), not integer: the candle batch path coerces the timestamp
# column with REAL(), which rejects an integer vector.
timestamp <- as.numeric(idx)
# Median elapsed-ns over a few repetitions, after one warmup pass.
time_ns <- function(fn, reps = 3L) {
fn() # warmup
samples <- numeric(reps)
for (r in seq_len(reps)) {
t0 <- Sys.time()
fn()
samples[r] <- as.numeric(Sys.time() - t0, units = "secs") * 1e9
}
median(samples)
}
mups_from_ns <- function(ns) bars / (ns / 1e9) / 1e6
# SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
indicators <- list(
list(
name = "SMA(20)",
stream = function() {
ind <- Sma(20)
for (i in seq_len(bars)) update(ind, close[i])
},
batch = function() {
batch(Sma(20), close)
}
),
list(
name = "ATR(14)",
stream = function() {
ind <- Atr(14)
for (i in seq_len(bars)) {
update(ind, open[i], high[i], low[i], close[i], volume[i], timestamp[i])
}
},
batch = function() {
batch(Atr(14), open, high, low, close, volume, timestamp)
}
),
list(
name = "MACD(12,26,9)",
stream = function() {
ind <- MacdIndicator(12, 26, 9)
for (i in seq_len(bars)) update(ind, close[i])
},
batch = NULL # multi-output: streaming only
)
)
cat(sprintf(
"Wickra R throughput - %s bars (median of 3 runs)\n\n",
format(bars, big.mark = ",")
))
cat(sprintf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)"))
cat(strrep("-", 60), "\n", sep = "")
for (ind in indicators) {
stream_mups <- sprintf("%.1f", mups_from_ns(time_ns(ind$stream)))
batch_mups <- if (is.null(ind$batch)) "-" else sprintf("%.1f", mups_from_ns(time_ns(ind$batch)))
cat(sprintf("%-22s%20s%18s\n", ind$name, stream_mups, batch_mups))
}
cat(paste0(
"\nMupd/s = million indicator updates per second. Streaming is the per-tick\n",
"`update` path crossing the R<->C-ABI boundary once per value; batch is the\n",
"bulk vector path (one boundary crossing). Higher is better. Numbers are\n",
"machine-dependent - use them for relative comparison, not as a speed claim.\n"
))
+30
View File
@@ -13,6 +13,36 @@ set -e
inc=""
lib=""
# WebAssembly (r-universe / webR): there is no prebuilt wasm C ABI to download,
# but the build image ships cargo (/usr/local/cargo/bin) and emscripten
# (EMSDK on PATH), so build the C ABI staticlib from source for
# wasm32-unknown-emscripten right here. Building it in the same image with the
# same emscripten avoids any ABI/version mismatch a prebuilt lib would risk.
# rayon (threads) is dropped via --no-default-features; the indicators are pure
# computation, so the serial path is functionally identical.
if [ "$(uname -s)" = "Emscripten" ]; then
version=$(sed -n 's/^Version:[[:space:]]*//p' DESCRIPTION)
echo "wickra: building C ABI from source for wasm32-unknown-emscripten (v${version})"
build=$(mktemp -d)
url="https://github.com/wickra-lib/wickra/archive/refs/tags/v${version}.tar.gz"
"${R_HOME}/bin/Rscript" -e "download.file('${url}', '${build}/src.tar.gz', mode = 'wb', quiet = TRUE)" \
|| { echo "wickra: failed to download source ${url}"; exit 1; }
"${R_HOME}/bin/Rscript" -e "untar('${build}/src.tar.gz', exdir = '${build}')"
root="${build}/wickra-${version}"
rustup target add wasm32-unknown-emscripten 2>/dev/null || true
( cd "${root}" && cargo build -p wickra-c --release \
--target wasm32-unknown-emscripten --no-default-features ) \
|| { echo "wickra: cargo wasm build failed"; exit 1; }
cp "${root}/bindings/c/include/wickra.h" src/wickra.h
cp "${root}/target/wasm32-unknown-emscripten/release/libwickra.a" src/libwickra.a
rm -rf "${build}"
# The static archive is linked into the package object; no shared lib to
# bundle and no rpath needed.
sed "s|@WICKRA_RPATH@||" src/Makevars.in > src/Makevars
exit 0
fi
if [ -n "${WICKRA_INCLUDE_DIR}" ] && [ -n "${WICKRA_LIB_DIR}" ]; then
echo "wickra: using C ABI from WICKRA_INCLUDE_DIR / WICKRA_LIB_DIR (dev override)"
inc="${WICKRA_INCLUDE_DIR}"
+119
View File
@@ -0,0 +1,119 @@
# Golden-fixture parity: replay the shared testdata/golden input series through
# the R FFI and assert every value matches the Rust reference output. Where the
# archetype test only checks finiteness, this pins exact values, catching wiring
# bugs (swapped params, wrong multi-output field). Fixtures are generated by
# `cargo run -p wickra-examples --bin gen_golden`.
#
# The fixtures live at the repository root (testdata/golden) and are present
# during the monorepo test run, but they are NOT bundled into the standalone R
# package. A packaged check (r-universe / CRAN) therefore cannot find them, so
# these tests skip there; the parity is already enforced by the repository CI.
find_golden_dir <- function() {
d <- normalizePath(getwd(), winslash = "/", mustWork = FALSE)
repeat {
g <- file.path(d, "testdata", "golden")
if (dir.exists(g)) return(g)
parent <- dirname(d)
if (identical(parent, d)) return(NULL)
d <- parent
}
}
golden_dir <- find_golden_dir()
skip_if_no_golden <- function() {
skip_if(is.null(golden_dir), "golden fixtures not bundled with the package")
}
read_golden <- function(name) {
read.csv(file.path(golden_dir, paste0(name, ".csv")),
colClasses = "character", check.names = FALSE)
}
read_golden_input <- function() read.csv(file.path(golden_dir, "input.csv"))
gcell <- function(s) if (identical(s, "nan")) NA_real_ else as.numeric(s)
expect_close <- function(got, want, row, field) {
if (is.na(want)) {
expect_true(is.na(got), info = paste("row", row, field, "expected warmup/NA"))
} else {
tol <- 1e-6 * max(1, abs(want))
expect_lte(abs(got - want), tol, label = paste("row", row, field, "got", got, "want", want))
}
}
test_that("scalar indicators match golden", {
skip_if_no_golden()
golden_input <- read_golden_input()
specs <- list(c("sma", 14), c("ema", 14), c("rsi", 14))
for (spec in specs) {
name <- spec[[1]]
ind <- switch(name, sma = Sma(14), ema = Ema(14), rsi = Rsi(14))
exp <- read_golden(name)
for (i in seq_len(nrow(golden_input))) {
got <- update(ind, golden_input$close[i])
expect_close(got, gcell(exp[i, 1]), i, name)
}
}
})
test_that("candle Atr matches golden", {
skip_if_no_golden()
golden_input <- read_golden_input()
atr <- Atr(14)
exp <- read_golden("atr")
for (i in seq_len(nrow(golden_input))) {
got <- update(atr, golden_input$open[i], golden_input$high[i], golden_input$low[i],
golden_input$close[i], golden_input$volume[i], i - 1)
expect_close(got, gcell(exp[i, 1]), i, "atr")
}
})
test_that("pairwise Beta matches golden", {
skip_if_no_golden()
golden_input <- read_golden_input()
beta <- Beta(20)
exp <- read_golden("beta")
for (i in seq_len(nrow(golden_input))) {
# generator fed (close, open)
got <- update(beta, golden_input$close[i], golden_input$open[i])
expect_close(got, gcell(exp[i, 1]), i, "beta")
}
})
test_that("multi-output MACD matches golden", {
skip_if_no_golden()
golden_input <- read_golden_input()
macd <- MacdIndicator(12, 26, 9)
exp <- read_golden("macd")
for (i in seq_len(nrow(golden_input))) {
out <- update(macd, golden_input$close[i])
if (identical(exp[i, "macd"], "nan")) {
expect_true(all(is.na(out)), info = paste("row", i, "macd warmup"))
} else {
expect_close(out[["macd"]], gcell(exp[i, "macd"]), i, "macd.macd")
expect_close(out[["signal"]], gcell(exp[i, "signal"]), i, "macd.signal")
expect_close(out[["histogram"]], gcell(exp[i, "histogram"]), i, "macd.histogram")
}
}
})
test_that("multi-output ADX matches golden", {
skip_if_no_golden()
golden_input <- read_golden_input()
adx <- Adx(14)
exp <- read_golden("adx")
for (i in seq_len(nrow(golden_input))) {
out <- update(adx, golden_input$open[i], golden_input$high[i], golden_input$low[i],
golden_input$close[i], golden_input$volume[i], i - 1)
if (identical(exp[i, "plus_di"], "nan")) {
expect_true(all(is.na(out)), info = paste("row", i, "adx warmup"))
} else {
expect_close(out[["plus_di"]], gcell(exp[i, "plus_di"]), i, "adx.plus_di")
expect_close(out[["minus_di"]], gcell(exp[i, "minus_di"]), i, "adx.minus_di")
expect_close(out[["adx"]], gcell(exp[i, "adx"]), i, "adx.adx")
}
}
})
+12
View File
@@ -46,6 +46,18 @@ 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.
## Benchmark
`benchmarks/throughput.mjs` reports streaming and batch updates-per-second for
`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a
cross-library ratio (the same Rust core runs under every binding) — see the
repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3.
```bash
wasm-pack build --target nodejs --out-dir pkg-node --release
node benchmarks/throughput.mjs
```
## Documentation
The full indicator catalogue, guides, quickstarts, and API reference live in
+127
View File
@@ -0,0 +1,127 @@
// Throughput benchmark for the Wickra WebAssembly bindings.
//
// Measures how many indicator updates per second the wasm binding sustains,
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
// OHLCV series. It is the wasm counterpart of the Node `throughput.js` and the
// Rust criterion benches: it benchmarks Wickra's own O(1) streaming engine
// across the JS<->wasm boundary (there is no install-free TA library with a
// comparable surface to compare against), so the headline number is raw
// per-binding throughput / FFI overhead, not a cross-library ratio.
//
// Three indicators are timed, chosen by FFI call-signature archetype rather
// than algorithm (the algorithm is identical to the Rust core; only the
// boundary cost differs): SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and
// MACD (1-in -> multi-out). Streaming is timed for all three; batch only for
// the single-output SMA and ATR (multi-output batch is not exposed uniformly).
//
// Build the nodejs-target package first (needs the wasm32-unknown-unknown
// target, i.e. a rustup toolchain), then run:
//
// cd bindings/wasm && wasm-pack build --target nodejs --out-dir pkg-node --release
// node benchmarks/throughput.mjs # 200k bars (default)
// node benchmarks/throughput.mjs --bars 1000000
import { createRequire } from 'node:module';
import { hrtime } from 'node:process';
const require = createRequire(import.meta.url);
// wasm-pack --target nodejs emits a CommonJS module named after the crate.
const wasm = require('../pkg-node/wickra_wasm.js');
const { SMA, ATR, MACD } = wasm;
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 Float64Array(BARS);
const high = new Float64Array(BARS);
const low = new Float64Array(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;
}
// 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 = hrtime.bigint();
fn();
samples.push(Number(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
}
// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out).
const indicators = [
{
name: 'SMA(20)',
stream: () => {
const ind = new SMA(20);
for (let i = 0; i < BARS; i++) ind.update(close[i]);
ind.free();
},
batch: () => {
const ind = new SMA(20);
ind.batch(close);
ind.free();
},
},
{
name: 'ATR(14)',
stream: () => {
const ind = new ATR(14);
for (let i = 0; i < BARS; i++) ind.update(high[i], low[i], close[i]);
ind.free();
},
batch: () => {
const ind = new ATR(14);
ind.batch(high, low, close);
ind.free();
},
},
{
name: 'MACD(12,26,9)',
stream: () => {
const ind = new MACD(12, 26, 9);
for (let i = 0; i < BARS; i++) ind.update(close[i]);
ind.free();
},
batch: null, // multi-output: streaming only
},
];
console.log(`Wickra WASM 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 streamMups = mupsFromNs(timeNs(ind.stream)).toFixed(1);
const batchMups = ind.batch ? mupsFromNs(timeNs(ind.batch)).toFixed(1) : '—';
console.log(`${ind.name.padEnd(22)}${streamMups.padStart(20)}${batchMups.padStart(18)}`);
}
console.log(
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
'`update` path crossing the JS<->wasm boundary once per value; batch is the\n' +
'bulk array path (one boundary crossing). Higher is better. Numbers are\n' +
'machine-dependent — use them for relative comparison, not as a speed claim.',
);
@@ -84,6 +84,9 @@ impl Indicator for Autocorrelation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
+14
View File
@@ -92,6 +92,9 @@ impl Indicator for Beta {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
@@ -225,4 +228,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut b = Beta::new(3).unwrap();
assert_eq!(b.update((f64::NAN, 1.0)), None);
assert_eq!(b.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(b.update((1.0, 2.0)), None);
assert_eq!(b.update((2.0, 5.0)), None);
assert!(b.update((3.0, 7.0)).is_some());
}
}
@@ -88,6 +88,9 @@ impl Indicator for BetaNeutralSpread {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
@@ -244,4 +247,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut s = BetaNeutralSpread::new(3).unwrap();
assert_eq!(s.update((f64::NAN, 1.0)), None);
assert_eq!(s.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(s.update((1.0, 2.0)), None);
assert_eq!(s.update((2.0, 5.0)), None);
assert!(s.update((3.0, 7.0)).is_some());
}
}
@@ -106,6 +106,9 @@ impl Indicator for BomarBands {
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
+3
View File
@@ -60,6 +60,9 @@ impl Indicator for Cfo {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
let forecast = self.linreg.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
@@ -71,6 +71,9 @@ impl Indicator for CoefficientOfVariation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -116,6 +116,9 @@ impl Indicator for Cointegration {
fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
@@ -443,4 +446,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut c = Cointegration::new(4, 0).unwrap();
assert_eq!(c.update((f64::NAN, 1.0)), None);
assert_eq!(c.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(c.update((1.0, 2.0)), None);
assert_eq!(c.update((2.0, 5.0)), None);
assert_eq!(c.update((3.0, 7.0)), None);
assert!(c.update((4.0, 11.0)).is_some());
}
}
@@ -96,6 +96,9 @@ impl Indicator for DetrendedStdDev {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -76,6 +76,9 @@ impl Indicator for DistanceSsd {
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if !input.0.is_finite() || !input.1.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -232,4 +235,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut d = DistanceSsd::new(3).unwrap();
assert_eq!(d.update((f64::NAN, 1.0)), None);
assert_eq!(d.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(d.update((1.0, 1.0)), None);
assert_eq!(d.update((2.0, 4.0)), None);
assert!(d.update((3.0, 9.0)).is_some());
}
}
@@ -77,6 +77,9 @@ impl Indicator for Expectancy {
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if !ret.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
@@ -96,6 +96,9 @@ impl Indicator for GrangerCausality {
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if !input.0.is_finite() || !input.1.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -332,4 +335,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut g = GrangerCausality::new(5, 1).unwrap();
assert_eq!(g.update((f64::NAN, 1.0)), None);
assert_eq!(g.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
for t in 0..4 {
assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
}
assert!(g.update((4.0, 2.0)).is_some());
}
}
@@ -87,6 +87,9 @@ impl Indicator for HasbrouckInformationShare {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (x, y) = input;
if !x.is_finite() || !y.is_finite() {
return None;
}
let Some((px, py)) = self.prev else {
self.prev = Some((x, y));
return None;
@@ -248,4 +251,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut h = HasbrouckInformationShare::new(2).unwrap();
assert_eq!(h.update((f64::NAN, 1.0)), None);
assert_eq!(h.update((1.0, f64::INFINITY)), None);
// First finite tick seeds prev; two more returns fill the window.
assert_eq!(h.update((1.0, 1.0)), None);
assert_eq!(h.update((2.0, 3.0)), None);
assert!(h.update((3.0, 4.0)).is_some());
}
}
@@ -138,6 +138,9 @@ impl Indicator for HurstExponent {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -116,6 +116,9 @@ impl Indicator for KalmanHedgeRatio {
fn update(&mut self, input: (f64, f64)) -> Option<KalmanHedgeRatioOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
// Predicted state covariance: add the transition noise to the diagonal
// (the very first observation starts from a zero prior).
let mut cov_pred = self.cov;
@@ -130,6 +130,9 @@ impl Indicator for KendallTau {
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if !input.0.is_finite() || !input.1.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -293,4 +296,14 @@ mod tests {
let v = k.update((3.0, 3.0)).unwrap();
assert!((-1.0..=1.0).contains(&v), "got {v}");
}
#[test]
fn non_finite_input_returns_none() {
let mut k = KendallTau::new(2).unwrap();
assert_eq!(k.update((f64::NAN, 1.0)), None);
assert_eq!(k.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(k.update((1.0, 2.0)), None);
assert!(k.update((2.0, 5.0)).is_some());
}
}
@@ -80,6 +80,9 @@ impl Indicator for Kurtosis {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
let sq = old * old;
@@ -152,6 +152,9 @@ impl Indicator for LeadLagCrossCorrelation {
fn update(&mut self, input: (f64, f64)) -> Option<LeadLagCrossCorrelationOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.a_buf.len() == self.len {
self.a_buf.pop_front();
self.b_buf.pop_front();
@@ -321,4 +324,17 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
// len = window + 2*max_lag = 2 + 2 = 4 finite ticks fill the buffers.
let mut ll = LeadLagCrossCorrelation::new(2, 1).unwrap();
assert_eq!(ll.update((f64::NAN, 1.0)), None);
assert_eq!(ll.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(ll.update((1.0, 2.0)), None);
assert_eq!(ll.update((2.0, 1.0)), None);
assert_eq!(ll.update((3.0, 4.0)), None);
assert!(ll.update((4.0, 2.0)).is_some());
}
}
@@ -99,6 +99,9 @@ impl Indicator for LinearRegression {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Sliding phase: pop the oldest, then shift every remaining index
// down by 1 in the running `sum_xy`. The identity
@@ -57,6 +57,9 @@ impl Indicator for LinRegAngle {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
self.slope.update(value).map(|s| s.atan().to_degrees())
}
@@ -98,6 +98,9 @@ impl Indicator for LinRegChannel {
type Output = LinRegChannelOutput;
fn update(&mut self, value: f64) -> Option<LinRegChannelOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -77,6 +77,9 @@ impl Indicator for LinRegIntercept {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -87,6 +87,9 @@ impl Indicator for LinRegSlope {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Sliding-window identity: when the window slides one step forward
// the indices `x` for every kept entry shift down by 1, so
@@ -89,6 +89,9 @@ impl Indicator for MedianAbsoluteDeviation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -96,6 +96,9 @@ impl Indicator for MedianChannel {
type Output = MedianChannelOutput;
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -59,6 +59,9 @@ impl Indicator for MidPoint {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -80,6 +80,9 @@ impl Indicator for OuHalfLife {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -242,4 +245,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut hl = OuHalfLife::new(4).unwrap();
assert_eq!(hl.update((f64::NAN, 1.0)), None);
assert_eq!(hl.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(hl.update((1.0, 0.0)), None);
assert_eq!(hl.update((2.0, 0.0)), None);
assert_eq!(hl.update((3.0, 0.0)), None);
assert!(hl.update((4.0, 0.0)).is_some());
}
}
@@ -89,6 +89,9 @@ impl Indicator for PearsonCorrelation {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (x, y) = input;
if !x.is_finite() || !y.is_finite() {
return None;
}
if self.window.len() == self.period {
let (ox, oy) = self.window.pop_front().expect("non-empty");
self.sum_x -= ox;
@@ -243,4 +246,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut p = PearsonCorrelation::new(3).unwrap();
assert_eq!(p.update((f64::NAN, 1.0)), None);
assert_eq!(p.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(p.update((1.0, 2.0)), None);
assert_eq!(p.update((2.0, 5.0)), None);
assert!(p.update((3.0, 7.0)).is_some());
}
}
@@ -74,6 +74,9 @@ impl Indicator for PercentageTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let step = close.abs() * self.percent / 100.0;
let stop = match self.prev_stop {
Some(prev) => {
@@ -82,6 +82,9 @@ impl Indicator for PolarizedFractalEfficiency {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
if let Some(prev) = self.prev_close {
let diff = close - prev;
let segment = diff.mul_add(diff, 1.0).sqrt();
@@ -80,6 +80,9 @@ impl Indicator for QuartileBands {
type Output = QuartileBandsOutput;
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -95,6 +95,9 @@ impl Indicator for RSquared {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -74,6 +74,9 @@ impl Indicator for RenkoTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let anchor = match self.anchor {
Some(prev) => {
if self.long {
@@ -90,6 +90,9 @@ impl Indicator for RollingCorrelation {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (x, y) = input;
if !x.is_finite() || !y.is_finite() {
return None;
}
let Some((px, py)) = self.prev else {
// First level in each channel: store it, no return yet.
self.prev = Some((x, y));
@@ -272,4 +275,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut rc = RollingCorrelation::new(2).unwrap();
assert_eq!(rc.update((f64::NAN, 1.0)), None);
assert_eq!(rc.update((1.0, f64::INFINITY)), None);
// First finite tick seeds prev; two more returns fill the window.
assert_eq!(rc.update((1.0, 1.0)), None);
assert_eq!(rc.update((2.0, 3.0)), None);
assert!(rc.update((3.0, 5.0)).is_some());
}
}
@@ -86,6 +86,9 @@ impl Indicator for RollingCovariance {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (x, y) = input;
if !x.is_finite() || !y.is_finite() {
return None;
}
let Some((px, py)) = self.prev else {
self.prev = Some((x, y));
return None;
@@ -240,4 +243,15 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut rc = RollingCovariance::new(2).unwrap();
assert_eq!(rc.update((f64::NAN, 1.0)), None);
assert_eq!(rc.update((1.0, f64::INFINITY)), None);
// First finite tick seeds prev; two more returns fill the window.
assert_eq!(rc.update((1.0, 1.0)), None);
assert_eq!(rc.update((2.0, 3.0)), None);
assert!(rc.update((3.0, 5.0)).is_some());
}
}
@@ -71,6 +71,9 @@ impl Indicator for RollingIqr {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -70,6 +70,9 @@ impl Indicator for RollingPercentileRank {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -104,6 +104,9 @@ impl Indicator for RollingQuantile {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -79,6 +79,9 @@ impl Indicator for Skewness {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -123,6 +123,9 @@ impl Indicator for SpearmanCorrelation {
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if !input.0.is_finite() || !input.1.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -311,4 +314,14 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut s = SpearmanCorrelation::new(2).unwrap();
assert_eq!(s.update((f64::NAN, 1.0)), None);
assert_eq!(s.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(s.update((1.0, 2.0)), None);
assert!(s.update((2.0, 5.0)).is_some());
}
}
@@ -89,6 +89,9 @@ impl Indicator for SpreadAr1Coefficient {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -248,4 +251,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
assert_eq!(ar1.update((f64::NAN, 1.0)), None);
assert_eq!(ar1.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(ar1.update((1.0, 0.0)), None);
assert_eq!(ar1.update((2.0, 0.0)), None);
assert_eq!(ar1.update((3.0, 0.0)), None);
assert!(ar1.update((4.0, 0.0)).is_some());
}
}
@@ -116,6 +116,9 @@ impl Indicator for SpreadBollingerBands {
fn update(&mut self, input: (f64, f64)) -> Option<SpreadBollingerBandsOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
let spread = a - b;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
@@ -84,6 +84,9 @@ impl Indicator for SpreadHurst {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -268,4 +271,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut h = SpreadHurst::new(8).unwrap();
assert_eq!(h.update((f64::NAN, 1.0)), None);
assert_eq!(h.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
for t in 0..7 {
assert_eq!(h.update((f64::from(t), 0.0)), None);
}
assert!(h.update((7.0, 0.0)).is_some());
}
}
@@ -93,6 +93,9 @@ impl Indicator for StandardError {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Slide: pop oldest, shift indices, then push the new value at index n 1.
let y0 = self.window.pop_front().expect("non-empty");
@@ -106,6 +106,9 @@ impl Indicator for StandardErrorBands {
type Output = StandardErrorBandsOutput;
fn update(&mut self, value: f64) -> Option<StandardErrorBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -84,6 +84,9 @@ impl Indicator for StepTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let stop = match self.prev_stop {
Some(prev) => {
if self.long {
@@ -72,6 +72,9 @@ impl Indicator for TrendLabel {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -75,6 +75,9 @@ impl Indicator for TrendStrengthIndex {
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return None;
}
self.buf.push_back(price);
if self.buf.len() > self.period {
self.buf.pop_front();
+3
View File
@@ -80,6 +80,9 @@ impl Indicator for Tsf {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -73,6 +73,9 @@ impl Indicator for TsfOscillator {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
let forecast = self.tsf.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
@@ -70,6 +70,9 @@ impl Indicator for Variance {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -98,6 +98,9 @@ impl Indicator for VarianceRatio {
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -264,4 +267,16 @@ mod tests {
let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_input_returns_none() {
let mut vr = VarianceRatio::new(4, 2).unwrap();
assert_eq!(vr.update((f64::NAN, 1.0)), None);
assert_eq!(vr.update((1.0, f64::INFINITY)), None);
// The rejected ticks leave no trace: a fresh window still warms up.
assert_eq!(vr.update((1.0, 0.0)), None);
assert_eq!(vr.update((2.0, 0.0)), None);
assert_eq!(vr.update((3.0, 0.0)), None);
assert!(vr.update((4.0, 0.0)).is_some());
}
}
@@ -68,6 +68,9 @@ impl Indicator for VerticalHorizontalFilter {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.closes.len() == self.period {
self.closes.pop_front();
}
@@ -66,6 +66,9 @@ impl Indicator for WinRate {
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if !ret.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
if old > 0.0 {

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