Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eab2649f1c | ||
|
|
f7f947e048 | ||
|
|
01dd08714b | ||
|
|
0fa70c9882 | ||
|
|
debe4523d5 | ||
|
|
a046c441e5 | ||
|
|
f7b91f6fa5 | ||
|
|
5030360a0c | ||
|
|
1dd487fabc | ||
|
|
cee174c0de | ||
|
|
c8e5d8a658 | ||
|
|
dc2e19e762 | ||
|
|
d8212beff6 | ||
|
|
86a595d505 | ||
|
|
9309bf9d60 | ||
|
|
3f05342f72 | ||
|
|
eb4454ab27 | ||
|
|
3093f194a2 | ||
|
|
21c86f348f | ||
|
|
a2ff35f5f9 |
@@ -3,4 +3,3 @@
|
|||||||
# Leave a key empty (e.g. patreon:) to skip a platform.
|
# Leave a key empty (e.g. patreon:) to skip a platform.
|
||||||
|
|
||||||
github: [kingchenc]
|
github: [kingchenc]
|
||||||
custom: ["https://wickra.org/sponsor"]
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@
|
|||||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||||
and their type stubs (If applicable).
|
and their type stubs (If applicable).
|
||||||
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
|
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
|
||||||
and the `README.md` are updated (If applicable). Wiki edits go to a
|
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||||
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
separate repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||||
|
|
||||||
## Notes for reviewers
|
## Notes for reviewers
|
||||||
|
|||||||
@@ -35,8 +35,25 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
|
id: setup_python
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Wait before Python retry
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Python (retry)
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -56,10 +73,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Run cross-library benchmark
|
- name: Run cross-library benchmark
|
||||||
working-directory: bindings/python
|
working-directory: bindings/python
|
||||||
|
# workflow_dispatch inputs are untrusted; pass them through the
|
||||||
|
# environment and quote them rather than interpolating into the shell
|
||||||
|
# command (OpenSSF Scorecard: Dangerous-Workflow).
|
||||||
|
env:
|
||||||
|
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
|
||||||
|
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
|
||||||
run: |
|
run: |
|
||||||
python -m benchmarks.compare_libraries \
|
python -m benchmarks.compare_libraries \
|
||||||
--size ${{ github.event.inputs.size || '20000' }} \
|
--size "$BENCH_SIZE" \
|
||||||
--iterations ${{ github.event.inputs.iterations || '10' }} \
|
--iterations "$BENCH_ITERATIONS" \
|
||||||
--streaming-window 5000 --streaming-iterations 2 \
|
--streaming-window 5000 --streaming-iterations 2 \
|
||||||
| tee benchmark.txt
|
| tee benchmark.txt
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -55,6 +57,95 @@ jobs:
|
|||||||
# streaming.
|
# streaming.
|
||||||
run: cargo build -p wickra-examples --bins
|
run: cargo build -p wickra-examples --bins
|
||||||
|
|
||||||
|
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
|
||||||
|
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
|
||||||
|
# browser-WASM and Python examples otherwise have no build gate, so a broken
|
||||||
|
# edit could land unnoticed. This is a parse-only smoke — actually running the
|
||||||
|
# examples needs the built native binding / wasm module / wheel, which the
|
||||||
|
# binding jobs provide separately.
|
||||||
|
examples-smoke:
|
||||||
|
name: Examples (syntax smoke)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
id: setup_node
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Wait before Node retry
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Node (retry)
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup_python
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Wait before Python retry
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Python (retry)
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Node examples — syntax check
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
count=0
|
||||||
|
for f in examples/node/*.js examples/wasm/*.js; do
|
||||||
|
echo "node --check $f"
|
||||||
|
node --check "$f"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "checked $count Node/WASM .js files"
|
||||||
|
|
||||||
|
- name: WASM demo module scripts — syntax check
|
||||||
|
# The .html demos embed an ES module; extract it and parse-check so a
|
||||||
|
# broken edit to the in-page strategy logic fails CI.
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
count=0
|
||||||
|
for f in examples/wasm/*.html; do
|
||||||
|
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
|
||||||
|
echo "node --check (module of) $f"
|
||||||
|
node --check module-check.mjs
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
rm -f module-check.mjs
|
||||||
|
echo "checked $count WASM .html module scripts"
|
||||||
|
|
||||||
|
- name: Python examples — byte-compile
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
count=0
|
||||||
|
for f in examples/python/*.py; do
|
||||||
|
echo "py_compile $f"
|
||||||
|
python -m py_compile "$f"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "compiled $count Python files"
|
||||||
|
|
||||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||||
@@ -71,17 +162,49 @@ jobs:
|
|||||||
components: clippy
|
components: clippy
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
|
id: setup_python
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Wait before Python retry
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Python (retry)
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
|
id: setup_node
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Wait before Node retry
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Node (retry)
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Clippy (bindings, all targets)
|
- name: Clippy (bindings, all targets)
|
||||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||||
@@ -118,6 +241,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Build on MSRV
|
- name: Build on MSRV
|
||||||
run: cargo build ${{ matrix.packages }} --verbose
|
run: cargo build ${{ matrix.packages }} --verbose
|
||||||
@@ -139,9 +264,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
- name: Install cargo-llvm-cov
|
||||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||||
with:
|
with:
|
||||||
tool: cargo-llvm-cov
|
tool: cargo-llvm-cov
|
||||||
|
|
||||||
@@ -191,6 +319,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
with:
|
with:
|
||||||
workspaces: fuzz
|
workspaces: fuzz
|
||||||
|
|
||||||
@@ -203,6 +333,7 @@ jobs:
|
|||||||
# never gets off the ground. The prebuilt binary avoids the entire
|
# never gets off the ground. The prebuilt binary avoids the entire
|
||||||
# transitive-dep compile.
|
# transitive-dep compile.
|
||||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||||
with:
|
with:
|
||||||
tool: cargo-fuzz
|
tool: cargo-fuzz
|
||||||
|
|
||||||
@@ -242,6 +373,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
# setup-python downloads the interpreter from the Actions tool cache /
|
# setup-python downloads the interpreter from the Actions tool cache /
|
||||||
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
||||||
@@ -304,6 +437,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Install wasm-pack
|
- name: Install wasm-pack
|
||||||
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
|
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
|
||||||
@@ -314,6 +449,7 @@ jobs:
|
|||||||
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||||
# release, which has `--features` as a top-level flag (since 0.12).
|
# 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@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||||
with:
|
with:
|
||||||
tool: wasm-pack
|
tool: wasm-pack
|
||||||
|
|
||||||
@@ -345,6 +481,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
||||||
# Windows runners with "Attempting to download 18..." followed by a
|
# Windows runners with "Attempting to download 18..." followed by a
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Static analysis security testing (findings P13.x). Analyses the Rust core and
|
||||||
|
# the Python / JavaScript binding surfaces with GitHub's CodeQL engine. Results
|
||||||
|
# appear under Security → Code scanning. `build-mode: none` analyses source
|
||||||
|
# directly — no compilation step — for every language here.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '31 3 * * 0' # Sundays 03:31 UTC
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload CodeQL results to code-scanning
|
||||||
|
packages: read
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: rust
|
||||||
|
build-mode: none
|
||||||
|
- language: python
|
||||||
|
build-mode: none
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -26,6 +26,8 @@ jobs:
|
|||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
# Idempotent publishing: if the version is already on crates.io we
|
# Idempotent publishing: if the version is already on crates.io we
|
||||||
# treat that as success so re-runs of the workflow don't fail.
|
# treat that as success so re-runs of the workflow don't fail.
|
||||||
@@ -85,6 +87,7 @@ jobs:
|
|||||||
# re-resolving Cargo.lock.
|
# re-resolving Cargo.lock.
|
||||||
- name: Install cargo-cyclonedx
|
- name: Install cargo-cyclonedx
|
||||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||||
with:
|
with:
|
||||||
tool: cargo-cyclonedx
|
tool: cargo-cyclonedx
|
||||||
|
|
||||||
@@ -131,7 +134,21 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- name: Set up Python
|
||||||
|
id: setup_python
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Wait before Python retry
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
- name: Set up Python (retry)
|
||||||
|
if: steps.setup_python.outcome == 'failure'
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
- name: Sync root README into bindings/python so it ships with the wheel
|
- name: Sync root README into bindings/python so it ships with the wheel
|
||||||
@@ -206,7 +223,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- name: Set up Node
|
||||||
|
id: setup_node
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Wait before Node retry
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Node (retry)
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
|
||||||
@@ -215,6 +248,8 @@ jobs:
|
|||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||||
|
timeout-minutes: 6
|
||||||
|
|
||||||
- name: Install Node deps
|
- name: Install Node deps
|
||||||
working-directory: bindings/node
|
working-directory: bindings/node
|
||||||
@@ -247,7 +282,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- name: Set up Node
|
||||||
|
id: setup_node
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
- name: Wait before Node retry
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Node (retry)
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
@@ -397,7 +449,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- name: Set up Node
|
||||||
|
id: setup_node
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
- name: Wait before Node retry
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
- name: Set up Node (retry)
|
||||||
|
if: steps.setup_node.outcome == 'failure'
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
@@ -410,6 +479,7 @@ jobs:
|
|||||||
# See the matching note in ci.yml: jetli's default installs an old
|
# See the matching note in ci.yml: jetli's default installs an old
|
||||||
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||||
with:
|
with:
|
||||||
tool: wasm-pack
|
tool: wasm-pack
|
||||||
|
|
||||||
@@ -428,7 +498,7 @@ jobs:
|
|||||||
node -e "
|
node -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
||||||
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
pkg.author = 'kingchenc <support@wickra.org>';
|
||||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||||
@@ -541,4 +611,41 @@ jobs:
|
|||||||
|
|
||||||
### Auto-generated changelog
|
### Auto-generated changelog
|
||||||
|
|
||||||
See below; GitHub computes it from the commits since the previous tag.
|
See below; GitHub computes it from the commits since the previous tag.
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Build provenance attestations (findings P13.2)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
attestations:
|
||||||
|
name: Attest build provenance
|
||||||
|
needs: [cargo-publish, python-wheels, python-sdist]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Signed SLSA build-provenance attestations for the published crates and
|
||||||
|
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
|
||||||
|
# from `npm publish --provenance`, so they are covered there. This job is
|
||||||
|
# isolated and runs *after* the publishes on the exact uploaded bytes, so a
|
||||||
|
# failure here can never block or corrupt a publish (same isolation that the
|
||||||
|
# SBOM step lacked before #79).
|
||||||
|
permissions:
|
||||||
|
id-token: write # OIDC for keyless Sigstore signing
|
||||||
|
attestations: write # write the attestations to this repo
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Download crate files
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: crate-files
|
||||||
|
path: artifacts/crates
|
||||||
|
- name: Download wheels + sdist
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
pattern: wheels-*
|
||||||
|
path: artifacts/python
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Attest build provenance
|
||||||
|
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
artifacts/crates/*.crate
|
||||||
|
artifacts/python/*.whl
|
||||||
|
artifacts/python/*.tar.gz
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: OpenSSF Scorecard
|
||||||
|
|
||||||
|
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
|
||||||
|
# schedule, on branch-protection changes, and on push to main. `publish_results`
|
||||||
|
# uploads the score to the public OpenSSF API so the README badge resolves, and
|
||||||
|
# the SARIF is surfaced under the repo's Security → Code scanning tab.
|
||||||
|
on:
|
||||||
|
branch_protection_rule:
|
||||||
|
schedule:
|
||||||
|
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Read-only by default; the analysis job widens to exactly what it needs.
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload the SARIF result to code-scanning
|
||||||
|
id-token: write # OIDC token to publish results to the OpenSSF API
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run Scorecard analysis
|
||||||
|
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
# Publish to the public OpenSSF endpoint that backs the README badge.
|
||||||
|
publish_results: true
|
||||||
|
|
||||||
|
- name: Upload SARIF artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Upload SARIF to code-scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
@@ -7,9 +7,29 @@ name: Sync indicator count
|
|||||||
#
|
#
|
||||||
# 1. README.md prose — synced on PR branches (this workflow)
|
# 1. README.md prose — synced on PR branches (this workflow)
|
||||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||||
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
|
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||||
# — synced on push to main / v* tag
|
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||||
# 4. site/index.md (local-only marketing site, not synced from CI)
|
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||||
|
# .vitepress/config.ts / public/hero.svg) — push to main / v* tag*
|
||||||
|
# 5. org profile README count (wickra-lib/.github, profile/README.md)
|
||||||
|
# — synced on push to main / v* tag*
|
||||||
|
# 6. org description ("… N indicators, install-free.")
|
||||||
|
# — synced on push to main / v* tag*
|
||||||
|
# 7. docs site published version (wickra-lib/wickra-docs: the
|
||||||
|
# "Published versions" table in overview.md + the Rust quickstart prose)
|
||||||
|
# — synced on v* tag only*
|
||||||
|
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
|
||||||
|
# nav version label, and the wickra-wasm dep) — synced on v* tag only*
|
||||||
|
#
|
||||||
|
# *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
|
||||||
|
# wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
|
||||||
|
# need write on wickra-lib/.github and admin:org for the org-description PATCH.
|
||||||
|
# Until that scope is granted these steps emit a ::warning:: and soft-skip —
|
||||||
|
# they never fail the run. The repo "About" homepage URL is also enforced in
|
||||||
|
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
|
||||||
|
#
|
||||||
|
# Note: surface 7 carries the release *version*, not the indicator count, so
|
||||||
|
# it is driven by the v* tag (which is the version) rather than the count.
|
||||||
#
|
#
|
||||||
# We count public types (not `mod xxx;` lines) because some modules export
|
# We count public types (not `mod xxx;` lines) because some modules export
|
||||||
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
|
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
|
||||||
@@ -54,11 +74,20 @@ jobs:
|
|||||||
# falls back to a hard failure when push isn't possible.
|
# falls back to a hard failure when push isn't possible.
|
||||||
- name: Determine if push to PR head is possible
|
- name: Determine if push to PR head is possible
|
||||||
id: ctx
|
id: ctx
|
||||||
|
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
|
||||||
|
# controlled on fork PRs) are passed through the environment, never
|
||||||
|
# interpolated straight into the shell, so a crafted branch name cannot
|
||||||
|
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||||
|
env:
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
BASE_REPO: ${{ github.repository }}
|
||||||
|
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
|
||||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
|
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||||
@@ -72,7 +101,7 @@ jobs:
|
|||||||
# any fix-up commit we make goes onto the PR branch itself. On
|
# any fix-up commit we make goes onto the PR branch itself. On
|
||||||
# push events we check out the default ref. fetch-depth: 0 lets
|
# push events we check out the default ref. fetch-depth: 0 lets
|
||||||
# us push back without "shallow update not allowed".
|
# us push back without "shallow update not allowed".
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||||
@@ -134,12 +163,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit & push counter fix to PR head
|
- name: Commit & push counter fix to PR head
|
||||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||||
|
# head_ref still carries the (untrusted) PR branch name forwarded by the
|
||||||
|
# ctx step; pass it through the environment so the push refspec cannot be
|
||||||
|
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||||
|
env:
|
||||||
|
COUNT: ${{ steps.count.outputs.count }}
|
||||||
|
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "wickra-bot"
|
git config user.name "wickra-bot"
|
||||||
git config user.email "wickra-bot@users.noreply.github.com"
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
git add README.md
|
git add README.md
|
||||||
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
|
git commit -m "chore: sync indicator count to ${COUNT}"
|
||||||
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
|
git push origin "HEAD:${HEAD_REF}"
|
||||||
|
|
||||||
# ----- main / tag flow ------------------------------------------
|
# ----- main / tag flow ------------------------------------------
|
||||||
#
|
#
|
||||||
@@ -150,36 +185,240 @@ jobs:
|
|||||||
# wiki repo (separate repo, no main history pollution). README is
|
# wiki repo (separate repo, no main history pollution). README is
|
||||||
# not touched on main any more.
|
# not touched on main any more.
|
||||||
|
|
||||||
- name: Update GitHub About description
|
- name: Update GitHub About (description + homepage)
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
n="${{ steps.count.outputs.count }}"
|
n="${{ steps.count.outputs.count }}"
|
||||||
|
# Canonical homepage — the docs site (P8.3). This is enforced on every
|
||||||
|
# run, so it must only point at docs.wickra.org once that domain is
|
||||||
|
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||||
|
# gated on the domain resolving, otherwise the About link would 404.
|
||||||
|
homepage="https://docs.wickra.org"
|
||||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||||
|
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||||
|
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||||
|
# Same Administration-write permission as --description (no extra scope).
|
||||||
|
gh repo edit --homepage "$homepage"
|
||||||
current=$(gh repo view --json description -q .description)
|
current=$(gh repo view --json description -q .description)
|
||||||
if [ "$current" = "$desc" ]; then
|
if [ "$current" = "$desc" ]; then
|
||||||
echo "About unchanged."
|
echo "About description unchanged; homepage enforced."
|
||||||
else
|
else
|
||||||
gh repo edit --description "$desc"
|
gh repo edit --description "$desc"
|
||||||
echo "About updated."
|
echo "About description + homepage updated."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Sync Wiki
|
# Counter sync target moved from the retired GitHub wiki to the docs site
|
||||||
|
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
|
||||||
|
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
|
||||||
|
# org steps so a token/scope gap never fails the run. Uses its own clone
|
||||||
|
# dir (docs-count) so it cannot collide with the tag-only version step
|
||||||
|
# below, which clones the same repo into `docs`.
|
||||||
|
- name: Sync docs indicator count (wickra-docs)
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
n="${{ steps.count.outputs.count }}"
|
n="${{ steps.count.outputs.count }}"
|
||||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
|
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
|
||||||
cd wiki
|
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
|
||||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
|
exit 0
|
||||||
|
fi
|
||||||
|
cd docs-count
|
||||||
|
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
|
||||||
if git diff --quiet; then
|
if git diff --quiet; then
|
||||||
echo "Wiki unchanged."
|
echo "Docs indicator count unchanged."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git config user.name "wickra-bot"
|
git config user.name "wickra-bot"
|
||||||
git config user.email "wickra-bot@users.noreply.github.com"
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
git add Home.md FAQ.md Streaming-vs-Batch.md
|
git add index.md overview.md Indicators-Overview.md
|
||||||
git commit -m "chore: sync indicator count to ${n}"
|
git commit -m "chore: sync indicator count to ${n}"
|
||||||
git push
|
if ! git push 2>/dev/null; then
|
||||||
|
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||||
|
else
|
||||||
|
echo "Docs indicator count synced to ${n}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- org-profile sync (soft-skip until PAT scope lands) -------
|
||||||
|
#
|
||||||
|
# These two steps keep the org page (github.com/wickra-lib) in sync
|
||||||
|
# with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
|
||||||
|
# syncs do not: write on wickra-lib/.github, and admin:org for the org
|
||||||
|
# description PATCH. Both are written to soft-skip with a ::warning::
|
||||||
|
# (never fail the run) so this workflow stays green before the scope is
|
||||||
|
# granted — once it is, they start syncing with no further code change.
|
||||||
|
|
||||||
|
- name: Sync org profile README count
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
n="${{ steps.count.outputs.count }}"
|
||||||
|
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
|
||||||
|
echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd orgprofile
|
||||||
|
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "Org profile README count unchanged."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "wickra-bot"
|
||||||
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
|
git add profile/README.md
|
||||||
|
git commit -m "chore: sync indicator count to ${n}"
|
||||||
|
if ! git push 2>/dev/null; then
|
||||||
|
echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||||
|
else
|
||||||
|
echo "Org profile README synced to ${n}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Sync org description
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
n="${{ steps.count.outputs.count }}"
|
||||||
|
org="wickra-lib"
|
||||||
|
# Reading the org description is public; the PATCH needs admin:org.
|
||||||
|
current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
|
||||||
|
if [ -z "$current" ]; then
|
||||||
|
echo "::warning::could not read org description (network/PAT?). Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
|
||||||
|
if [ "$current" = "$updated" ]; then
|
||||||
|
echo "Org description count unchanged."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
|
||||||
|
echo "Org description synced to ${n}."
|
||||||
|
else
|
||||||
|
echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
|
||||||
|
#
|
||||||
|
# Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
|
||||||
|
# version in the "Published versions" table (overview.md) and the Rust
|
||||||
|
# quickstart prose. Unlike the indicator count these change only on a
|
||||||
|
# release, so this step runs on v* tag pushes only and takes the version
|
||||||
|
# straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
|
||||||
|
# wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
|
||||||
|
# soft-skips with a ::warning:: and never fails the run; once granted, every
|
||||||
|
# release self-heals the docs version with no code change (replaces the old
|
||||||
|
# manual P0.5 post-release wiki bump).
|
||||||
|
- name: Sync docs version (wickra-docs)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF#refs/tags/v}"
|
||||||
|
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs 2>/dev/null; then
|
||||||
|
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd docs
|
||||||
|
# Published-versions table rows (crates.io / PyPI / npm): replace only the
|
||||||
|
# version number, leaving the trailing padding + pipe intact. The '.' in
|
||||||
|
# the quickstart pattern matches the literal backtick around the version
|
||||||
|
# without needing a backtick in this shell string. Historical "since
|
||||||
|
# X.Y.Z" references contain no such anchor and are never matched.
|
||||||
|
sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
|
||||||
|
sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "Docs version already at ${version}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "wickra-bot"
|
||||||
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
|
git add overview.md Quickstart-Rust.md
|
||||||
|
git commit -m "chore: sync published version to ${version}"
|
||||||
|
if ! git push 2>/dev/null; then
|
||||||
|
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||||
|
else
|
||||||
|
echo "Docs version synced to ${version}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- webpage (marketing site) self-update (findings P12.1) ------------
|
||||||
|
#
|
||||||
|
# The marketing site (wickra-lib/webpage) carries the same indicator count
|
||||||
|
# and published version as the docs. Mirrors the docs steps above: the
|
||||||
|
# count syncs on push-to-main + tag, the version syncs on v* tags only.
|
||||||
|
# Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
|
||||||
|
# a tag run. Soft-skips with a ::warning:: if the token can't reach the
|
||||||
|
# repo, so the run never fails.
|
||||||
|
- name: Sync webpage indicator count (wickra-lib/webpage)
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
n="${{ steps.count.outputs.count }}"
|
||||||
|
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
|
||||||
|
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd webpage-count
|
||||||
|
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts public/hero.svg
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "Webpage indicator count unchanged."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "wickra-bot"
|
||||||
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
|
git add index.md .vitepress/config.ts public/hero.svg
|
||||||
|
git commit -m "chore: sync indicator count to ${n}"
|
||||||
|
if ! git push 2>/dev/null; then
|
||||||
|
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||||
|
else
|
||||||
|
echo "Webpage indicator count synced to ${n}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Sync webpage version (wickra-lib/webpage)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF#refs/tags/v}"
|
||||||
|
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
|
||||||
|
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd webpage-ver
|
||||||
|
# api/*.md "Latest" lines, the nav version label, and the wickra-wasm
|
||||||
|
# dep pin. The '.' anchors match the backtick / quote / caret without a
|
||||||
|
# literal in this shell string; historical "Since X.Y.Z" prose has no
|
||||||
|
# such anchor and is never matched.
|
||||||
|
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
|
||||||
|
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
|
||||||
|
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "Webpage version already at ${version}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "wickra-bot"
|
||||||
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
|
git add api/*.md .vitepress/config.ts package.json
|
||||||
|
git commit -m "chore: sync published version to ${version}"
|
||||||
|
if ! git push 2>/dev/null; then
|
||||||
|
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||||
|
else
|
||||||
|
echo "Webpage version synced to ${version}."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ jobs:
|
|||||||
name: metadata audit
|
name: metadata audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Audit repo-metadata.toml drift
|
- name: Audit repo-metadata.toml drift
|
||||||
|
|||||||
+7
-3
@@ -44,10 +44,14 @@ tarpaulin-report.html
|
|||||||
# Node binding artifacts
|
# Node binding artifacts
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
bindings/node/*.node
|
bindings/node/*.node
|
||||||
bindings/node/index.d.ts
|
|
||||||
bindings/node/npm-debug.log*
|
bindings/node/npm-debug.log*
|
||||||
# package-lock.json is committed (under bindings/node/) so contributors
|
# index.js + index.d.ts are generated by `napi build` but committed (a matched
|
||||||
# get reproducible npm installs. Top-level lockfiles still aren't expected.
|
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
|
||||||
|
# regenerating both when a binding's public API changes.
|
||||||
|
# package-lock.json is committed for the tracked Node packages — bindings/node/
|
||||||
|
# and examples/node/ — so contributors get reproducible npm installs. There is
|
||||||
|
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
|
||||||
|
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
|
||||||
|
|
||||||
# WASM build output
|
# WASM build output
|
||||||
bindings/wasm/pkg/
|
bindings/wasm/pkg/
|
||||||
|
|||||||
+39
-1
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Build-provenance attestations for release artifacts.** The release workflow
|
||||||
|
now emits signed SLSA build-provenance attestations for the published crates
|
||||||
|
and Python wheels/sdist (`actions/attest-build-provenance`); npm packages
|
||||||
|
carry inline Sigstore provenance from `npm publish --provenance`. Every
|
||||||
|
published artifact is cryptographically traceable to this repository's release
|
||||||
|
workflow run.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **CodeQL static analysis and OpenSSF Scorecard run in CI.** CodeQL (Rust,
|
||||||
|
Python, JavaScript) and the OpenSSF Scorecard workflow now run on every push;
|
||||||
|
results appear under Security → Code scanning and a public Scorecard badge is
|
||||||
|
shown in the README.
|
||||||
|
- **CI workflows hardened against script injection.** Untrusted event contexts
|
||||||
|
(PR branch names, `workflow_dispatch` inputs) are passed through the step
|
||||||
|
environment instead of being interpolated directly into shell commands.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Node binding: invalid indicator periods now throw instead of being silently
|
||||||
|
clamped.** The scalar-indicator constructors previously clamped `period = 0`
|
||||||
|
to `1`; every Node constructor now propagates the core's validation error
|
||||||
|
(e.g. `period must be greater than zero`), matching the Python and WASM
|
||||||
|
bindings and the Rust core. Constructing with a valid period is unaffected.
|
||||||
|
- **Binding package READMEs are now per-ecosystem.** The Python, Node.js, and
|
||||||
|
WebAssembly READMEs were byte-identical 314-line copies of the workspace
|
||||||
|
README and had drifted out of sync (stale indicator count, Python snippets
|
||||||
|
shown on the Node and WASM package pages). Each is now a focused landing page
|
||||||
|
with the correct install command, a language-correct quick-start snippet, and
|
||||||
|
links to the canonical documentation — removing the manual three-way sync
|
||||||
|
burden. No code or API changes.
|
||||||
|
- **CONTRIBUTING now states the correct MSRV (1.86 workspace / 1.88
|
||||||
|
`bindings/node`)** and documents that these are the dependency-forced floors,
|
||||||
|
kept minimal on purpose. The previous text claimed 1.75 / 1.77, which the
|
||||||
|
`msrv` CI job has enforced against since the criterion and napi-build bumps.
|
||||||
|
|
||||||
## [0.3.1] - 2026-05-30
|
## [0.3.1] - 2026-05-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -832,7 +869,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
optional Binance live feed.
|
optional Binance live feed.
|
||||||
- Bindings for Python, Node.js, and WebAssembly.
|
- Bindings for Python, Node.js, and WebAssembly.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.3.1...HEAD
|
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.0...HEAD
|
||||||
|
[0.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
|
||||||
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
||||||
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
||||||
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ message: >-
|
|||||||
type: software
|
type: software
|
||||||
authors:
|
authors:
|
||||||
- alias: kingchenc
|
- alias: kingchenc
|
||||||
email: wickra.lib@gmail.com
|
email: support@wickra.org
|
||||||
repository-code: "https://github.com/wickra-lib/wickra"
|
repository-code: "https://github.com/wickra-lib/wickra"
|
||||||
url: "https://wickra.org"
|
url: "https://wickra.org"
|
||||||
abstract: >-
|
abstract: >-
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ project in public spaces.
|
|||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||||
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
|
at **support@wickra.org**. All reports will be reviewed and investigated
|
||||||
promptly and fairly, and the maintainer will respect the privacy and security
|
promptly and fairly, and the maintainer will respect the privacy and security
|
||||||
of the reporter.
|
of the reporter.
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -35,8 +35,13 @@ cargo test --workspace
|
|||||||
cargo test -p wickra-data --features live-binance
|
cargo test -p wickra-data --features live-binance
|
||||||
```
|
```
|
||||||
|
|
||||||
The minimum supported Rust version is **1.75** for the workspace crates and
|
The minimum supported Rust version is **1.86** for the workspace crates and
|
||||||
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
|
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
|
||||||
|
not chosen freely — they are the lowest versions our dependencies allow
|
||||||
|
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
|
||||||
|
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
|
||||||
|
the library builds for the widest possible audience; please don't raise it
|
||||||
|
without a dependency that actually requires it.
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
|
||||||
@@ -63,6 +68,21 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
|||||||
wasm-pack test --node bindings/wasm
|
wasm-pack test --node bindings/wasm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Lockfile policy
|
||||||
|
|
||||||
|
| Component | Lockfile | Tracked? | Why |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
|
||||||
|
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
|
||||||
|
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
|
||||||
|
| `bindings/python` | — | n/a (no lockfile) | PyO3 convention: the Python package has no Python runtime dependencies of its own, and its native code is already pinned through the workspace `Cargo.lock`. CI installs build/test tooling (`maturin`, `pytest`, `numpy`, `hypothesis`) directly via `pip`. |
|
||||||
|
| `fuzz` | `fuzz/Cargo.lock` | **no** (ignored) | `fuzz/` is a detached crate; `cargo-fuzz init` generates `fuzz/.gitignore` which ignores its `Cargo.lock`. The fuzz smoke job resolves dependencies fresh, so the lock is not needed for reproducibility here. |
|
||||||
|
| `site` (marketing) | `package-lock.json` | **no** (ghost-ignored) | The VitePress site is a local-only project excluded via `.git/info/exclude`; its lockfile stays local. |
|
||||||
|
|
||||||
|
When adding a new committed Node package, commit its `package-lock.json` too and
|
||||||
|
remove any matching ignore rule. Do **not** add a top-level `package-lock.json` —
|
||||||
|
the repository root is not an npm package.
|
||||||
|
|
||||||
## Standards for a change
|
## Standards for a change
|
||||||
|
|
||||||
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
|
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
|
||||||
@@ -76,9 +96,9 @@ wasm-pack test --node bindings/wasm
|
|||||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||||
- **Docs.** Update the relevant page on the
|
- **Docs.** Update the relevant page on the
|
||||||
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
|
[documentation site](https://docs.wickra.org) and the
|
||||||
`README.md` when behaviour or the public API changes. The Wiki lives in
|
`README.md` when behaviour or the public API changes. The docs live in
|
||||||
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||||
|
|
||||||
## Commit and pull-request workflow
|
## Commit and pull-request workflow
|
||||||
|
|||||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"criterion",
|
"criterion",
|
||||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-core"
|
name = "wickra-core"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"proptest",
|
"proptest",
|
||||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-data"
|
name = "wickra-data"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-node"
|
name = "wickra-node"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-python"
|
name = "wickra-python"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-wasm"
|
name = "wickra-wasm"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+3
-3
@@ -12,8 +12,8 @@ members = [
|
|||||||
exclude = ["fuzz"]
|
exclude = ["fuzz"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
authors = ["kingchenc <wickra.lib@gmail.com>"]
|
authors = ["kingchenc <support@wickra.org>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "PolyForm-Noncommercial-1.0.0"
|
license = "PolyForm-Noncommercial-1.0.0"
|
||||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
|||||||
categories = ["finance", "mathematics", "science"]
|
categories = ["finance", "mathematics", "science"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wickra-core = { path = "crates/wickra-core", version = "0.3.1" }
|
wickra-core = { path = "crates/wickra-core", version = "0.4.0" }
|
||||||
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
# Wickra
|
<p align="center">
|
||||||
|
<a href="https://wickra.org"><img src="https://wickra.org/og-banner.webp" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||||
|
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||||
[](https://crates.io/crates/wickra)
|
[](https://crates.io/crates/wickra)
|
||||||
[](https://pypi.org/project/wickra/)
|
[](https://pypi.org/project/wickra/)
|
||||||
[](https://www.npmjs.com/package/wickra)
|
[](https://www.npmjs.com/package/wickra)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||||
|
[](https://github.com/wickra-lib/wickra/attestations)
|
||||||
|
|
||||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ Report it privately through one of:
|
|||||||
|
|
||||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||||
("Report a vulnerability" under the repository's *Security* tab), or
|
("Report a vulnerability" under the repository's *Security* tab), or
|
||||||
- email to **wickra.lib@gmail.com** with a subject line starting with
|
- email to **support@wickra.org** with a subject line starting with
|
||||||
`[wickra security]`.
|
`[wickra security]`.
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
|
|||||||
+45
-286
@@ -1,314 +1,73 @@
|
|||||||
# Wickra
|
# Wickra — Node.js
|
||||||
|
|
||||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||||
[](https://crates.io/crates/wickra)
|
|
||||||
[](https://pypi.org/project/wickra/)
|
|
||||||
[](https://www.npmjs.com/package/wickra)
|
[](https://www.npmjs.com/package/wickra)
|
||||||
[](LICENSE)
|
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||||
|
|
||||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
**Streaming-first technical indicators for Node.js. `npm install wickra` —
|
||||||
|
prebuilt native binary, no system dependencies.**
|
||||||
|
|
||||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||||
machine that updates in O(1) per new data point, so live trading bots and
|
streaming state machine, so live trading bots and historical backtests share
|
||||||
historical backtests share the exact same implementation.
|
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||||
|
it exposes 200+ streaming-first indicators across sixteen families.
|
||||||
|
|
||||||
```python
|
## Install
|
||||||
import numpy as np
|
|
||||||
import wickra as ta
|
|
||||||
|
|
||||||
# Batch: classic TA-Lib-style usage
|
|
||||||
prices = np.linspace(100, 200, 1000)
|
|
||||||
rsi = ta.RSI(14)
|
|
||||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
|
||||||
|
|
||||||
# Streaming: same indicator, fed tick by tick
|
|
||||||
rsi = ta.RSI(14)
|
|
||||||
for price in live_feed:
|
|
||||||
value = rsi.update(price) # O(1) — no recomputation over history
|
|
||||||
if value is not None and value > 70:
|
|
||||||
print("overbought")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why Wickra exists
|
|
||||||
|
|
||||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
|
||||||
talipp, tulipy — and every one of them shares the same blind spot:
|
|
||||||
|
|
||||||
| Library | Install pain | Streaming | Multi-language | Active |
|
|
||||||
|------------------------|-----------------|-----------|----------------|--------|
|
|
||||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
|
||||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
|
||||||
| pandas-ta | clean | no | no | slow |
|
|
||||||
| finta | clean | no | no | stale |
|
|
||||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
|
||||||
| talipp | clean | yes | no | yes |
|
|
||||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
|
||||||
| ooples (C#) | clean | no | C# only | yes |
|
|
||||||
|
|
||||||
Wickra is the only library that combines all of: clean install, streaming,
|
|
||||||
multi-language reach, and active maintenance.
|
|
||||||
|
|
||||||
## Benchmark: how much faster is "streaming-first"?
|
|
||||||
|
|
||||||
The numbers below were measured on a single developer workstation and are not
|
|
||||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
|
||||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
|
||||||
speedups** between libraries on identical input, not as a universal
|
|
||||||
performance contract.
|
|
||||||
|
|
||||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
|
||||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
|
||||||
Python 3.12, Node 20.
|
|
||||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
|
||||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
|
||||||
installed peer library and runs them on the same generated inputs as
|
|
||||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
|
||||||
push and uploads the raw report as a build artefact.
|
|
||||||
|
|
||||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
|
||||||
streaming gap widens linearly with how much history a batch-only library has
|
|
||||||
to recompute on every tick.
|
|
||||||
|
|
||||||
### Batch — single full pass over a 20 000-bar series
|
|
||||||
|
|
||||||
Reading the table: each cell shows that library's runtime, plus how many times
|
|
||||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | finta | talipp |
|
|
||||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
|
||||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
|
||||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
|
||||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
|
||||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
|
||||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
|
||||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
|
||||||
|
|
||||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
|
||||||
|
|
||||||
A batch-only library has to re-run its full indicator over the entire history on
|
|
||||||
every new tick; Wickra updates state in O(1).
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
|
||||||
|-----------|---------------------|---------------------------|
|
|
||||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
|
||||||
|
|
||||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
|
||||||
> cleanly on Windows without C build tooling — which is precisely the install
|
|
||||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
|
||||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
|
||||||
> them in your environment to see those rows light up too.
|
|
||||||
|
|
||||||
Run the suite yourself:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e bindings/python[bench]
|
npm install wickra
|
||||||
python -m benchmarks.compare_libraries
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Indicators
|
The native addon ships as a prebuilt binary per platform (Linux, macOS,
|
||||||
|
Windows — x64 and arm64), selected automatically through optional
|
||||||
|
dependencies. There is nothing to compile.
|
||||||
|
|
||||||
214 streaming-first indicators across sixteen families. Every one passes the
|
## Quick start
|
||||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
|
||||||
semantics tests.
|
|
||||||
|
|
||||||
| Family | Indicators |
|
```js
|
||||||
|--------|-----------|
|
const wickra = require('wickra');
|
||||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
|
||||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
|
||||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
|
||||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
|
||||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
|
||||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
|
||||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
|
||||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
|
||||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
|
||||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
|
||||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
|
||||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
|
||||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
|
||||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
|
||||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
|
||||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
|
||||||
|
|
||||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
// Batch: run an indicator over a whole array.
|
||||||
inherit it automatically.
|
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
|
||||||
|
const values = new wickra.RSI(14).batch(prices); // null during warmup
|
||||||
|
|
||||||
## Languages
|
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||||
|
const rsi = new wickra.RSI(14);
|
||||||
| Binding | Install | Example |
|
for (const price of liveFeed) {
|
||||||
|-------------------|-----------------------------------------------|---------|
|
const value = rsi.update(price); // no recomputation over history
|
||||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
if (value !== null && value > 70) {
|
||||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
console.log('overbought');
|
||||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
}
|
||||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
|
||||||
|
|
||||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
|
||||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
|
||||||
|
|
||||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
|
||||||
memory-safe implementation.
|
|
||||||
|
|
||||||
## Rust API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
|
||||||
|
|
||||||
// Streaming or batch — same trait, same code.
|
|
||||||
let mut sma = Sma::new(14)?;
|
|
||||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
|
||||||
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
for price in live_feed {
|
|
||||||
if let Some(v) = rsi.update(price) {
|
|
||||||
println!("RSI = {v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose indicators: RSI(7) on top of EMA(14).
|
|
||||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
|
||||||
chain.update(price);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Live data sources
|
|
||||||
|
|
||||||
`wickra-data` (separate crate, opt-in) ships:
|
|
||||||
|
|
||||||
- A streaming OHLCV **CSV reader**.
|
|
||||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
|
||||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
|
||||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, Rsi};
|
|
||||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
|
||||||
|
|
||||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
while let Some(event) = stream.next_event().await? {
|
|
||||||
if event.is_closed {
|
|
||||||
if let Some(v) = rsi.update(event.candle.close) {
|
|
||||||
println!("RSI = {v:.2}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A Python live-trading example using the public `websockets` package lives at
|
`batch(prices)` and feeding the same prices through `update()` produce
|
||||||
`examples/python/live_trading.py`.
|
identical values — the equivalence is enforced by the test suite.
|
||||||
|
|
||||||
## Project layout
|
## Documentation
|
||||||
|
|
||||||
```
|
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||||
wickra/
|
the main repository and documentation site:
|
||||||
├── crates/
|
|
||||||
│ ├── wickra-core/ core engine + all 71 indicators
|
|
||||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
|
||||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
|
||||||
├── bindings/
|
|
||||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
|
||||||
│ ├── node/ napi-rs (publishes on npm)
|
|
||||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
|
||||||
├── examples/ examples/README.md indexes every language
|
|
||||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
|
||||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
|
||||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
|
||||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
|
||||||
│ └── wasm/ browser demo for `wickra-wasm`
|
|
||||||
└── .github/workflows/ CI and release pipelines
|
|
||||||
```
|
|
||||||
|
|
||||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||||
`benches/` directory.
|
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||||
|
|
||||||
## Building everything from source
|
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||||
|
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||||
|
|
||||||
```bash
|
## Disclaimer
|
||||||
# Rust core + tests
|
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo bench -p wickra
|
|
||||||
|
|
||||||
# Python binding (requires Rust toolchain + maturin)
|
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||||
cd bindings/python
|
are deterministic transforms of the input data — they are not financial advice
|
||||||
maturin develop --release
|
and do not predict the market. Any use in a live trading context is at your own
|
||||||
pytest
|
risk. The library is provided **as is**, without warranty of any kind.
|
||||||
|
|
||||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
|
||||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
|
||||||
|
|
||||||
# Node binding (requires @napi-rs/cli)
|
|
||||||
cd bindings/node && npm install && npm run build && npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Every layer is covered; run the suites with the commands in
|
|
||||||
[Building everything from source](#building-everything-from-source).
|
|
||||||
|
|
||||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
|
||||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
|
||||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
|
||||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
|
||||||
resampler, and the Binance payload parser.
|
|
||||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
|
||||||
equivalence, reference values, lifecycle, input validation, and
|
|
||||||
dict/tuple candle inputs.
|
|
||||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
|
||||||
values across all indicators.
|
|
||||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
|
||||||
and reference values.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
|
||||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
|
||||||
|
|
||||||
A short orientation for first-time contributors:
|
|
||||||
|
|
||||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
|
||||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
|
||||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
|
||||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
|
||||||
proptest. The four bindings inherit your indicator automatically once
|
|
||||||
you expose it in the language wrappers.
|
|
||||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
|
||||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
|
||||||
most regressions; please don't disable them.
|
|
||||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
|
||||||
its own tests; please keep the `batch == streaming` invariant.
|
|
||||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
|
||||||
are CI gates; running them locally before pushing keeps reviews short.
|
|
||||||
|
|
||||||
For larger architectural changes, open an issue first so we can sketch the
|
|
||||||
shape together before you invest the time.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||||
|
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
thing not allowed is commercial sale of the software or of services built
|
||||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
|
||||||
commercial sale of the software or of services built around it. If you want to
|
|
||||||
use Wickra commercially, get in touch about a license.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
|
||||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
|
||||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Completeness contract for the Wickra Node bindings: every exported indicator
|
||||||
|
// class must expose the full streaming + batch + lifecycle interface. This
|
||||||
|
// catches a new indicator being wired into the binding without the standard
|
||||||
|
// methods (or an export silently disappearing) without needing a hand-written
|
||||||
|
// test per indicator.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const wickra = require('..');
|
||||||
|
|
||||||
|
// An "indicator class" is an exported constructor whose prototype carries the
|
||||||
|
// streaming `update` method. This excludes `version` (a plain function) and any
|
||||||
|
// non-indicator export.
|
||||||
|
function indicatorClasses() {
|
||||||
|
return Object.keys(wickra).filter((name) => {
|
||||||
|
const value = wickra[name];
|
||||||
|
return (
|
||||||
|
typeof value === 'function' &&
|
||||||
|
value.prototype &&
|
||||||
|
typeof value.prototype.update === 'function'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the binding exports the full indicator catalogue', () => {
|
||||||
|
const names = indicatorClasses();
|
||||||
|
// The published catalogue is 214 indicators. Guard against a regression that
|
||||||
|
// silently drops exported classes (e.g. a stale or partial native build).
|
||||||
|
assert.ok(
|
||||||
|
names.length >= 200,
|
||||||
|
`expected at least 200 indicator classes, got ${names.length}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
|
||||||
|
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
|
||||||
|
const missing = [];
|
||||||
|
for (const name of indicatorClasses()) {
|
||||||
|
const proto = wickra[name].prototype;
|
||||||
|
for (const method of required) {
|
||||||
|
if (typeof proto[method] !== 'function') {
|
||||||
|
missing.push(`${name}.${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
missing,
|
||||||
|
[],
|
||||||
|
`indicator classes missing required methods: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
|
||||||
|
// Every indicator that takes no constructor arguments must still satisfy the
|
||||||
|
// pre-warmup contract. (Indicators with required parameters are exercised by
|
||||||
|
// the dedicated suites; here we cover the zero-arg ones generically.)
|
||||||
|
let checked = 0;
|
||||||
|
for (const name of indicatorClasses()) {
|
||||||
|
let instance;
|
||||||
|
try {
|
||||||
|
instance = new wickra[name]();
|
||||||
|
} catch {
|
||||||
|
continue; // needs constructor arguments — covered elsewhere
|
||||||
|
}
|
||||||
|
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
|
||||||
|
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Input-validation tests for the Wickra Node bindings: malformed constructor
|
||||||
|
// parameters and mismatched batch inputs must raise a JS Error (the napi
|
||||||
|
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
|
||||||
|
// Node counterpart of bindings/python/tests/test_input_validation.py.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const wickra = require('..');
|
||||||
|
|
||||||
|
// --- Constructors reject invalid periods / parameters ---
|
||||||
|
|
||||||
|
test('ATR rejects a zero period at construction', () => {
|
||||||
|
// ATR validates its period (it drives the Wilder-smoothing length). The
|
||||||
|
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
|
||||||
|
// warmup-1 pass-through rather than an error, so they are not asserted here.
|
||||||
|
assert.throws(() => new wickra.ATR(0), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MACD rejects zero and non-increasing fast/slow periods', () => {
|
||||||
|
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
|
||||||
|
// fast must be strictly less than slow.
|
||||||
|
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
|
||||||
|
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PSAR rejects a step greater than its maximum', () => {
|
||||||
|
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
|
||||||
|
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
|
||||||
|
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
|
||||||
|
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
|
||||||
|
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('InitialBalance and OpeningRange reject a zero period', () => {
|
||||||
|
assert.throws(() => new wickra.InitialBalance(0), /.*/);
|
||||||
|
assert.throws(() => new wickra.OpeningRange(0), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ichimoku rejects zero and non-increasing periods', () => {
|
||||||
|
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
|
||||||
|
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
|
||||||
|
// Periods must satisfy tenkan < kijun < senkouB.
|
||||||
|
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
|
||||||
|
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
|
||||||
|
// InverseFisherTransform needs a non-zero scaling factor.
|
||||||
|
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
|
||||||
|
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
|
||||||
|
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
|
||||||
|
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
|
||||||
|
// MAMA needs fast limit > slow limit.
|
||||||
|
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
|
||||||
|
// EmpiricalModeDecomposition needs a positive fraction.
|
||||||
|
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
|
||||||
|
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
|
||||||
|
// binding treats their period 0 as a warmup-1 pass-through (same as the
|
||||||
|
// simple moving averages) rather than an error.
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Batch methods reject mismatched input lengths ---
|
||||||
|
|
||||||
|
test('candle batch methods reject unequal-length columns', () => {
|
||||||
|
const high = [10, 11, 12];
|
||||||
|
const low = [9, 10]; // one short
|
||||||
|
const close = [9.5, 10.5, 11.5];
|
||||||
|
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
|
||||||
|
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
|
||||||
|
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ValueArea batch rejects unequal-length columns', () => {
|
||||||
|
const high = [1, 2, 3];
|
||||||
|
const low = [0.5, 1.5]; // short
|
||||||
|
const volume = [10, 10, 10];
|
||||||
|
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
|
||||||
|
});
|
||||||
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zero period is clamped to a valid window', () => {
|
test('zero period is rejected at construction', () => {
|
||||||
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
|
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
|
||||||
// clamp pathological values like period=0 to the smallest valid window.
|
// it as a thrown JS error, consistent with the Python and WASM bindings.
|
||||||
const sma = new wickra.SMA(0);
|
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
|
||||||
assert.equal(sma.warmupPeriod(), 1);
|
// A valid period still constructs and runs.
|
||||||
assert.equal(sma.update(42), 42);
|
assert.equal(new wickra.SMA(1).update(42), 42);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Throughput benchmark for the Wickra Node bindings.
|
||||||
|
//
|
||||||
|
// Measures how many indicator updates per second the native binding sustains,
|
||||||
|
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
|
||||||
|
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
|
||||||
|
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
|
||||||
|
// O(1) streaming engine (there is no install-free TA library on npm with a
|
||||||
|
// comparable surface to compare against), so the headline number is raw
|
||||||
|
// throughput, not a cross-library ratio.
|
||||||
|
//
|
||||||
|
// Run after building the binding:
|
||||||
|
//
|
||||||
|
// cd bindings/node && npm install && npx napi build --platform --release
|
||||||
|
// node benchmarks/throughput.js # 200k bars (default)
|
||||||
|
// node benchmarks/throughput.js --bars 1000000
|
||||||
|
|
||||||
|
const wickra = require('..');
|
||||||
|
|
||||||
|
function parseBars() {
|
||||||
|
const idx = process.argv.indexOf('--bars');
|
||||||
|
if (idx !== -1 && process.argv[idx + 1]) {
|
||||||
|
const n = Number(process.argv[idx + 1]);
|
||||||
|
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
|
||||||
|
console.error('--bars must be a number >= 1000');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return 200_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BARS = parseBars();
|
||||||
|
|
||||||
|
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
|
||||||
|
const close = new Array(BARS);
|
||||||
|
const high = new Array(BARS);
|
||||||
|
const low = new Array(BARS);
|
||||||
|
const volume = new Array(BARS);
|
||||||
|
for (let i = 0; i < BARS; i++) {
|
||||||
|
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
|
||||||
|
close[i] = mid + Math.sin(i * 0.05) * 2;
|
||||||
|
high[i] = Math.max(close[i], mid) + 1.5;
|
||||||
|
low[i] = Math.min(close[i], mid) - 1.5;
|
||||||
|
volume[i] = 1000 + (i % 97) * 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Median elapsed-ns over a few repetitions, after one warmup pass.
|
||||||
|
function timeNs(fn, reps = 3) {
|
||||||
|
fn(); // warmup (JIT + cache)
|
||||||
|
const samples = [];
|
||||||
|
for (let r = 0; r < reps; r++) {
|
||||||
|
const t0 = process.hrtime.bigint();
|
||||||
|
fn();
|
||||||
|
samples.push(Number(process.hrtime.bigint() - t0));
|
||||||
|
}
|
||||||
|
samples.sort((a, b) => a - b);
|
||||||
|
return samples[Math.floor(samples.length / 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mupsFromNs(ns) {
|
||||||
|
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each indicator: a streaming step and a batch call over the full series.
|
||||||
|
const indicators = [
|
||||||
|
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||||
|
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||||
|
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||||
|
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||||
|
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||||
|
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
|
||||||
|
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
|
||||||
|
console.log('-'.repeat(60));
|
||||||
|
|
||||||
|
for (const ind of indicators) {
|
||||||
|
const streamNs = timeNs(() => {
|
||||||
|
const inst = ind.make();
|
||||||
|
for (let i = 0; i < BARS; i++) ind.step(inst, i);
|
||||||
|
});
|
||||||
|
const batchNs = timeNs(() => {
|
||||||
|
ind.batch(ind.make());
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
|
||||||
|
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
|
||||||
|
'better. Numbers are machine-dependent — use them for relative comparison.',
|
||||||
|
);
|
||||||
Vendored
+2243
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-arm64",
|
"name": "wickra-darwin-arm64",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-arm64.node",
|
"main": "wickra.darwin-arm64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-x64",
|
"name": "wickra-darwin-x64",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-x64.node",
|
"main": "wickra.darwin-x64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-arm64-gnu",
|
"name": "wickra-linux-arm64-gnu",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"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",
|
"main": "wickra.linux-arm64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-x64-gnu",
|
"name": "wickra-linux-x64-gnu",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"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",
|
"main": "wickra.linux-x64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-win32-arm64-msvc",
|
"name": "wickra-win32-arm64-msvc",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"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",
|
"main": "wickra.win32-arm64-msvc.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-win32-x64-msvc",
|
"name": "wickra-win32-x64-msvc",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"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",
|
"main": "wickra.win32-x64-msvc.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@napi-rs/cli": "^2.18.0"
|
"@napi-rs/cli": "^2.18.0"
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-darwin-arm64": "0.3.1",
|
"wickra-darwin-arm64": "0.4.0",
|
||||||
"wickra-darwin-x64": "0.3.1",
|
"wickra-darwin-x64": "0.4.0",
|
||||||
"wickra-linux-arm64-gnu": "0.3.1",
|
"wickra-linux-arm64-gnu": "0.4.0",
|
||||||
"wickra-linux-x64-gnu": "0.3.1",
|
"wickra-linux-x64-gnu": "0.4.0",
|
||||||
"wickra-win32-arm64-msvc": "0.3.1",
|
"wickra-win32-arm64-msvc": "0.4.0",
|
||||||
"wickra-win32-x64-msvc": "0.3.1"
|
"wickra-win32-x64-msvc": "0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/cli": {
|
"node_modules/@napi-rs/cli": {
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-darwin-arm64": {
|
"node_modules/wickra-darwin-arm64": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.0.tgz",
|
||||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-darwin-x64": {
|
"node_modules/wickra-darwin-x64": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.0.tgz",
|
||||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-linux-arm64-gnu": {
|
"node_modules/wickra-linux-arm64-gnu": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.0.tgz",
|
||||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-linux-x64-gnu": {
|
"node_modules/wickra-linux-x64-gnu": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.0.tgz",
|
||||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-win32-arm64-msvc": {
|
"node_modules/wickra-win32-arm64-msvc": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.0.tgz",
|
||||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-win32-x64-msvc": {
|
"node_modules/wickra-win32-x64-msvc": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.0.tgz",
|
||||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||||
"author": "kingchenc <wickra.lib@gmail.com>",
|
"author": "kingchenc <support@wickra.org>",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
@@ -47,12 +47,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-linux-x64-gnu": "0.3.1",
|
"wickra-linux-x64-gnu": "0.4.0",
|
||||||
"wickra-linux-arm64-gnu": "0.3.1",
|
"wickra-linux-arm64-gnu": "0.4.0",
|
||||||
"wickra-darwin-x64": "0.3.1",
|
"wickra-darwin-x64": "0.4.0",
|
||||||
"wickra-darwin-arm64": "0.3.1",
|
"wickra-darwin-arm64": "0.4.0",
|
||||||
"wickra-win32-x64-msvc": "0.3.1",
|
"wickra-win32-x64-msvc": "0.4.0",
|
||||||
"wickra-win32-arm64-msvc": "0.3.1"
|
"wickra-win32-arm64-msvc": "0.4.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "napi build --platform --release",
|
"build": "napi build --platform --release",
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
"artifacts": "napi artifacts",
|
"artifacts": "napi artifacts",
|
||||||
"universal": "napi universal",
|
"universal": "napi universal",
|
||||||
"version": "napi version",
|
"version": "napi version",
|
||||||
"test": "node --test __tests__/"
|
"test": "node --test __tests__/",
|
||||||
|
"bench": "node benchmarks/throughput.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@napi-rs/cli": "^2.18.0"
|
"@napi-rs/cli": "^2.18.0"
|
||||||
|
|||||||
+43
-78
@@ -22,26 +22,6 @@ fn map_err(e: wc::Error) -> NapiError {
|
|||||||
NapiError::new(Status::InvalidArg, e.to_string())
|
NapiError::new(Status::InvalidArg, e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper for the scalar-indicator macro only. Scalar `new` functions can fail
|
|
||||||
/// solely on `period == 0`, which `clamp_period` already rules out, so the
|
|
||||||
/// `Result` is provably `Ok` here. Candle indicators and the multi-parameter
|
|
||||||
/// indicators have genuinely fallible parameters and instead use fallible
|
|
||||||
/// `#[napi(constructor)]`s that return `napi::Result<Self>` and throw a JS error.
|
|
||||||
fn must<T>(r: Result<T, wc::Error>) -> T {
|
|
||||||
r.expect("wickra: scalar indicator parameter clamped to a valid range")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clamp a period parameter so the underlying indicator never sees zero. JS
|
|
||||||
/// callers who pass `0` get a window of `1` instead of a thrown exception —
|
|
||||||
/// effectively a pass-through indicator that still produces valid outputs.
|
|
||||||
const fn clamp_period(p: u32) -> usize {
|
|
||||||
if p == 0 {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
p as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
|
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
|
||||||
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
|
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
|
||||||
}
|
}
|
||||||
@@ -64,10 +44,10 @@ macro_rules! node_scalar_indicator {
|
|||||||
#[napi]
|
#[napi]
|
||||||
impl $wrapper {
|
impl $wrapper {
|
||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32) -> Self {
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
Self {
|
Ok(Self {
|
||||||
inner: must(<$rust_ty>::new(clamp_period(period))),
|
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||||
@@ -137,11 +117,10 @@ node_scalar_indicator!(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
|
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
|
||||||
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
|
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` and
|
||||||
// parameter and additionally rejects `period == 1` (a 1-bar standard
|
// rejects both `period == 0` and `period == 1` (a 1-bar standard deviation is
|
||||||
// deviation is always zero), so the `clamp_period`-to-1 strategy from
|
// always zero). Hand-rolled, but behaves like `node_scalar_indicator!`: the
|
||||||
// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible
|
// fallible `new` propagates the core error and throws a JS error on bad period.
|
||||||
// constructor instead, throws a JS error on bad period.
|
|
||||||
#[napi(js_name = "RVIVolatility")]
|
#[napi(js_name = "RVIVolatility")]
|
||||||
pub struct RviVolatilityNode {
|
pub struct RviVolatilityNode {
|
||||||
inner: wc::RviVolatility,
|
inner: wc::RviVolatility,
|
||||||
@@ -1351,7 +1330,7 @@ impl InertiaNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
|
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period))
|
inner: wc::Inertia::new(rvi_period as usize, linreg_period as usize)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1412,9 +1391,9 @@ impl ConnorsRsiNode {
|
|||||||
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
|
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::ConnorsRsi::new(
|
inner: wc::ConnorsRsi::new(
|
||||||
clamp_period(period_rsi),
|
period_rsi as usize,
|
||||||
clamp_period(period_streak),
|
period_streak as usize,
|
||||||
clamp_period(period_rank),
|
period_rank as usize,
|
||||||
)
|
)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
@@ -1484,12 +1463,8 @@ impl SmiNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Smi::new(
|
inner: wc::Smi::new(period as usize, d_period as usize, d2_period as usize)
|
||||||
clamp_period(period),
|
.map_err(map_err)?,
|
||||||
clamp_period(d_period),
|
|
||||||
clamp_period(d2_period),
|
|
||||||
)
|
|
||||||
.map_err(map_err)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1559,15 +1534,15 @@ impl KstNode {
|
|||||||
) -> napi::Result<Self> {
|
) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Kst::new(
|
inner: wc::Kst::new(
|
||||||
clamp_period(roc1),
|
roc1 as usize,
|
||||||
clamp_period(roc2),
|
roc2 as usize,
|
||||||
clamp_period(roc3),
|
roc3 as usize,
|
||||||
clamp_period(roc4),
|
roc4 as usize,
|
||||||
clamp_period(sma1),
|
sma1 as usize,
|
||||||
clamp_period(sma2),
|
sma2 as usize,
|
||||||
clamp_period(sma3),
|
sma3 as usize,
|
||||||
clamp_period(sma4),
|
sma4 as usize,
|
||||||
clamp_period(signal),
|
signal as usize,
|
||||||
)
|
)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
@@ -1620,7 +1595,7 @@ impl PgoNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Pgo::new(clamp_period(period)).map_err(map_err)?,
|
inner: wc::Pgo::new(period as usize).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1672,7 +1647,7 @@ impl RviNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?,
|
inner: wc::Rvi::new(period as usize).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1732,9 +1707,9 @@ impl AwesomeOscillatorHistogramNode {
|
|||||||
pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result<Self> {
|
pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::AwesomeOscillatorHistogram::new(
|
inner: wc::AwesomeOscillatorHistogram::new(
|
||||||
clamp_period(fast),
|
fast as usize,
|
||||||
clamp_period(slow),
|
slow as usize,
|
||||||
clamp_period(sma_period),
|
sma_period as usize,
|
||||||
)
|
)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
@@ -1783,13 +1758,8 @@ impl StcNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result<Self> {
|
pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Stc::new(
|
inner: wc::Stc::new(fast as usize, slow as usize, schaff_period as usize, factor)
|
||||||
clamp_period(fast),
|
.map_err(map_err)?,
|
||||||
clamp_period(slow),
|
|
||||||
clamp_period(schaff_period),
|
|
||||||
factor,
|
|
||||||
)
|
|
||||||
.map_err(map_err)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1829,10 +1799,10 @@ impl ElderImpulseNode {
|
|||||||
) -> napi::Result<Self> {
|
) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::ElderImpulse::new(
|
inner: wc::ElderImpulse::new(
|
||||||
clamp_period(ema_period),
|
ema_period as usize,
|
||||||
clamp_period(macd_fast),
|
macd_fast as usize,
|
||||||
clamp_period(macd_slow),
|
macd_slow as usize,
|
||||||
clamp_period(macd_signal),
|
macd_signal as usize,
|
||||||
)
|
)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
@@ -1875,12 +1845,8 @@ impl ZeroLagMacdNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::ZeroLagMacd::new(
|
inner: wc::ZeroLagMacd::new(fast as usize, slow as usize, signal as usize)
|
||||||
clamp_period(fast),
|
.map_err(map_err)?,
|
||||||
clamp_period(slow),
|
|
||||||
clamp_period(signal),
|
|
||||||
)
|
|
||||||
.map_err(map_err)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1927,7 +1893,7 @@ impl CfoNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
|
inner: wc::Cfo::new(period as usize).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -1961,7 +1927,7 @@ impl ApoNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
|
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Apo::new(clamp_period(fast), clamp_period(slow)).map_err(map_err)?,
|
inner: wc::Apo::new(fast as usize, slow as usize).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -2032,7 +1998,7 @@ impl EvwmaNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Evwma::new(clamp_period(period)).map_err(map_err)?,
|
inner: wc::Evwma::new(period as usize).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -2088,7 +2054,7 @@ impl AlligatorNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(jaw: u32, teeth: u32, lips: u32) -> napi::Result<Self> {
|
pub fn new(jaw: u32, teeth: u32, lips: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Alligator::new(clamp_period(jaw), clamp_period(teeth), clamp_period(lips))
|
inner: wc::Alligator::new(jaw as usize, teeth as usize, lips as usize)
|
||||||
.map_err(map_err)?,
|
.map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2146,7 +2112,7 @@ impl JmaNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32, phase: f64, power: u32) -> napi::Result<Self> {
|
pub fn new(period: u32, phase: f64, power: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Jma::new(clamp_period(period), phase, power).map_err(map_err)?,
|
inner: wc::Jma::new(period as usize, phase, power).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -2182,8 +2148,7 @@ impl VidyaNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32, cmo_period: u32) -> napi::Result<Self> {
|
pub fn new(period: u32, cmo_period: u32) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Vidya::new(clamp_period(period), clamp_period(cmo_period))
|
inner: wc::Vidya::new(period as usize, cmo_period as usize).map_err(map_err)?,
|
||||||
.map_err(map_err)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
@@ -2219,7 +2184,7 @@ impl AlmaNode {
|
|||||||
#[napi(constructor)]
|
#[napi(constructor)]
|
||||||
pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result<Self> {
|
pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: wc::Alma::new(clamp_period(period), offset, sigma).map_err(map_err)?,
|
inner: wc::Alma::new(period as usize, offset, sigma).map_err(map_err)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[napi]
|
#[napi]
|
||||||
|
|||||||
+41
-283
@@ -1,314 +1,72 @@
|
|||||||
# Wickra
|
# Wickra — Python
|
||||||
|
|
||||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||||
[](https://crates.io/crates/wickra)
|
|
||||||
[](https://pypi.org/project/wickra/)
|
[](https://pypi.org/project/wickra/)
|
||||||
[](https://www.npmjs.com/package/wickra)
|
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
**Streaming-first technical indicators for Python. `pip install wickra` — no
|
||||||
|
system dependencies, no C build tooling.**
|
||||||
|
|
||||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||||
machine that updates in O(1) per new data point, so live trading bots and
|
streaming state machine, so live trading bots and historical backtests share
|
||||||
historical backtests share the exact same implementation.
|
the exact same implementation. This package is the Python binding (PyO3); it
|
||||||
|
exposes 200+ streaming-first indicators across sixteen families.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install wickra
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
|
||||||
|
compile and no C library to track down.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import wickra as ta
|
import wickra as ta
|
||||||
|
|
||||||
# Batch: classic TA-Lib-style usage
|
# Batch: classic TA-Lib-style usage over a whole array.
|
||||||
prices = np.linspace(100, 200, 1000)
|
prices = np.linspace(100, 200, 1000)
|
||||||
rsi = ta.RSI(14)
|
rsi = ta.RSI(14)
|
||||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||||
|
|
||||||
# Streaming: same indicator, fed tick by tick
|
# Streaming: the same indicator, fed tick by tick in O(1).
|
||||||
rsi = ta.RSI(14)
|
rsi = ta.RSI(14)
|
||||||
for price in live_feed:
|
for price in live_feed:
|
||||||
value = rsi.update(price) # O(1) — no recomputation over history
|
value = rsi.update(price) # no recomputation over history
|
||||||
if value is not None and value > 70:
|
if value is not None and value > 70:
|
||||||
print("overbought")
|
print("overbought")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why Wickra exists
|
`batch(prices)` and feeding the same prices through `update()` produce
|
||||||
|
identical values — the equivalence is enforced by the test suite.
|
||||||
|
|
||||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
## Documentation
|
||||||
talipp, tulipy — and every one of them shares the same blind spot:
|
|
||||||
|
|
||||||
| Library | Install pain | Streaming | Multi-language | Active |
|
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||||
|------------------------|-----------------|-----------|----------------|--------|
|
the main repository and documentation site:
|
||||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
|
||||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
|
||||||
| pandas-ta | clean | no | no | slow |
|
|
||||||
| finta | clean | no | no | stale |
|
|
||||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
|
||||||
| talipp | clean | yes | no | yes |
|
|
||||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
|
||||||
| ooples (C#) | clean | no | C# only | yes |
|
|
||||||
|
|
||||||
Wickra is the only library that combines all of: clean install, streaming,
|
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||||
multi-language reach, and active maintenance.
|
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||||
|
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||||
|
|
||||||
## Benchmark: how much faster is "streaming-first"?
|
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||||
|
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||||
|
|
||||||
The numbers below were measured on a single developer workstation and are not
|
## Disclaimer
|
||||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
|
||||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
|
||||||
speedups** between libraries on identical input, not as a universal
|
|
||||||
performance contract.
|
|
||||||
|
|
||||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
are deterministic transforms of the input data — they are not financial advice
|
||||||
Python 3.12, Node 20.
|
and do not predict the market. Any use in a live trading context is at your own
|
||||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
risk. The library is provided **as is**, without warranty of any kind.
|
||||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
|
||||||
installed peer library and runs them on the same generated inputs as
|
|
||||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
|
||||||
push and uploads the raw report as a build artefact.
|
|
||||||
|
|
||||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
|
||||||
streaming gap widens linearly with how much history a batch-only library has
|
|
||||||
to recompute on every tick.
|
|
||||||
|
|
||||||
### Batch — single full pass over a 20 000-bar series
|
|
||||||
|
|
||||||
Reading the table: each cell shows that library's runtime, plus how many times
|
|
||||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | finta | talipp |
|
|
||||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
|
||||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
|
||||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
|
||||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
|
||||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
|
||||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
|
||||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
|
||||||
|
|
||||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
|
||||||
|
|
||||||
A batch-only library has to re-run its full indicator over the entire history on
|
|
||||||
every new tick; Wickra updates state in O(1).
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
|
||||||
|-----------|---------------------|---------------------------|
|
|
||||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
|
||||||
|
|
||||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
|
||||||
> cleanly on Windows without C build tooling — which is precisely the install
|
|
||||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
|
||||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
|
||||||
> them in your environment to see those rows light up too.
|
|
||||||
|
|
||||||
Run the suite yourself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e bindings/python[bench]
|
|
||||||
python -m benchmarks.compare_libraries
|
|
||||||
```
|
|
||||||
|
|
||||||
## Indicators
|
|
||||||
|
|
||||||
214 streaming-first indicators across sixteen families. Every one passes the
|
|
||||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
|
||||||
semantics tests.
|
|
||||||
|
|
||||||
| Family | Indicators |
|
|
||||||
|--------|-----------|
|
|
||||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
|
||||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
|
||||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
|
||||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
|
||||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
|
||||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
|
||||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
|
||||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
|
||||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
|
||||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
|
||||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
|
||||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
|
||||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
|
||||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
|
||||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
|
||||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
|
||||||
|
|
||||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
|
||||||
inherit it automatically.
|
|
||||||
|
|
||||||
## Languages
|
|
||||||
|
|
||||||
| Binding | Install | Example |
|
|
||||||
|-------------------|-----------------------------------------------|---------|
|
|
||||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
|
||||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
|
||||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
|
||||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
|
||||||
|
|
||||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
|
||||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
|
||||||
|
|
||||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
|
||||||
memory-safe implementation.
|
|
||||||
|
|
||||||
## Rust API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
|
||||||
|
|
||||||
// Streaming or batch — same trait, same code.
|
|
||||||
let mut sma = Sma::new(14)?;
|
|
||||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
|
||||||
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
for price in live_feed {
|
|
||||||
if let Some(v) = rsi.update(price) {
|
|
||||||
println!("RSI = {v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose indicators: RSI(7) on top of EMA(14).
|
|
||||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
|
||||||
chain.update(price);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Live data sources
|
|
||||||
|
|
||||||
`wickra-data` (separate crate, opt-in) ships:
|
|
||||||
|
|
||||||
- A streaming OHLCV **CSV reader**.
|
|
||||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
|
||||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
|
||||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, Rsi};
|
|
||||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
|
||||||
|
|
||||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
while let Some(event) = stream.next_event().await? {
|
|
||||||
if event.is_closed {
|
|
||||||
if let Some(v) = rsi.update(event.candle.close) {
|
|
||||||
println!("RSI = {v:.2}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A Python live-trading example using the public `websockets` package lives at
|
|
||||||
`examples/python/live_trading.py`.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
wickra/
|
|
||||||
├── crates/
|
|
||||||
│ ├── wickra-core/ core engine + all 71 indicators
|
|
||||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
|
||||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
|
||||||
├── bindings/
|
|
||||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
|
||||||
│ ├── node/ napi-rs (publishes on npm)
|
|
||||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
|
||||||
├── examples/ examples/README.md indexes every language
|
|
||||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
|
||||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
|
||||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
|
||||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
|
||||||
│ └── wasm/ browser demo for `wickra-wasm`
|
|
||||||
└── .github/workflows/ CI and release pipelines
|
|
||||||
```
|
|
||||||
|
|
||||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
|
||||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
|
||||||
`benches/` directory.
|
|
||||||
|
|
||||||
## Building everything from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Rust core + tests
|
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo bench -p wickra
|
|
||||||
|
|
||||||
# Python binding (requires Rust toolchain + maturin)
|
|
||||||
cd bindings/python
|
|
||||||
maturin develop --release
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
|
||||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
|
||||||
|
|
||||||
# Node binding (requires @napi-rs/cli)
|
|
||||||
cd bindings/node && npm install && npm run build && npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Every layer is covered; run the suites with the commands in
|
|
||||||
[Building everything from source](#building-everything-from-source).
|
|
||||||
|
|
||||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
|
||||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
|
||||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
|
||||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
|
||||||
resampler, and the Binance payload parser.
|
|
||||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
|
||||||
equivalence, reference values, lifecycle, input validation, and
|
|
||||||
dict/tuple candle inputs.
|
|
||||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
|
||||||
values across all indicators.
|
|
||||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
|
||||||
and reference values.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
|
||||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
|
||||||
|
|
||||||
A short orientation for first-time contributors:
|
|
||||||
|
|
||||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
|
||||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
|
||||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
|
||||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
|
||||||
proptest. The four bindings inherit your indicator automatically once
|
|
||||||
you expose it in the language wrappers.
|
|
||||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
|
||||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
|
||||||
most regressions; please don't disable them.
|
|
||||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
|
||||||
its own tests; please keep the `batch == streaming` invariant.
|
|
||||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
|
||||||
are CI gates; running them locally before pushing keeps reviews short.
|
|
||||||
|
|
||||||
For larger architectural changes, open an issue first so we can sketch the
|
|
||||||
shape together before you invest the time.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||||
|
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
thing not allowed is commercial sale of the software or of services built
|
||||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
|
||||||
commercial sale of the software or of services built around it. If you want to
|
|
||||||
use Wickra commercially, get in touch about a license.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
|
||||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
|
||||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||||
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
|
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|||||||
+45
-287
@@ -1,314 +1,72 @@
|
|||||||
# Wickra
|
# Wickra — WebAssembly
|
||||||
|
|
||||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||||
[](https://crates.io/crates/wickra)
|
[](https://www.npmjs.com/package/wickra-wasm)
|
||||||
[](https://pypi.org/project/wickra/)
|
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||||
[](https://www.npmjs.com/package/wickra)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
**Streaming-first technical indicators in the browser. `npm install
|
||||||
|
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||||
|
|
||||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||||
machine that updates in O(1) per new data point, so live trading bots and
|
streaming state machine, so live trading dashboards and historical backtests
|
||||||
historical backtests share the exact same implementation.
|
share the exact same implementation. This package is the WebAssembly binding
|
||||||
|
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||||
|
indicators across sixteen families.
|
||||||
|
|
||||||
```python
|
## Install
|
||||||
import numpy as np
|
|
||||||
import wickra as ta
|
|
||||||
|
|
||||||
# Batch: classic TA-Lib-style usage
|
|
||||||
prices = np.linspace(100, 200, 1000)
|
|
||||||
rsi = ta.RSI(14)
|
|
||||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
|
||||||
|
|
||||||
# Streaming: same indicator, fed tick by tick
|
|
||||||
rsi = ta.RSI(14)
|
|
||||||
for price in live_feed:
|
|
||||||
value = rsi.update(price) # O(1) — no recomputation over history
|
|
||||||
if value is not None and value > 70:
|
|
||||||
print("overbought")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why Wickra exists
|
|
||||||
|
|
||||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
|
||||||
talipp, tulipy — and every one of them shares the same blind spot:
|
|
||||||
|
|
||||||
| Library | Install pain | Streaming | Multi-language | Active |
|
|
||||||
|------------------------|-----------------|-----------|----------------|--------|
|
|
||||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
|
||||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
|
||||||
| pandas-ta | clean | no | no | slow |
|
|
||||||
| finta | clean | no | no | stale |
|
|
||||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
|
||||||
| talipp | clean | yes | no | yes |
|
|
||||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
|
||||||
| ooples (C#) | clean | no | C# only | yes |
|
|
||||||
|
|
||||||
Wickra is the only library that combines all of: clean install, streaming,
|
|
||||||
multi-language reach, and active maintenance.
|
|
||||||
|
|
||||||
## Benchmark: how much faster is "streaming-first"?
|
|
||||||
|
|
||||||
The numbers below were measured on a single developer workstation and are not
|
|
||||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
|
||||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
|
||||||
speedups** between libraries on identical input, not as a universal
|
|
||||||
performance contract.
|
|
||||||
|
|
||||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
|
||||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
|
||||||
Python 3.12, Node 20.
|
|
||||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
|
||||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
|
||||||
installed peer library and runs them on the same generated inputs as
|
|
||||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
|
||||||
push and uploads the raw report as a build artefact.
|
|
||||||
|
|
||||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
|
||||||
streaming gap widens linearly with how much history a batch-only library has
|
|
||||||
to recompute on every tick.
|
|
||||||
|
|
||||||
### Batch — single full pass over a 20 000-bar series
|
|
||||||
|
|
||||||
Reading the table: each cell shows that library's runtime, plus how many times
|
|
||||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra** | finta | talipp |
|
|
||||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
|
||||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
|
||||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
|
||||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
|
||||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
|
||||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
|
||||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
|
||||||
|
|
||||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
|
||||||
|
|
||||||
A batch-only library has to re-run its full indicator over the entire history on
|
|
||||||
every new tick; Wickra updates state in O(1).
|
|
||||||
|
|
||||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
|
||||||
|-----------|---------------------|---------------------------|
|
|
||||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
|
||||||
|
|
||||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
|
||||||
> cleanly on Windows without C build tooling — which is precisely the install
|
|
||||||
> pain Wickra was built to remove. The benchmark script auto-detects every
|
|
||||||
> peer library it can find and runs them on the same inputs as Wickra; install
|
|
||||||
> them in your environment to see those rows light up too.
|
|
||||||
|
|
||||||
Run the suite yourself:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e bindings/python[bench]
|
npm install wickra-wasm
|
||||||
python -m benchmarks.compare_libraries
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Indicators
|
## Quick start
|
||||||
|
|
||||||
214 streaming-first indicators across sixteen families. Every one passes the
|
The module ships a default `init` export that loads the `.wasm` payload; await
|
||||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
it once before constructing indicators.
|
||||||
semantics tests.
|
|
||||||
|
|
||||||
| Family | Indicators |
|
```js
|
||||||
|--------|-----------|
|
import init, { RSI } from 'wickra-wasm';
|
||||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
|
||||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
|
||||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
|
||||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
|
||||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
|
||||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
|
||||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
|
||||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
|
||||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
|
||||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
|
||||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
|
||||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
|
||||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
|
||||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
|
||||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
|
||||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
|
||||||
|
|
||||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
await init(); // load the WebAssembly module once
|
||||||
inherit it automatically.
|
|
||||||
|
|
||||||
## Languages
|
// Streaming: feed prices tick by tick in O(1).
|
||||||
|
const rsi = new RSI(14);
|
||||||
| Binding | Install | Example |
|
for (const price of liveFeed) {
|
||||||
|-------------------|-----------------------------------------------|---------|
|
const value = rsi.update(price); // null during warmup
|
||||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
if (value !== null && value > 70) {
|
||||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
console.log('overbought');
|
||||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
}
|
||||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
|
||||||
|
|
||||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
|
||||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
|
||||||
|
|
||||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
|
||||||
memory-safe implementation.
|
|
||||||
|
|
||||||
## Rust API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
|
||||||
|
|
||||||
// Streaming or batch — same trait, same code.
|
|
||||||
let mut sma = Sma::new(14)?;
|
|
||||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
|
||||||
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
for price in live_feed {
|
|
||||||
if let Some(v) = rsi.update(price) {
|
|
||||||
println!("RSI = {v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose indicators: RSI(7) on top of EMA(14).
|
|
||||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
|
||||||
chain.update(price);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Live data sources
|
|
||||||
|
|
||||||
`wickra-data` (separate crate, opt-in) ships:
|
|
||||||
|
|
||||||
- A streaming OHLCV **CSV reader**.
|
|
||||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
|
||||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
|
||||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use wickra::{Indicator, Rsi};
|
|
||||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
|
||||||
|
|
||||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
|
||||||
let mut rsi = Rsi::new(14)?;
|
|
||||||
while let Some(event) = stream.next_event().await? {
|
|
||||||
if event.is_closed {
|
|
||||||
if let Some(v) = rsi.update(event.candle.close) {
|
|
||||||
println!("RSI = {v:.2}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A Python live-trading example using the public `websockets` package lives at
|
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
|
||||||
`examples/python/live_trading.py`.
|
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
|
||||||
|
`null` while the indicator is still warming up.
|
||||||
|
|
||||||
## Project layout
|
## Documentation
|
||||||
|
|
||||||
```
|
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||||
wickra/
|
the main repository and documentation site:
|
||||||
├── crates/
|
|
||||||
│ ├── wickra-core/ core engine + all 71 indicators
|
|
||||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
|
||||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
|
||||||
├── bindings/
|
|
||||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
|
||||||
│ ├── node/ napi-rs (publishes on npm)
|
|
||||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
|
||||||
├── examples/ examples/README.md indexes every language
|
|
||||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
|
||||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
|
||||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
|
||||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
|
||||||
│ └── wasm/ browser demo for `wickra-wasm`
|
|
||||||
└── .github/workflows/ CI and release pipelines
|
|
||||||
```
|
|
||||||
|
|
||||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||||
`benches/` directory.
|
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||||
|
|
||||||
## Building everything from source
|
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||||
|
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||||
|
|
||||||
```bash
|
## Disclaimer
|
||||||
# Rust core + tests
|
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo bench -p wickra
|
|
||||||
|
|
||||||
# Python binding (requires Rust toolchain + maturin)
|
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||||
cd bindings/python
|
are deterministic transforms of the input data — they are not financial advice
|
||||||
maturin develop --release
|
and do not predict the market. Any use in a live trading context is at your own
|
||||||
pytest
|
risk. The library is provided **as is**, without warranty of any kind.
|
||||||
|
|
||||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
|
||||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
|
||||||
|
|
||||||
# Node binding (requires @napi-rs/cli)
|
|
||||||
cd bindings/node && npm install && npm run build && npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Every layer is covered; run the suites with the commands in
|
|
||||||
[Building everything from source](#building-everything-from-source).
|
|
||||||
|
|
||||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
|
||||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
|
||||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
|
||||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
|
||||||
resampler, and the Binance payload parser.
|
|
||||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
|
||||||
equivalence, reference values, lifecycle, input validation, and
|
|
||||||
dict/tuple candle inputs.
|
|
||||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
|
||||||
values across all indicators.
|
|
||||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
|
||||||
and reference values.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
|
||||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
|
||||||
|
|
||||||
A short orientation for first-time contributors:
|
|
||||||
|
|
||||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
|
||||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
|
||||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
|
||||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
|
||||||
proptest. The four bindings inherit your indicator automatically once
|
|
||||||
you expose it in the language wrappers.
|
|
||||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
|
||||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
|
||||||
most regressions; please don't disable them.
|
|
||||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
|
||||||
its own tests; please keep the `batch == streaming` invariant.
|
|
||||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
|
||||||
are CI gates; running them locally before pushing keeps reviews short.
|
|
||||||
|
|
||||||
For larger architectural changes, open an issue first so we can sketch the
|
|
||||||
shape together before you invest the time.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||||
|
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
thing not allowed is commercial sale of the software or of services built
|
||||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
|
||||||
commercial sale of the software or of services built around it. If you want to
|
|
||||||
use Wickra commercially, get in touch about a license.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
|
||||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
|
||||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -6139,6 +6139,12 @@ mod tests {
|
|||||||
fn close_enough(a: f64, b: f64) -> bool {
|
fn close_enough(a: f64, b: f64) -> bool {
|
||||||
if a.is_nan() {
|
if a.is_nan() {
|
||||||
b.is_nan()
|
b.is_nan()
|
||||||
|
} else if a == b {
|
||||||
|
// Exact equality, including matching infinities (e.g. ProfitFactor
|
||||||
|
// with no losing trades is +inf in both the streaming and batch
|
||||||
|
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
|
||||||
|
// would otherwise reject two equal infinities.
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
(a - b).abs() < 1e-9
|
(a - b).abs() < 1e-9
|
||||||
}
|
}
|
||||||
@@ -6630,6 +6636,146 @@ mod tests {
|
|||||||
"ready after 5 finite inputs even with prior NaNs"
|
"ready after 5 finite inputs even with prior NaNs"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streaming `update` must reproduce `batch` value-for-value for every scalar
|
||||||
|
// indicator — the core O(1) state-machine invariant. Each entry builds a
|
||||||
|
// fresh instance for the batch pass and another for the streaming pass. The
|
||||||
|
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
|
||||||
|
// factories, so they are known-valid.
|
||||||
|
macro_rules! assert_scalar_stream_eq {
|
||||||
|
($ctor:expr, $prices:expr) => {{
|
||||||
|
let prices: &[f64] = $prices;
|
||||||
|
let batch = { $ctor }.batch(prices);
|
||||||
|
let mut streaming = { $ctor };
|
||||||
|
for (i, &p) in prices.iter().enumerate() {
|
||||||
|
let b = batch.get_index(i as u32);
|
||||||
|
match streaming.update(p) {
|
||||||
|
Some(v) => assert!(
|
||||||
|
close_enough(v, b),
|
||||||
|
"{} streaming != batch at {i}: {v} vs {b}",
|
||||||
|
stringify!($ctor)
|
||||||
|
),
|
||||||
|
None => assert!(
|
||||||
|
b.is_nan(),
|
||||||
|
"{} expected NaN warmup at {i}",
|
||||||
|
stringify!($ctor)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn scalar_streaming_matches_batch_broad() {
|
||||||
|
let prices: Vec<f64> = (0..120)
|
||||||
|
.map(|i| {
|
||||||
|
let t = f64::from(i);
|
||||||
|
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let p = prices.as_slice();
|
||||||
|
|
||||||
|
// Moving averages.
|
||||||
|
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
|
||||||
|
|
||||||
|
// Momentum / oscillators.
|
||||||
|
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
|
||||||
|
|
||||||
|
// Volatility / statistics / regression.
|
||||||
|
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
|
||||||
|
|
||||||
|
// Ehlers / cycle.
|
||||||
|
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(
|
||||||
|
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
|
||||||
|
p
|
||||||
|
);
|
||||||
|
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
|
||||||
|
|
||||||
|
// Risk / performance (scalar f64 input).
|
||||||
|
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||||
|
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional invalid-constructor coverage. These wrap the same fallible core
|
||||||
|
// `new` as the Python / Node bindings, where the equivalent calls are
|
||||||
|
// confirmed to error.
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn additional_invalid_constructors_are_rejected() {
|
||||||
|
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
|
||||||
|
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
|
||||||
|
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
|
||||||
|
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ============================== Family 15: Risk / Performance ==============================
|
// ============================== Family 15: Risk / Performance ==============================
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -1,30 +1,31 @@
|
|||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
Wickra's full documentation lives in the **[GitHub Wiki](https://github.com/wickra-lib/wickra/wiki)**.
|
Wickra's full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**.
|
||||||
|
|
||||||
That includes:
|
That includes:
|
||||||
|
|
||||||
- **Quickstarts** for [Rust](https://github.com/wickra-lib/wickra/wiki/Quickstart-Rust.md),
|
- **Quickstarts** for [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||||
[Python](https://github.com/wickra-lib/wickra/wiki/Quickstart-Python.md),
|
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||||
[Node](https://github.com/wickra-lib/wickra/wiki/Quickstart-Node.md), and
|
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||||
[WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md).
|
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||||
- A per-indicator deep dive for every one of the **214 indicators** across
|
- A per-indicator deep dive for every one of the **214 indicators** across
|
||||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||||
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
|
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
|
||||||
Risk / Performance) — see the
|
Risk / Performance) — see the
|
||||||
[indicators overview](https://github.com/wickra-lib/wickra/wiki/Indicators-Overview.md).
|
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||||
- **Reference pages**: [warmup periods](https://github.com/wickra-lib/wickra/wiki/Warmup-Periods.md),
|
- **Reference pages**: [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||||
[streaming vs batch](https://github.com/wickra-lib/wickra/wiki/Streaming-vs-Batch.md),
|
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||||
[indicator chaining](https://github.com/wickra-lib/wickra/wiki/Indicator-Chaining.md), and the
|
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), and the
|
||||||
[data layer](https://github.com/wickra-lib/wickra/wiki/Data-Layer.md).
|
[data layer](https://docs.wickra.org/Data-Layer).
|
||||||
- **Guides**: [Cookbook](https://github.com/wickra-lib/wickra/wiki/Cookbook.md),
|
- **Guides**: [Cookbook](https://docs.wickra.org/Cookbook),
|
||||||
[TA-Lib migration](https://github.com/wickra-lib/wickra/wiki/TA-Lib-Migration.md),
|
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||||
[FAQ](https://github.com/wickra-lib/wickra/wiki/FAQ.md).
|
[FAQ](https://docs.wickra.org/FAQ).
|
||||||
|
|
||||||
## Editing the wiki
|
## Editing the docs
|
||||||
|
|
||||||
The wiki is a separate git repository at `https://github.com/wickra-lib/wickra.wiki.git`.
|
The documentation site is a separate git repository at
|
||||||
Clone it locally if you want to bulk-edit; otherwise the GitHub web UI's "Edit" button on any
|
`https://github.com/wickra-lib/wickra-docs`. Open a pull request there to
|
||||||
wiki page is fine for one-off changes.
|
propose changes; the site is built with VitePress and deploys to
|
||||||
|
`docs.wickra.org`.
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
|
|||||||
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
|
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
|
||||||
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
|
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
|
||||||
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
|
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
|
||||||
|
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
|
||||||
|
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
|
||||||
|
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
|
||||||
|
|
||||||
## WebAssembly — `examples/wasm/`
|
## WebAssembly — `examples/wasm/`
|
||||||
|
|
||||||
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
|
|||||||
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
|
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
|
||||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
|
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
|
||||||
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
|
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
|
||||||
|
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long < 30, exit > 70); prints a PnL / Sharpe / max-DD summary table. |
|
||||||
|
| `strategy_macd_adx.html` | Hourly BTCUSDT MACD crossover gated by ADX(14) > 20, with the same summary table. |
|
||||||
|
| `strategy_bollinger_squeeze.html` | Daily BTCUSDT Bollinger-squeeze breakout with a 2×ATR(14) stop and summary table. |
|
||||||
|
|
||||||
## Example datasets
|
## Example datasets
|
||||||
|
|
||||||
|
|||||||
Generated
+9
-9
@@ -17,21 +17,21 @@
|
|||||||
},
|
},
|
||||||
"../../bindings/node": {
|
"../../bindings/node": {
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.1.4",
|
"version": "0.4.0",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@napi-rs/cli": "^2.18.0"
|
"@napi-rs/cli": "^2.18.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-darwin-arm64": "0.1.4",
|
"wickra-darwin-arm64": "0.4.0",
|
||||||
"wickra-darwin-x64": "0.1.4",
|
"wickra-darwin-x64": "0.4.0",
|
||||||
"wickra-linux-arm64-gnu": "0.1.4",
|
"wickra-linux-arm64-gnu": "0.4.0",
|
||||||
"wickra-linux-x64-gnu": "0.1.4",
|
"wickra-linux-x64-gnu": "0.4.0",
|
||||||
"wickra-win32-arm64-msvc": "0.1.4",
|
"wickra-win32-arm64-msvc": "0.4.0",
|
||||||
"wickra-win32-x64-msvc": "0.1.4"
|
"wickra-win32-x64-msvc": "0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra": {
|
"node_modules/wickra": {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Strategy example: Bollinger-Squeeze breakout with ATR-based stop.
|
||||||
|
//
|
||||||
|
// Enters long when the Bollinger Bandwidth has just printed a fresh 6-month low
|
||||||
|
// (the squeeze) and price closes above the upper band (the release). Exits when
|
||||||
|
// price closes below entry minus 2 * ATR(14), or when the upper band rolls back
|
||||||
|
// under the entry price. 0.1% fees per trade.
|
||||||
|
//
|
||||||
|
// Educational example. NOT a live trading recommendation. The Node counterpart
|
||||||
|
// of `examples/python/strategy_bollinger_squeeze.py` and the Rust
|
||||||
|
// `examples/rust/src/bin/strategy_bollinger_squeeze.rs`, printing the same
|
||||||
|
// summary.
|
||||||
|
//
|
||||||
|
// Build the native binding once, then run it:
|
||||||
|
//
|
||||||
|
// cd bindings/node && npm install && npx napi build --platform --release
|
||||||
|
// cd ../../examples/node && npm install
|
||||||
|
// node strategy_bollinger_squeeze.js
|
||||||
|
//
|
||||||
|
// Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because daily bars
|
||||||
|
// give an interpretable 6-month-low lookback (~180 bars).
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const wickra = require('wickra');
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const BB_PERIOD = 20;
|
||||||
|
const BB_K = 2.0;
|
||||||
|
const ATR_PERIOD = 14;
|
||||||
|
const ATR_STOP_MULT = 2.0;
|
||||||
|
const SQUEEZE_LOOKBACK = 180;
|
||||||
|
|
||||||
|
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
|
||||||
|
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
|
||||||
|
|
||||||
|
function loadCandles(csvPath) {
|
||||||
|
const text = fs.readFileSync(csvPath, 'utf8');
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new Error(`${csvPath}: file is empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',').map((cell) => cell.trim());
|
||||||
|
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
|
||||||
|
`found: ${header.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) {
|
||||||
|
throw new Error(`${csvPath}: CSV has a header but no data rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = {};
|
||||||
|
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
|
||||||
|
|
||||||
|
const candles = [];
|
||||||
|
for (let row = 1; row < lines.length; row++) {
|
||||||
|
const cells = lines[row].split(',');
|
||||||
|
const candle = {};
|
||||||
|
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
|
||||||
|
const raw = cells[idx[col]];
|
||||||
|
const value = raw === undefined ? NaN : Number(raw.trim());
|
||||||
|
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candle[col] = value;
|
||||||
|
}
|
||||||
|
candles.push(candle);
|
||||||
|
}
|
||||||
|
return candles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? '+' : '') + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const n = closedTrades.length;
|
||||||
|
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
|
||||||
|
const varRet =
|
||||||
|
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (s) => s.padEnd(23);
|
||||||
|
console.log(`=== ${name} ===`);
|
||||||
|
console.log(`${label('Bars:')}${bars}`);
|
||||||
|
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
|
||||||
|
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
|
||||||
|
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
|
||||||
|
console.log(
|
||||||
|
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
|
||||||
|
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
|
||||||
|
);
|
||||||
|
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
|
||||||
|
console.log();
|
||||||
|
console.log(
|
||||||
|
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
|
||||||
|
'are simplified or omitted. Past performance is not indicative of future results.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||||||
|
|
||||||
|
let candles;
|
||||||
|
try {
|
||||||
|
candles = loadCandles(csvPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (candles.length < SQUEEZE_LOOKBACK + BB_PERIOD) {
|
||||||
|
console.error(
|
||||||
|
`error: dataset has only ${candles.length} bars; need at least ` +
|
||||||
|
`${SQUEEZE_LOOKBACK + BB_PERIOD}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bb = new wickra.BollingerBands(BB_PERIOD, BB_K);
|
||||||
|
const atr = new wickra.ATR(ATR_PERIOD);
|
||||||
|
// Rolling window of the last SQUEEZE_LOOKBACK bandwidths (Python deque(maxlen)).
|
||||||
|
const bwWindow = [];
|
||||||
|
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
let stopLevel = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
|
||||||
|
for (const c of candles) {
|
||||||
|
const bbOut = bb.update(c.close);
|
||||||
|
const atrVal = atr.update(c.high, c.low, c.close);
|
||||||
|
const price = c.close;
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (bbOut == null || atrVal == null) continue;
|
||||||
|
|
||||||
|
const { upper, middle, lower } = bbOut;
|
||||||
|
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
|
||||||
|
|
||||||
|
if (Number.isNaN(bandwidth)) continue;
|
||||||
|
bwWindow.push(bandwidth);
|
||||||
|
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
|
||||||
|
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
|
||||||
|
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const stopHit = price < stopLevel;
|
||||||
|
const upperCollapse = upper < entryPrice;
|
||||||
|
if (stopHit || upperCollapse) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
|
||||||
|
const breakout = price > upper;
|
||||||
|
if (isNewLow && breakout) {
|
||||||
|
entryPrice = price;
|
||||||
|
stopLevel = price - ATR_STOP_MULT * atrVal;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const lastPrice = candles[candles.length - 1].close;
|
||||||
|
const tradeRet = lastPrice / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(
|
||||||
|
'Bollinger Squeeze Breakout (1d, BTCUSDT)',
|
||||||
|
candles[0].close,
|
||||||
|
candles[candles.length - 1].close,
|
||||||
|
candles.length,
|
||||||
|
closedTrades,
|
||||||
|
equity,
|
||||||
|
equityCurve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Strategy example: MACD crossover with ADX trend-strength filter.
|
||||||
|
//
|
||||||
|
// Long-only trend follower. Entries fire on a MACD-line-crosses-above-signal
|
||||||
|
// event (histogram turns positive) while ADX(14) > 20 (i.e. a directional
|
||||||
|
// market). Exits on the opposite MACD crossover regardless of ADX. 0.1% fees
|
||||||
|
// per trade.
|
||||||
|
//
|
||||||
|
// Educational example. NOT a live trading recommendation. The Node counterpart
|
||||||
|
// of `examples/python/strategy_macd_adx.py` and the Rust
|
||||||
|
// `examples/rust/src/bin/strategy_macd_adx.rs`, printing the same summary.
|
||||||
|
//
|
||||||
|
// Build the native binding once, then run it:
|
||||||
|
//
|
||||||
|
// cd bindings/node && npm install && npx napi build --platform --release
|
||||||
|
// cd ../../examples/node && npm install
|
||||||
|
// node strategy_macd_adx.js
|
||||||
|
//
|
||||||
|
// Uses the checked-in `examples/data/btcusdt-1h.csv` dataset.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const wickra = require('wickra');
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const ADX_FLOOR = 20.0;
|
||||||
|
|
||||||
|
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
|
||||||
|
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv');
|
||||||
|
|
||||||
|
function loadCandles(csvPath) {
|
||||||
|
const text = fs.readFileSync(csvPath, 'utf8');
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new Error(`${csvPath}: file is empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',').map((cell) => cell.trim());
|
||||||
|
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
|
||||||
|
`found: ${header.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) {
|
||||||
|
throw new Error(`${csvPath}: CSV has a header but no data rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = {};
|
||||||
|
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
|
||||||
|
|
||||||
|
const candles = [];
|
||||||
|
for (let row = 1; row < lines.length; row++) {
|
||||||
|
const cells = lines[row].split(',');
|
||||||
|
const candle = {};
|
||||||
|
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
|
||||||
|
const raw = cells[idx[col]];
|
||||||
|
const value = raw === undefined ? NaN : Number(raw.trim());
|
||||||
|
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candle[col] = value;
|
||||||
|
}
|
||||||
|
candles.push(candle);
|
||||||
|
}
|
||||||
|
return candles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? '+' : '') + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const n = closedTrades.length;
|
||||||
|
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
|
||||||
|
const varRet =
|
||||||
|
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (s) => s.padEnd(23);
|
||||||
|
console.log(`=== ${name} ===`);
|
||||||
|
console.log(`${label('Bars:')}${bars}`);
|
||||||
|
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
|
||||||
|
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
|
||||||
|
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
|
||||||
|
console.log(
|
||||||
|
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
|
||||||
|
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
|
||||||
|
);
|
||||||
|
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
|
||||||
|
console.log();
|
||||||
|
console.log(
|
||||||
|
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
|
||||||
|
'are simplified or omitted. Past performance is not indicative of future results.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||||||
|
|
||||||
|
let candles;
|
||||||
|
try {
|
||||||
|
candles = loadCandles(csvPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const macd = new wickra.MACD(12, 26, 9);
|
||||||
|
const adx = new wickra.ADX(14);
|
||||||
|
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
// null until the first warm bar, then a boolean — matches the Python
|
||||||
|
// `prev_hist_sign: bool | None`, so a cross only fires after a real prior sign.
|
||||||
|
let prevHistSign = null;
|
||||||
|
|
||||||
|
for (const c of candles) {
|
||||||
|
const macdOut = macd.update(c.close);
|
||||||
|
const adxOut = adx.update(c.high, c.low, c.close);
|
||||||
|
const price = c.close;
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (macdOut == null || adxOut == null) continue;
|
||||||
|
|
||||||
|
const histogram = macdOut.histogram;
|
||||||
|
const adxValue = adxOut.adx;
|
||||||
|
|
||||||
|
const histSign = histogram > 0.0;
|
||||||
|
const crossUp = prevHistSign === false && histSign;
|
||||||
|
const crossDown = prevHistSign === true && !histSign;
|
||||||
|
prevHistSign = histSign;
|
||||||
|
|
||||||
|
if (!inPosition && crossUp && adxValue > ADX_FLOOR) {
|
||||||
|
entryPrice = price;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
} else if (inPosition && crossDown) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const lastPrice = candles[candles.length - 1].close;
|
||||||
|
const tradeRet = lastPrice / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(
|
||||||
|
'MACD + ADX Trend Filter (1h, BTCUSDT)',
|
||||||
|
candles[0].close,
|
||||||
|
candles[candles.length - 1].close,
|
||||||
|
candles.length,
|
||||||
|
closedTrades,
|
||||||
|
equity,
|
||||||
|
equityCurve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Strategy example: RSI mean-reversion on hourly BTCUSDT data.
|
||||||
|
//
|
||||||
|
// Goes long when RSI(14) crosses below 30 (oversold), exits when RSI crosses
|
||||||
|
// above 70 (overbought). Position is binary (full-in / full-out), fees are
|
||||||
|
// 0.1% per trade (Binance maker tier), no stop-loss.
|
||||||
|
//
|
||||||
|
// Educational example. NOT a recommended trading strategy in real markets.
|
||||||
|
// The point is to show how Wickra streaming indicators wire up into a complete
|
||||||
|
// signal -> fill -> PnL -> equity loop in a single file. It is the Node
|
||||||
|
// counterpart of `examples/python/strategy_rsi_mean_reversion.py` and the Rust
|
||||||
|
// `examples/rust/src/bin/strategy_rsi_mean_reversion.rs`, and prints the same
|
||||||
|
// summary.
|
||||||
|
//
|
||||||
|
// Build the native binding once, then run it:
|
||||||
|
//
|
||||||
|
// cd bindings/node && npm install && npx napi build --platform --release
|
||||||
|
// cd ../../examples/node && npm install
|
||||||
|
// node strategy_rsi_mean_reversion.js
|
||||||
|
//
|
||||||
|
// Uses the checked-in `examples/data/btcusdt-1h.csv` dataset.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const wickra = require('wickra');
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const RSI_PERIOD = 14;
|
||||||
|
const OVERSOLD = 30.0;
|
||||||
|
const OVERBOUGHT = 70.0;
|
||||||
|
|
||||||
|
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
|
||||||
|
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv');
|
||||||
|
|
||||||
|
// Parse a plain OHLCV CSV into an array of candle objects. The Wickra CSV
|
||||||
|
// layout is plain numeric — no quoted fields, no embedded commas — so splitting
|
||||||
|
// on `,` is a complete and correct parse for it.
|
||||||
|
function loadCandles(csvPath) {
|
||||||
|
const text = fs.readFileSync(csvPath, 'utf8');
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new Error(`${csvPath}: file is empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',').map((cell) => cell.trim());
|
||||||
|
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
|
||||||
|
`found: ${header.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) {
|
||||||
|
throw new Error(`${csvPath}: CSV has a header but no data rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = {};
|
||||||
|
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
|
||||||
|
|
||||||
|
const candles = [];
|
||||||
|
for (let row = 1; row < lines.length; row++) {
|
||||||
|
const cells = lines[row].split(',');
|
||||||
|
const candle = {};
|
||||||
|
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
|
||||||
|
const raw = cells[idx[col]];
|
||||||
|
const value = raw === undefined ? NaN : Number(raw.trim());
|
||||||
|
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candle[col] = value;
|
||||||
|
}
|
||||||
|
candles.push(candle);
|
||||||
|
}
|
||||||
|
return candles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced-sign fixed-point (matches Python's `{:+.Nf}`): the value's own minus is
|
||||||
|
// preserved by toFixed; we only add an explicit '+' for non-negative values.
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? '+' : '') + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const n = closedTrades.length;
|
||||||
|
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
|
||||||
|
const varRet =
|
||||||
|
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (s) => s.padEnd(23);
|
||||||
|
console.log(`=== ${name} ===`);
|
||||||
|
console.log(`${label('Bars:')}${bars}`);
|
||||||
|
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
|
||||||
|
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
|
||||||
|
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
|
||||||
|
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
|
||||||
|
console.log(
|
||||||
|
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
|
||||||
|
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
|
||||||
|
);
|
||||||
|
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
|
||||||
|
console.log();
|
||||||
|
console.log(
|
||||||
|
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
|
||||||
|
'are simplified or omitted. Past performance is not indicative of future results.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||||||
|
|
||||||
|
let candles;
|
||||||
|
try {
|
||||||
|
candles = loadCandles(csvPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (candles.length < RSI_PERIOD * 4) {
|
||||||
|
console.error(`error: dataset too small: ${candles.length}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsi = new wickra.RSI(RSI_PERIOD);
|
||||||
|
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
|
||||||
|
for (const c of candles) {
|
||||||
|
const rsiVal = rsi.update(c.close);
|
||||||
|
const price = c.close;
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (rsiVal == null) continue;
|
||||||
|
|
||||||
|
if (!inPosition && rsiVal < OVERSOLD) {
|
||||||
|
entryPrice = price;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
} else if (inPosition && rsiVal > OVERBOUGHT) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const lastPrice = candles[candles.length - 1].close;
|
||||||
|
const tradeRet = lastPrice / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(
|
||||||
|
'RSI Mean-Reversion (1h, BTCUSDT)',
|
||||||
|
candles[0].close,
|
||||||
|
candles[candles.length - 1].close,
|
||||||
|
candles.length,
|
||||||
|
closedTrades,
|
||||||
|
equity,
|
||||||
|
equityCurve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+14
-1
@@ -42,10 +42,23 @@ Then open the demo you want at `http://localhost:8000/examples/wasm/<file>`.
|
|||||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up in-page to 5m / 15m / 1h / 4h / 1d buckets and prints RSI / MACD-histogram / ADX per timeframe. Mirrors `examples/python/multi_timeframe.py`. |
|
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up in-page to 5m / 15m / 1h / 4h / 1d buckets and prints RSI / MACD-histogram / ADX per timeframe. Mirrors `examples/python/multi_timeframe.py`. |
|
||||||
| `parallel_assets.html` | Synthetic `(assets, bars)` panel, serial baseline on the main thread vs. a pool of module Workers each loading its own copy of the WASM module. Mirrors `examples/python/parallel_assets.py`. |
|
| `parallel_assets.html` | Synthetic `(assets, bars)` panel, serial baseline on the main thread vs. a pool of module Workers each loading its own copy of the WASM module. Mirrors `examples/python/parallel_assets.py`. |
|
||||||
| `parallel_worker.js` | Module worker used by `parallel_assets.html` (not loaded directly). |
|
| `parallel_worker.js` | Module worker used by `parallel_assets.html` (not loaded directly). |
|
||||||
|
| `strategy_rsi_mean_reversion.html` | RSI(14) mean-reversion (long < 30, exit > 70), 0.1% fees, summary table. Mirrors `examples/python/strategy_rsi_mean_reversion.py`. |
|
||||||
|
| `strategy_macd_adx.html` | MACD(12,26,9) crossover gated by ADX(14) > 20, summary table. Mirrors `examples/python/strategy_macd_adx.py`. |
|
||||||
|
| `strategy_bollinger_squeeze.html` | Bollinger-squeeze breakout with a 2×ATR(14) stop, summary table. Mirrors `examples/python/strategy_bollinger_squeeze.py`. |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
The in-browser benchmark is `parallel_assets.html`: it times a serial main-thread
|
||||||
|
baseline against a pool of module Workers and reports the speedup. For raw
|
||||||
|
single-thread throughput numbers see the sibling benchmarks — Rust criterion
|
||||||
|
(`crates/wickra/benches/`), Python (`bindings/python/benchmarks/compare_libraries.py`)
|
||||||
|
and Node (`bindings/node/benchmarks/throughput.js`, `npm run bench`). The WASM
|
||||||
|
engine is the same Rust core compiled to `wasm32`, so its relative ordering of
|
||||||
|
indicators tracks those.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [Quickstart: WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md) — module-load
|
- [Quickstart: WASM](https://docs.wickra.org/Quickstart-WASM) — module-load
|
||||||
flow, `wasm-pack` targets, and the streaming API.
|
flow, `wasm-pack` targets, and the streaming API.
|
||||||
- [examples/README.md](../README.md) — cross-language index, including
|
- [examples/README.md](../README.md) — cross-language index, including
|
||||||
the Rust, Python and Node siblings of every demo above.
|
the Rust, Python and Node siblings of every demo above.
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Wickra WASM — Bollinger-squeeze breakout</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||||
|
h1 { margin-bottom: .25rem; }
|
||||||
|
.meta { color: #666; margin-top: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
th { background: #fafafa; }
|
||||||
|
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||||
|
label { display: inline-block; margin-right: .5rem; }
|
||||||
|
button { padding: .5rem 1rem; }
|
||||||
|
#status { margin-top: 1rem; color: #666; }
|
||||||
|
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Wickra WASM — Bollinger-squeeze breakout</h1>
|
||||||
|
<p class="meta">
|
||||||
|
Enters long on a fresh 180-bar Bollinger-bandwidth low (the squeeze) with a
|
||||||
|
close above the upper band (the release); exits on a 2×ATR(14) stop or
|
||||||
|
an upper-band collapse, 0.1% fees. The browser counterpart of
|
||||||
|
<code>examples/python/strategy_bollinger_squeeze.py</code>,
|
||||||
|
<code>examples/node/strategy_bollinger_squeeze.js</code> and the Rust
|
||||||
|
<code>strategy_bollinger_squeeze.rs</code> — same loop, same summary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1d.csv" size="40" /></label>
|
||||||
|
<button id="go" disabled>Run strategy</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p id="status">Loading WASM module…</p>
|
||||||
|
|
||||||
|
<table id="results" hidden>
|
||||||
|
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="note" id="disclaimer" hidden>
|
||||||
|
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||||
|
are simplified or omitted. Past performance is not indicative of future
|
||||||
|
results.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import init, { version, installPanicHook, BollingerBands, ATR } from "../../bindings/wasm/pkg/wickra_wasm.js";
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const BB_PERIOD = 20;
|
||||||
|
const BB_K = 2.0;
|
||||||
|
const ATR_PERIOD = 14;
|
||||||
|
const ATR_STOP_MULT = 2.0;
|
||||||
|
const SQUEEZE_LOOKBACK = 180;
|
||||||
|
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
|
||||||
|
|
||||||
|
function parseCsv(text) {
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) throw new Error("file is empty");
|
||||||
|
const header = lines[0].split(",").map((s) => s.trim());
|
||||||
|
const missing = REQUIRED.filter((c) => !header.includes(c));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
|
||||||
|
const idx = {};
|
||||||
|
for (const c of REQUIRED) idx[c] = header.indexOf(c);
|
||||||
|
const cols = { open: [], high: [], low: [], close: [], volume: [] };
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = lines[i].split(",");
|
||||||
|
for (const c of ["open", "high", "low", "close", "volume"]) {
|
||||||
|
const v = Number(cells[idx[c]]);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
|
||||||
|
}
|
||||||
|
cols[c].push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStrategy(cols) {
|
||||||
|
const n = cols.close.length;
|
||||||
|
if (n < SQUEEZE_LOOKBACK + BB_PERIOD) {
|
||||||
|
throw new Error(`dataset has only ${n} bars; need at least ${SQUEEZE_LOOKBACK + BB_PERIOD}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bb = new BollingerBands(BB_PERIOD, BB_K);
|
||||||
|
const atr = new ATR(ATR_PERIOD);
|
||||||
|
const bwWindow = [];
|
||||||
|
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
let stopLevel = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const price = cols.close[i];
|
||||||
|
const bbOut = bb.update(price);
|
||||||
|
const atrVal = atr.update(cols.high[i], cols.low[i], price);
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (bbOut == null || atrVal == null) continue;
|
||||||
|
|
||||||
|
const { upper, middle, lower } = bbOut;
|
||||||
|
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
|
||||||
|
if (Number.isNaN(bandwidth)) continue;
|
||||||
|
|
||||||
|
bwWindow.push(bandwidth);
|
||||||
|
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
|
||||||
|
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
|
||||||
|
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const stopHit = price < stopLevel;
|
||||||
|
const upperCollapse = upper < entryPrice;
|
||||||
|
if (stopHit || upperCollapse) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
|
||||||
|
const breakout = price > upper;
|
||||||
|
if (isNewLow && breakout) {
|
||||||
|
entryPrice = price;
|
||||||
|
stopLevel = price - ATR_STOP_MULT * atrVal;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarise("Bollinger Squeeze Breakout (1d, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarise(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const nT = closedTrades.length;
|
||||||
|
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||||
|
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
["Strategy", name],
|
||||||
|
["Bars", String(bars)],
|
||||||
|
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||||
|
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||||
|
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||||
|
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||||
|
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||||
|
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||||
|
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows) {
|
||||||
|
const tbody = document.querySelector("#results tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
document.getElementById("results").hidden = false;
|
||||||
|
document.getElementById("disclaimer").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const path = document.getElementById("path").value;
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
status.textContent = `Fetching ${path}…`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(path);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||||
|
const cols = parseCsv(await resp.text());
|
||||||
|
status.textContent = `Running Bollinger squeeze over ${cols.close.length} bars…`;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
render(runStrategy(cols));
|
||||||
|
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `error: ${err.message || err}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("go").onclick = run;
|
||||||
|
|
||||||
|
init().then(() => {
|
||||||
|
installPanicHook();
|
||||||
|
document.getElementById("go").disabled = false;
|
||||||
|
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Wickra WASM — MACD + ADX trend filter</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||||
|
h1 { margin-bottom: .25rem; }
|
||||||
|
.meta { color: #666; margin-top: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
th { background: #fafafa; }
|
||||||
|
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||||
|
label { display: inline-block; margin-right: .5rem; }
|
||||||
|
button { padding: .5rem 1rem; }
|
||||||
|
#status { margin-top: 1rem; color: #666; }
|
||||||
|
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Wickra WASM — MACD + ADX trend filter</h1>
|
||||||
|
<p class="meta">
|
||||||
|
Long-only trend follower: enters on a MACD(12,26,9) histogram crossover up
|
||||||
|
while ADX(14) > 20, exits on the opposite crossover, 0.1% fees. The
|
||||||
|
browser counterpart of <code>examples/python/strategy_macd_adx.py</code>,
|
||||||
|
<code>examples/node/strategy_macd_adx.js</code> and the Rust
|
||||||
|
<code>strategy_macd_adx.rs</code> — same loop, same summary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1h.csv" size="40" /></label>
|
||||||
|
<button id="go" disabled>Run strategy</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p id="status">Loading WASM module…</p>
|
||||||
|
|
||||||
|
<table id="results" hidden>
|
||||||
|
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="note" id="disclaimer" hidden>
|
||||||
|
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||||
|
are simplified or omitted. Past performance is not indicative of future
|
||||||
|
results.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import init, { version, installPanicHook, MACD, ADX } from "../../bindings/wasm/pkg/wickra_wasm.js";
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const ADX_FLOOR = 20.0;
|
||||||
|
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
|
||||||
|
|
||||||
|
function parseCsv(text) {
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) throw new Error("file is empty");
|
||||||
|
const header = lines[0].split(",").map((s) => s.trim());
|
||||||
|
const missing = REQUIRED.filter((c) => !header.includes(c));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
|
||||||
|
const idx = {};
|
||||||
|
for (const c of REQUIRED) idx[c] = header.indexOf(c);
|
||||||
|
const cols = { open: [], high: [], low: [], close: [], volume: [] };
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = lines[i].split(",");
|
||||||
|
for (const c of ["open", "high", "low", "close", "volume"]) {
|
||||||
|
const v = Number(cells[idx[c]]);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
|
||||||
|
}
|
||||||
|
cols[c].push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStrategy(cols) {
|
||||||
|
const n = cols.close.length;
|
||||||
|
const macd = new MACD(12, 26, 9);
|
||||||
|
const adx = new ADX(14);
|
||||||
|
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
let prevHistSign = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const price = cols.close[i];
|
||||||
|
const macdOut = macd.update(price);
|
||||||
|
const adxOut = adx.update(cols.high[i], cols.low[i], price);
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (macdOut == null || adxOut == null) continue;
|
||||||
|
|
||||||
|
const histSign = macdOut.histogram > 0.0;
|
||||||
|
const crossUp = prevHistSign === false && histSign;
|
||||||
|
const crossDown = prevHistSign === true && !histSign;
|
||||||
|
prevHistSign = histSign;
|
||||||
|
|
||||||
|
if (!inPosition && crossUp && adxOut.adx > ADX_FLOOR) {
|
||||||
|
entryPrice = price;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
} else if (inPosition && crossDown) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarise("MACD + ADX Trend Filter (1h, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarise(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const nT = closedTrades.length;
|
||||||
|
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||||
|
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
["Strategy", name],
|
||||||
|
["Bars", String(bars)],
|
||||||
|
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||||
|
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||||
|
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||||
|
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||||
|
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||||
|
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||||
|
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows) {
|
||||||
|
const tbody = document.querySelector("#results tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
document.getElementById("results").hidden = false;
|
||||||
|
document.getElementById("disclaimer").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const path = document.getElementById("path").value;
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
status.textContent = `Fetching ${path}…`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(path);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||||
|
const cols = parseCsv(await resp.text());
|
||||||
|
status.textContent = `Running MACD + ADX over ${cols.close.length} bars…`;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
render(runStrategy(cols));
|
||||||
|
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `error: ${err.message || err}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("go").onclick = run;
|
||||||
|
|
||||||
|
init().then(() => {
|
||||||
|
installPanicHook();
|
||||||
|
document.getElementById("go").disabled = false;
|
||||||
|
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Wickra WASM — RSI mean-reversion</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||||
|
h1 { margin-bottom: .25rem; }
|
||||||
|
.meta { color: #666; margin-top: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
th { background: #fafafa; }
|
||||||
|
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||||
|
label { display: inline-block; margin-right: .5rem; }
|
||||||
|
button { padding: .5rem 1rem; }
|
||||||
|
#status { margin-top: 1rem; color: #666; }
|
||||||
|
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Wickra WASM — RSI mean-reversion</h1>
|
||||||
|
<p class="meta">
|
||||||
|
Goes long when RSI(14) crosses below 30 and exits above 70, with 0.1% fees
|
||||||
|
and a full-in / full-out position. The browser counterpart of
|
||||||
|
<code>examples/python/strategy_rsi_mean_reversion.py</code>,
|
||||||
|
<code>examples/node/strategy_rsi_mean_reversion.js</code> and the Rust
|
||||||
|
<code>strategy_rsi_mean_reversion.rs</code> — same signal → fill → PnL →
|
||||||
|
equity loop, same summary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1h.csv" size="40" /></label>
|
||||||
|
<button id="go" disabled>Run strategy</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p id="status">Loading WASM module…</p>
|
||||||
|
|
||||||
|
<table id="results" hidden>
|
||||||
|
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="note" id="disclaimer" hidden>
|
||||||
|
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||||
|
are simplified or omitted. Past performance is not indicative of future
|
||||||
|
results.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import init, { version, installPanicHook, RSI } from "../../bindings/wasm/pkg/wickra_wasm.js";
|
||||||
|
|
||||||
|
const FEE = 0.001;
|
||||||
|
const RSI_PERIOD = 14;
|
||||||
|
const OVERSOLD = 30.0;
|
||||||
|
const OVERBOUGHT = 70.0;
|
||||||
|
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
|
||||||
|
|
||||||
|
// Plain OHLCV CSV — the Wickra layout never quotes values nor embeds commas,
|
||||||
|
// so split-on-comma is a complete parse.
|
||||||
|
function parseCsv(text) {
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) throw new Error("file is empty");
|
||||||
|
const header = lines[0].split(",").map((s) => s.trim());
|
||||||
|
const missing = REQUIRED.filter((c) => !header.includes(c));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
|
||||||
|
const idx = {};
|
||||||
|
for (const c of REQUIRED) idx[c] = header.indexOf(c);
|
||||||
|
const cols = { open: [], high: [], low: [], close: [], volume: [] };
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = lines[i].split(",");
|
||||||
|
for (const c of ["open", "high", "low", "close", "volume"]) {
|
||||||
|
const v = Number(cells[idx[c]]);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
|
||||||
|
}
|
||||||
|
cols[c].push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced-sign fixed-point, matching the CLI summaries' `{:+.Nf}`.
|
||||||
|
function signed(value, digits) {
|
||||||
|
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStrategy(cols) {
|
||||||
|
const n = cols.close.length;
|
||||||
|
if (n < RSI_PERIOD * 4) throw new Error(`dataset too small: ${n}`);
|
||||||
|
|
||||||
|
const rsi = new RSI(RSI_PERIOD);
|
||||||
|
let inPosition = false;
|
||||||
|
let entryPrice = 0.0;
|
||||||
|
const closedTrades = [];
|
||||||
|
let equity = 1.0;
|
||||||
|
const equityCurve = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const price = cols.close[i];
|
||||||
|
const rsiVal = rsi.update(price);
|
||||||
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||||
|
equityCurve.push(mtm);
|
||||||
|
|
||||||
|
if (rsiVal == null) continue;
|
||||||
|
|
||||||
|
if (!inPosition && rsiVal < OVERSOLD) {
|
||||||
|
entryPrice = price;
|
||||||
|
equity *= 1.0 - FEE;
|
||||||
|
inPosition = true;
|
||||||
|
} else if (inPosition && rsiVal > OVERBOUGHT) {
|
||||||
|
const tradeRet = price / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
inPosition = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPosition) {
|
||||||
|
const tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||||
|
closedTrades.push(tradeRet);
|
||||||
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarise("RSI Mean-Reversion (1h, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarise(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||||||
|
const buyHold = lastPrice / firstPrice;
|
||||||
|
const stratReturn = finalEquity - 1.0;
|
||||||
|
const bhReturn = buyHold - 1.0;
|
||||||
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||||||
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||||||
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||||||
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||||||
|
const nT = closedTrades.length;
|
||||||
|
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||||
|
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 1) : 0.0;
|
||||||
|
const stddev = Math.sqrt(varRet);
|
||||||
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||||||
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||||||
|
let maxDd = 0.0;
|
||||||
|
for (const eq of equityCurve) {
|
||||||
|
if (eq > peak) peak = eq;
|
||||||
|
const dd = (peak - eq) / peak;
|
||||||
|
if (dd > maxDd) maxDd = dd;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
["Strategy", name],
|
||||||
|
["Bars", String(bars)],
|
||||||
|
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||||
|
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||||
|
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||||
|
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||||
|
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||||
|
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||||
|
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows) {
|
||||||
|
const tbody = document.querySelector("#results tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
document.getElementById("results").hidden = false;
|
||||||
|
document.getElementById("disclaimer").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const path = document.getElementById("path").value;
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
status.textContent = `Fetching ${path}…`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(path);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||||
|
const cols = parseCsv(await resp.text());
|
||||||
|
status.textContent = `Running RSI mean-reversion over ${cols.close.length} bars…`;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
render(runStrategy(cols));
|
||||||
|
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `error: ${err.message || err}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("go").onclick = run;
|
||||||
|
|
||||||
|
init().then(() => {
|
||||||
|
installPanicHook();
|
||||||
|
document.getElementById("go").disabled = false;
|
||||||
|
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+3
-3
@@ -10,8 +10,8 @@
|
|||||||
org = "wickra-lib"
|
org = "wickra-lib"
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
url = "https://github.com/wickra-lib/wickra"
|
url = "https://github.com/wickra-lib/wickra"
|
||||||
wiki_url = "https://github.com/wickra-lib/wickra/wiki"
|
docs_url = "https://docs.wickra.org"
|
||||||
wiki_git = "https://github.com/wickra-lib/wickra.wiki.git"
|
docs_git = "https://github.com/wickra-lib/wickra-docs"
|
||||||
issues_url = "https://github.com/wickra-lib/wickra/issues"
|
issues_url = "https://github.com/wickra-lib/wickra/issues"
|
||||||
discussions = "https://github.com/wickra-lib/wickra/discussions"
|
discussions = "https://github.com/wickra-lib/wickra/discussions"
|
||||||
security_url = "https://github.com/wickra-lib/wickra/security/advisories/new"
|
security_url = "https://github.com/wickra-lib/wickra/security/advisories/new"
|
||||||
@@ -21,7 +21,7 @@ security_url = "https://github.com/wickra-lib/wickra/security/advisories/new"
|
|||||||
# not move with the GitHub org transfer. `github` is the org @-mention slug
|
# not move with the GitHub org transfer. `github` is the org @-mention slug
|
||||||
# used in CODEOWNERS and prose.
|
# used in CODEOWNERS and prose.
|
||||||
handle = "kingchenc"
|
handle = "kingchenc"
|
||||||
email = "wickra.lib@gmail.com"
|
email = "support@wickra.org"
|
||||||
github = "wickra-lib"
|
github = "wickra-lib"
|
||||||
|
|
||||||
[badges]
|
[badges]
|
||||||
|
|||||||
Reference in New Issue
Block a user