Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f0bfbc48 | |||
| 8ccfd638b2 | |||
| 452f270b4d | |||
| baffebd3da | |||
| e631e55195 | |||
| 63949f6fc8 | |||
| 92210eb7b8 | |||
| c3029f2548 | |||
| 806ae22abe | |||
| fb9c39d4cd | |||
| b1653e2107 | |||
| 32e18cb3a3 | |||
| dc0c3d1736 | |||
| ad1231cde1 | |||
| 7ebb60b60a | |||
| d7cb771a28 | |||
| 3e5a19c94a | |||
| 8bfe24ac1d | |||
| 395a2289f4 | |||
| 9973d1a6bf | |||
| cb216668ee | |||
| 20c0002f8e | |||
| 99497eb062 | |||
| 7e51f31f02 | |||
| 3ee3fb67ec | |||
| 3ebcb3f758 | |||
| b31a9a3624 | |||
| 6433c9b3de | |||
| 447bbc8220 | |||
| c9ef2fc037 | |||
| 49c2872ad4 |
@@ -59,3 +59,20 @@ updates:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
|
||||
# Java binding + examples (Maven). Tracks the C-ABI binding's build plugins
|
||||
# (e.g. central-publishing-maven-plugin) and the examples' jackson dependency,
|
||||
# which had no Dependabot coverage before — a stale publishing plugin slipped
|
||||
# through unnoticed until an OSV scan flagged it.
|
||||
- package-ecosystem: maven
|
||||
directories:
|
||||
- "/bindings/java"
|
||||
- "/bindings/java/benchmarks"
|
||||
- "/examples/java"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(maven)"
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
name: Rust cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+58
-25
@@ -34,12 +34,13 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -104,8 +105,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -193,8 +195,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -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:
|
||||
@@ -287,7 +291,7 @@ jobs:
|
||||
toolchain: "1.88"
|
||||
packages: "-p wickra-node"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -324,8 +328,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -340,7 +345,7 @@ jobs:
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
@@ -365,8 +370,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -383,8 +389,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -408,7 +415,7 @@ jobs:
|
||||
# attributes the modern nightly compiler rejects, so the install
|
||||
# never gets off the ground. The prebuilt binary avoids the entire
|
||||
# transitive-dep compile.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
@@ -436,13 +443,14 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.9", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -517,8 +525,9 @@ 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
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -540,7 +549,7 @@ jobs:
|
||||
# same taiki-e prebuilt-binary installer we already use for
|
||||
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||
# release, which has `--features` as a top-level flag (since 0.12).
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
@@ -560,13 +569,14 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ["18", "20"]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -618,19 +628,28 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -643,7 +662,7 @@ jobs:
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cbindgen
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cbindgen
|
||||
@@ -680,12 +699,13 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -730,12 +750,13 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -745,7 +766,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
continue-on-error: true
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
@@ -759,7 +780,7 @@ jobs:
|
||||
|
||||
- name: Set up Go (retry)
|
||||
if: steps.setup-go.outcome == 'failure'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
@@ -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:
|
||||
@@ -860,7 +882,7 @@ jobs:
|
||||
WICKRA_INCLUDE_DIR: ${{ github.workspace }}/bindings/c/include
|
||||
WICKRA_LIB_DIR: ${{ github.workspace }}/target/release
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -904,6 +926,16 @@ jobs:
|
||||
R CMD INSTALL bindings/r
|
||||
Rscript -e 'library(testthat); library(wickra); test_dir("bindings/r/tests/testthat", stop_on_failure = TRUE)'
|
||||
|
||||
- name: Build the vignette code
|
||||
shell: bash
|
||||
# The getting-started vignette runs at R CMD check time on r-universe /
|
||||
# CRAN (with pandoc); this job only INSTALLs, so execute the vignette's R
|
||||
# chunks here (knit, no pandoc needed) to catch a broken example before it
|
||||
# reaches the published build.
|
||||
run: |
|
||||
Rscript -e 'install.packages("knitr", repos = Sys.getenv("RSPM", unset = "https://cloud.r-project.org"))'
|
||||
Rscript -e 'knitr::knit("bindings/r/vignettes/getting-started.Rmd", output = tempfile(fileext = ".md"), quiet = TRUE); cat("vignette code OK\n")'
|
||||
|
||||
- name: Run the offline R examples
|
||||
shell: bash
|
||||
# No loader-path exports: the installed package is self-contained (bundled
|
||||
@@ -923,12 +955,13 @@ 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:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -950,7 +983,7 @@ jobs:
|
||||
- name: Set up JDK 22
|
||||
id: setup-java
|
||||
continue-on-error: true
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "22"
|
||||
@@ -965,7 +998,7 @@ jobs:
|
||||
|
||||
- name: Set up JDK 22 (retry)
|
||||
if: steps.setup-java.outcome == 'failure'
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "22"
|
||||
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Settings -> Environments.
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
# consumers can audit the published dependency tree without
|
||||
# re-resolving Cargo.lock.
|
||||
- name: Install cargo-cyclonedx
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- { os: windows-11-arm, target: aarch64, manylinux: auto }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
name: Build Python sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Sync root README into bindings/python so it ships in the sdist
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
- name: Install wasm-pack (latest, via prebuilt binary)
|
||||
# See the matching note in ci.yml: jetli's default installs an old
|
||||
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
@@ -588,7 +588,7 @@ jobs:
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write # request the GitHub OIDC token for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -718,7 +718,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -758,7 +758,7 @@ jobs:
|
||||
# setup-java writes a settings.xml with the 'central' server credentials
|
||||
# (mapped from the env vars below) and imports the GPG signing key.
|
||||
- name: Set up JDK 22 + Maven Central credentials + GPG
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "22"
|
||||
@@ -788,7 +788,7 @@ jobs:
|
||||
needs: c-abi-build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -877,7 +877,7 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
id-token: write # OIDC token to publish results to the OpenSSF API
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload SARIF to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# Fetching the now-gone `refs/heads/<branch>` then fails the run (exit 1).
|
||||
# The head SHA stays reachable via `refs/pull/N/head` after the branch is
|
||||
# gone, so the checkout — and the run — survives an instant merge.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
@@ -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)."
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: metadata audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
actions: read # online audits resolve referenced actions
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ bindings/node/npm-debug.log*
|
||||
|
||||
# WASM build output
|
||||
bindings/wasm/pkg/
|
||||
bindings/wasm/pkg-node/
|
||||
|
||||
# Python venv
|
||||
.venv/
|
||||
|
||||
+16
-13
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
+121
-1
@@ -5,6 +5,118 @@ 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.9] - 2026-06-12
|
||||
|
||||
Maintenance release: supply-chain and CI housekeeping only. No library code or
|
||||
public API changes.
|
||||
|
||||
### Security
|
||||
- Triaged the pyo3 advisories RUSTSEC-2026-0176 (out-of-bounds read in
|
||||
`PyList`/`PyTuple` `nth`/`nth_back`) and RUSTSEC-2026-0177 (missing `Sync`
|
||||
bound on `PyCFunction::new_closure`) as **not affecting Wickra**: neither
|
||||
vulnerable API is reachable from the Python binding. Both are fixed in pyo3
|
||||
0.29, but rust-numpy 0.28 pins pyo3 `^0.28`, so the upgrade is blocked
|
||||
upstream; the advisories are recorded with their not-affected rationale in
|
||||
`deny.toml` and `osv-scanner.toml` and will be cleared once rust-numpy 0.29
|
||||
ships.
|
||||
|
||||
### Changed
|
||||
- Java binding: bumped `central-publishing-maven-plugin` 0.5.0 → 0.10.0 (the
|
||||
Maven Central publishing plugin used at release time).
|
||||
- Bumped the SHA-pinned GitHub Actions used in CI (`actions/checkout`,
|
||||
`actions/setup-go`, `actions/setup-java`, `github/codeql-action`,
|
||||
`taiki-e/install-action`) to their latest releases.
|
||||
- Added a Maven ecosystem to Dependabot so the Java binding's build plugins and
|
||||
dependencies are tracked going forward.
|
||||
|
||||
|
||||
## [0.8.8] - 2026-06-11
|
||||
### Fixed
|
||||
- R binding: declare `Depends: R (>= 2.10)`, clearing the `R CMD check` warning
|
||||
("package needs dependence on R (>= 2.10)") that the bundled, lazy-loaded
|
||||
`sample_ohlcv` dataset triggers on r-universe / CRAN.
|
||||
|
||||
## [0.8.7] - 2026-06-11
|
||||
### Added
|
||||
- R binding: a *Getting started* vignette and a synthetic `sample_ohlcv` example
|
||||
dataset, giving new users a runnable, self-contained walkthrough and populating
|
||||
the R-universe Articles and Datasets tabs. The vignette's code is exercised in
|
||||
CI so a broken example is caught before the published build.
|
||||
|
||||
## [0.8.6] - 2026-06-11
|
||||
### Changed
|
||||
- Package registry metadata for better discoverability:
|
||||
- R (R-universe): added the R-universe URL and `X-schema.org-keywords` to the
|
||||
R `DESCRIPTION`, plus a package logo at `bindings/r/man/figures/logo.png`
|
||||
(pkgdown convention).
|
||||
- Python (PyPI): added a `Documentation` project URL.
|
||||
- C# (NuGet): added a package icon via `PackageIcon`.
|
||||
|
||||
## [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 +1597,15 @@ 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.9...HEAD
|
||||
[0.8.9]: https://github.com/wickra-lib/wickra/compare/v0.8.8...v0.8.9
|
||||
[0.8.8]: https://github.com/wickra-lib/wickra/compare/v0.8.7...v0.8.8
|
||||
[0.8.7]: https://github.com/wickra-lib/wickra/compare/v0.8.6...v0.8.7
|
||||
[0.8.6]: https://github.com/wickra-lib/wickra/compare/v0.8.5...v0.8.6
|
||||
[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
@@ -1944,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1955,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
@@ -1967,14 +1967,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-c"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1984,7 +1984,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -2001,7 +2001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2011,7 +2011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -2021,7 +2021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -2030,7 +2030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
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.9" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -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
|
||||
@@ -406,13 +419,13 @@ The library is provided **as is**, without warranty of any kind; see
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<img alt="GitHub stars" src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/stars.svg">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<img alt="GitHub forks" src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/forks.svg">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<img alt="GitHub issues" src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/issues.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -423,6 +436,6 @@ The library is provided **as is**, without warranty of any kind; see
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#wickra-lib/wickra&Date">
|
||||
<img alt="Wickra star history" width="640"
|
||||
src="https://api.star-history.com/svg?repos=wickra-lib/wickra&type=Date&theme=dark">
|
||||
src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/star-history.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
+3
-3
@@ -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.9`
|
||||
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.9 (latest) | :white_check_mark: |
|
||||
| < 0.8.9 | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>Wickra</PackageId>
|
||||
<Version>0.7.9</Version>
|
||||
<Version>0.8.9</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>
|
||||
@@ -20,6 +20,7 @@
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>technical-analysis;indicators;trading;finance;streaming;ffi;native</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
@@ -42,6 +43,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\icon.png" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
+12
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
|
||||
[](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
|
||||
[](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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -30,14 +30,14 @@ Maven:
|
||||
<dependency>
|
||||
<groupId>org.wickra</groupId>
|
||||
<artifactId>wickra</artifactId>
|
||||
<version>0.8.1</version>
|
||||
<version>0.8.9</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Gradle:
|
||||
|
||||
```kotlin
|
||||
implementation("org.wickra:wickra:0.8.1")
|
||||
implementation("org.wickra:wickra:0.8.9")
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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 <-> 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 -> 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).
|
||||
*
|
||||
* <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) {}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>org.wickra</groupId>
|
||||
<artifactId>wickra</artifactId>
|
||||
<version>0.8.1</version>
|
||||
<version>0.8.9</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Wickra</name>
|
||||
@@ -157,7 +157,7 @@
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<version>0.10.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"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.9",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"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.9",
|
||||
"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.9",
|
||||
"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": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"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.9",
|
||||
"wickra-darwin-x64": "0.8.9",
|
||||
"wickra-linux-arm64-gnu": "0.8.9",
|
||||
"wickra-linux-x64-gnu": "0.8.9",
|
||||
"wickra-win32-arm64-msvc": "0.8.9",
|
||||
"wickra-win32-x64-msvc": "0.8.9"
|
||||
}
|
||||
},
|
||||
"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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.8.9.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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.8.9.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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.8.9.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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.8.9.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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.8.9.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.9",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.8.9.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.9",
|
||||
"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.9",
|
||||
"wickra-linux-arm64-gnu": "0.8.9",
|
||||
"wickra-darwin-x64": "0.8.9",
|
||||
"wickra-darwin-arm64": "0.8.9",
|
||||
"wickra-win32-x64-msvc": "0.8.9",
|
||||
"wickra-win32-arm64-msvc": "0.8.9"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.8.1"
|
||||
version = "0.8.9"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -48,6 +48,7 @@ bench = [
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/wickra-lib/wickra"
|
||||
Documentation = "https://docs.wickra.org"
|
||||
Repository = "https://github.com/wickra-lib/wickra"
|
||||
Issues = "https://github.com/wickra-lib/wickra/issues"
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
^src/wickra-c$
|
||||
^src/Makevars$
|
||||
^\.gitignore$
|
||||
^data-raw$
|
||||
|
||||
+10
-3
@@ -1,15 +1,19 @@
|
||||
Package: wickra
|
||||
Type: Package
|
||||
Title: Streaming-First Technical Indicators
|
||||
Version: 0.7.9
|
||||
Version: 0.8.9
|
||||
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
|
||||
the Rust core and the other language bindings, so that live and historical
|
||||
evaluation use the exact same implementation.
|
||||
License: MIT + file LICENSE | Apache License 2.0
|
||||
URL: https://github.com/wickra-lib/wickra, https://docs.wickra.org
|
||||
URL: https://github.com/wickra-lib/wickra, https://docs.wickra.org,
|
||||
https://wickra-lib.r-universe.dev
|
||||
BugReports: https://github.com/wickra-lib/wickra/issues
|
||||
X-schema.org-keywords: technical-analysis, indicators, streaming, trading,
|
||||
finance, quant, algorithmic-trading, backtesting, time-series, ta-lib,
|
||||
candlestick, market-data
|
||||
Encoding: UTF-8
|
||||
NeedsCompilation: yes
|
||||
SystemRequirements: the Wickra C ABI shared library, downloaded automatically at
|
||||
@@ -17,6 +21,9 @@ SystemRequirements: the Wickra C ABI shared library, downloaded automatically at
|
||||
Set WICKRA_INCLUDE_DIR and WICKRA_LIB_DIR to build against a locally built C
|
||||
ABI instead (e.g. after `cargo build -p wickra-c --release`).
|
||||
Roxygen: list(markdown = TRUE)
|
||||
Suggests: testthat (>= 3.0.0)
|
||||
Depends: R (>= 2.10)
|
||||
Suggests: testthat (>= 3.0.0), knitr, rmarkdown
|
||||
VignetteBuilder: knitr
|
||||
LazyData: true
|
||||
Config/testthat/edition: 3
|
||||
Config/roxygen2/version: 8.0.0
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#' Synthetic daily OHLCV sample series
|
||||
#'
|
||||
#' A deterministic, synthetic daily OHLCV (open / high / low / close / volume)
|
||||
#' price series for use in the examples, the *Getting started* vignette, and
|
||||
#' tests. It is a seeded random walk, **not** real market data. Regenerate with
|
||||
#' `data-raw/sample_ohlcv.R`.
|
||||
#'
|
||||
#' @format A data frame with 250 rows and 6 columns:
|
||||
#' \describe{
|
||||
#' \item{date}{Trading date (`Date`).}
|
||||
#' \item{open}{Opening price.}
|
||||
#' \item{high}{Session high.}
|
||||
#' \item{low}{Session low.}
|
||||
#' \item{close}{Closing price.}
|
||||
#' \item{volume}{Traded volume.}
|
||||
#' }
|
||||
"sample_ohlcv"
|
||||
+12
-1
@@ -1,4 +1,4 @@
|
||||
# Wickra — R
|
||||
# Wickra — R <img src="man/figures/logo.png" align="right" height="120" alt="Wickra logo" />
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
Vendored
+30
@@ -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}"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generates data/sample_ohlcv.rda — a deterministic, synthetic daily OHLCV
|
||||
# series used by the examples, the getting-started vignette, and tests. It is a
|
||||
# seeded random walk, NOT real market data.
|
||||
#
|
||||
# Regenerate (run from the R package root, bindings/r):
|
||||
# Rscript data-raw/sample_ohlcv.R
|
||||
|
||||
set.seed(42)
|
||||
n <- 250L
|
||||
dates <- seq(as.Date("2023-01-02"), by = "day", length.out = n)
|
||||
|
||||
# Random-walk close with a mild upward drift; derive OHLC around it.
|
||||
returns <- rnorm(n, mean = 0.0004, sd = 0.012)
|
||||
close <- round(100 * cumprod(1 + returns), 2)
|
||||
open <- round(c(100, head(close, -1)) * (1 + rnorm(n, 0, 0.003)), 2)
|
||||
high <- round(pmax(open, close) * (1 + abs(rnorm(n, 0, 0.004))), 2)
|
||||
low <- round(pmin(open, close) * (1 - abs(rnorm(n, 0, 0.004))), 2)
|
||||
volume <- round(1e6 * exp(rnorm(n, 0, 0.3)))
|
||||
|
||||
sample_ohlcv <- data.frame(
|
||||
date = dates,
|
||||
open = open,
|
||||
high = high,
|
||||
low = low,
|
||||
close = close,
|
||||
volume = volume
|
||||
)
|
||||
|
||||
save(sample_ohlcv, file = "data/sample_ohlcv.rda", compress = "xz", version = 2)
|
||||
cat(sprintf("wrote data/sample_ohlcv.rda: %d rows x %d cols\n",
|
||||
nrow(sample_ohlcv), ncol(sample_ohlcv)))
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,27 @@
|
||||
% Generated by roxygen2: do not edit by hand
|
||||
% Please edit documentation in R/data.R
|
||||
\docType{data}
|
||||
\name{sample_ohlcv}
|
||||
\alias{sample_ohlcv}
|
||||
\title{Synthetic daily OHLCV sample series}
|
||||
\format{
|
||||
A data frame with 250 rows and 6 columns:
|
||||
\describe{
|
||||
\item{date}{Trading date (\code{Date}).}
|
||||
\item{open}{Opening price.}
|
||||
\item{high}{Session high.}
|
||||
\item{low}{Session low.}
|
||||
\item{close}{Closing price.}
|
||||
\item{volume}{Traded volume.}
|
||||
}
|
||||
}
|
||||
\usage{
|
||||
sample_ohlcv
|
||||
}
|
||||
\description{
|
||||
A deterministic, synthetic daily OHLCV (open / high / low / close / volume)
|
||||
price series for use in the examples, the \emph{Getting started} vignette, and
|
||||
tests. It is a seeded random walk, \strong{not} real market data. Regenerate with
|
||||
\code{data-raw/sample_ohlcv.R}.
|
||||
}
|
||||
\keyword{datasets}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "Getting started with wickra"
|
||||
output: rmarkdown::html_vignette
|
||||
vignette: >
|
||||
%\VignetteIndexEntry{Getting started with wickra}
|
||||
%\VignetteEngine{knitr::rmarkdown}
|
||||
%\VignetteEncoding{UTF-8}
|
||||
---
|
||||
|
||||
```{r setup, include = FALSE}
|
||||
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
|
||||
```
|
||||
|
||||
`wickra` exposes the Wickra technical-analysis library in R over its C ABI hub.
|
||||
Every indicator is a constructor returning a `wickra_indicator`; you feed it data
|
||||
one observation at a time with `update()` (an O(1) streaming step) or run a whole
|
||||
series at once with `batch()`. Both paths share the exact same Rust core, so a
|
||||
live feed and a historical backtest compute identical values.
|
||||
|
||||
```{r}
|
||||
library(wickra)
|
||||
```
|
||||
|
||||
## A sample series
|
||||
|
||||
The package ships a small synthetic OHLCV series, `sample_ohlcv`, for examples
|
||||
(a seeded random walk — not real market data).
|
||||
|
||||
```{r}
|
||||
head(sample_ohlcv)
|
||||
```
|
||||
|
||||
## Batch: a whole series at once
|
||||
|
||||
Scalar indicators run over a vector with `batch()`. Warmup positions are `NA`.
|
||||
|
||||
```{r}
|
||||
sma <- Sma(20)
|
||||
sma_values <- batch(sma, sample_ohlcv$close)
|
||||
tail(sma_values)
|
||||
```
|
||||
|
||||
## Streaming: one observation at a time
|
||||
|
||||
The same indicator fed tick-by-tick with `update()` returns the identical
|
||||
values — an equivalence the test suite enforces for every indicator.
|
||||
|
||||
```{r}
|
||||
sma_stream <- Sma(20)
|
||||
streamed <- vapply(sample_ohlcv$close, function(p) update(sma_stream, p), numeric(1))
|
||||
|
||||
same_warmup <- all(is.na(streamed) == is.na(sma_values))
|
||||
same_values <- all(streamed == sma_values, na.rm = TRUE)
|
||||
c(batch_equals_streaming = same_warmup && same_values)
|
||||
```
|
||||
|
||||
A typical streaming loop reacts to each value as it arrives:
|
||||
|
||||
```{r}
|
||||
rsi <- Rsi(14)
|
||||
overbought_days <- 0L
|
||||
for (price in sample_ohlcv$close) {
|
||||
v <- update(rsi, price) # NA during warmup
|
||||
if (!is.na(v) && v > 70) overbought_days <- overbought_days + 1L
|
||||
}
|
||||
overbought_days
|
||||
```
|
||||
|
||||
## Multi-output indicators
|
||||
|
||||
Indicators with several outputs return a *named* numeric vector (`NA` while
|
||||
warming up). MACD is the classic example — line, signal, and histogram:
|
||||
|
||||
```{r}
|
||||
macd <- MacdIndicator(12, 26, 9)
|
||||
last_macd <- c(macd = NA, signal = NA, histogram = NA)
|
||||
for (price in sample_ohlcv$close) last_macd <- update(macd, price)
|
||||
last_macd
|
||||
```
|
||||
|
||||
## Candle indicators
|
||||
|
||||
Indicators that need the whole bar take the OHLCV fields plus a timestamp:
|
||||
|
||||
```{r}
|
||||
atr <- Atr(14)
|
||||
last_atr <- NA_real_
|
||||
for (i in seq_len(nrow(sample_ohlcv))) {
|
||||
last_atr <- update(
|
||||
atr,
|
||||
sample_ohlcv$open[i], sample_ohlcv$high[i], sample_ohlcv$low[i],
|
||||
sample_ohlcv$close[i], sample_ohlcv$volume[i], i - 1
|
||||
)
|
||||
}
|
||||
last_atr
|
||||
```
|
||||
|
||||
## Resetting state
|
||||
|
||||
`reset()` returns an indicator to its warmup state so the same object can be
|
||||
reused on a fresh series:
|
||||
|
||||
```{r}
|
||||
reset(sma)
|
||||
update(sma, 100) # NaN — warming up again
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- Full indicator catalogue, guides, and per-indicator reference:
|
||||
<https://docs.wickra.org>.
|
||||
- Every constructor (`Sma()`, `Rsi()`, `MacdIndicator()`, `Atr()`, …) is listed
|
||||
in this package's help index.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user