Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab2649f1c | |||
| f7f947e048 | |||
| 01dd08714b | |||
| 0fa70c9882 | |||
| debe4523d5 | |||
| a046c441e5 | |||
| f7b91f6fa5 | |||
| 5030360a0c | |||
| 1dd487fabc | |||
| cee174c0de | |||
| c8e5d8a658 | |||
| dc2e19e762 | |||
| d8212beff6 | |||
| 86a595d505 | |||
| 9309bf9d60 | |||
| 3f05342f72 | |||
| eb4454ab27 | |||
| 3093f194a2 | |||
| 21c86f348f | |||
| a2ff35f5f9 | |||
| 01aeb965d1 | |||
| f1fed6cdd5 |
@@ -3,4 +3,3 @@
|
||||
# Leave a key empty (e.g. patreon:) to skip a platform.
|
||||
|
||||
github: [kingchenc]
|
||||
custom: ["https://wickra.org/sponsor"]
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
|
||||
and the `README.md` are updated (If applicable). Wiki edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
|
||||
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
@@ -35,8 +35,25 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -56,10 +73,16 @@ jobs:
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
# workflow_dispatch inputs are untrusted; pass them through the
|
||||
# environment and quote them rather than interpolating into the shell
|
||||
# command (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
|
||||
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries \
|
||||
--size ${{ github.event.inputs.size || '20000' }} \
|
||||
--iterations ${{ github.event.inputs.iterations || '10' }} \
|
||||
--size "$BENCH_SIZE" \
|
||||
--iterations "$BENCH_ITERATIONS" \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -55,6 +57,95 @@ jobs:
|
||||
# streaming.
|
||||
run: cargo build -p wickra-examples --bins
|
||||
|
||||
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
|
||||
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
|
||||
# browser-WASM and Python examples otherwise have no build gate, so a broken
|
||||
# edit could land unnoticed. This is a parse-only smoke — actually running the
|
||||
# examples needs the built native binding / wasm module / wheel, which the
|
||||
# binding jobs provide separately.
|
||||
examples-smoke:
|
||||
name: Examples (syntax smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Node examples — syntax check
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/node/*.js examples/wasm/*.js; do
|
||||
echo "node --check $f"
|
||||
node --check "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "checked $count Node/WASM .js files"
|
||||
|
||||
- name: WASM demo module scripts — syntax check
|
||||
# The .html demos embed an ES module; extract it and parse-check so a
|
||||
# broken edit to the in-page strategy logic fails CI.
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/wasm/*.html; do
|
||||
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
|
||||
echo "node --check (module of) $f"
|
||||
node --check module-check.mjs
|
||||
count=$((count + 1))
|
||||
done
|
||||
rm -f module-check.mjs
|
||||
echo "checked $count WASM .html module scripts"
|
||||
|
||||
- name: Python examples — byte-compile
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/python/*.py; do
|
||||
echo "py_compile $f"
|
||||
python -m py_compile "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "compiled $count Python files"
|
||||
|
||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||
@@ -71,17 +162,49 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
@@ -118,6 +241,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
@@ -139,9 +264,12 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
@@ -191,6 +319,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
with:
|
||||
workspaces: fuzz
|
||||
|
||||
@@ -203,6 +333,7 @@ jobs:
|
||||
# never gets off the ground. The prebuilt binary avoids the entire
|
||||
# transitive-dep compile.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
@@ -242,6 +373,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# setup-python downloads the interpreter from the Actions tool cache /
|
||||
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
||||
@@ -304,6 +437,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install wasm-pack
|
||||
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
|
||||
@@ -314,6 +449,7 @@ jobs:
|
||||
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||
# release, which has `--features` as a top-level flag (since 0.12).
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -345,6 +481,8 @@ jobs:
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
||||
# Windows runners with "Attempting to download 18..." followed by a
|
||||
|
||||
@@ -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 }}"
|
||||
+121
-10
@@ -26,6 +26,8 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# Idempotent publishing: if the version is already on crates.io we
|
||||
# treat that as success so re-runs of the workflow don't fail.
|
||||
@@ -85,16 +87,21 @@ jobs:
|
||||
# re-resolving Cargo.lock.
|
||||
- name: Install cargo-cyclonedx
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
|
||||
- name: Generate CycloneDX SBOMs
|
||||
run: |
|
||||
cargo cyclonedx --format json --top-level -p wickra-core
|
||||
cargo cyclonedx --format json --top-level -p wickra-data
|
||||
cargo cyclonedx --format json --top-level -p wickra
|
||||
# cargo-cyclonedx walks the whole workspace in a single pass and
|
||||
# writes a <package>.cdx.json next to each member's Cargo.toml; it
|
||||
# has no -p/--package selector. Collect the three crates.io crates
|
||||
# (the .crate files published by this job) into the upload dir.
|
||||
cargo cyclonedx --format json --top-level
|
||||
mkdir -p sboms
|
||||
find . -name "*.cdx.json" -not -path "./target/*" -exec cp {} sboms/ \;
|
||||
cp crates/wickra-core/wickra-core.cdx.json sboms/
|
||||
cp crates/wickra-data/wickra-data.cdx.json sboms/
|
||||
cp crates/wickra/wickra.cdx.json sboms/
|
||||
ls -lh sboms/
|
||||
|
||||
- name: Upload SBOMs
|
||||
@@ -127,7 +134,21 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Sync root README into bindings/python so it ships with the wheel
|
||||
@@ -202,7 +223,23 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
@@ -211,6 +248,8 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
@@ -243,7 +282,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -393,7 +449,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -406,6 +479,7 @@ jobs:
|
||||
# See the matching note in ci.yml: jetli's default installs an old
|
||||
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -424,7 +498,7 @@ jobs:
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
||||
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
||||
pkg.author = 'kingchenc <support@wickra.org>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||
@@ -537,4 +611,41 @@ jobs:
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Build provenance attestations (findings P13.2)
|
||||
# --------------------------------------------------------------------------
|
||||
attestations:
|
||||
name: Attest build provenance
|
||||
needs: [cargo-publish, python-wheels, python-sdist]
|
||||
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)
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
|
||||
# — synced on push to main / v* tag
|
||||
# 4. site/index.md (local-only marketing site, not synced from CI)
|
||||
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||
# .vitepress/config.ts / 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
|
||||
# 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.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
|
||||
# controlled on fork PRs) are passed through the environment, never
|
||||
# interpolated straight into the shell, so a crafted branch name cannot
|
||||
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
@@ -72,7 +101,7 @@ jobs:
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
@@ -134,12 +163,18 @@ jobs:
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
# head_ref still carries the (untrusted) PR branch name forwarded by the
|
||||
# ctx step; pass it through the environment so the push refspec cannot be
|
||||
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
COUNT: ${{ steps.count.outputs.count }}
|
||||
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
|
||||
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
|
||||
git commit -m "chore: sync indicator count to ${COUNT}"
|
||||
git push origin "HEAD:${HEAD_REF}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
@@ -150,36 +185,240 @@ jobs:
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
|
||||
- name: Update GitHub About description
|
||||
- name: Update GitHub About (description + homepage)
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
# Canonical homepage — the docs site (P8.3). This is enforced on every
|
||||
# run, so it must only point at docs.wickra.org once that domain is
|
||||
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||
# gated on the domain resolving, otherwise the About link would 404.
|
||||
homepage="https://docs.wickra.org"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||
# Same Administration-write permission as --description (no extra scope).
|
||||
gh repo edit --homepage "$homepage"
|
||||
current=$(gh repo view --json description -q .description)
|
||||
if [ "$current" = "$desc" ]; then
|
||||
echo "About unchanged."
|
||||
echo "About description unchanged; homepage enforced."
|
||||
else
|
||||
gh repo edit --description "$desc"
|
||||
echo "About updated."
|
||||
echo "About description + homepage updated."
|
||||
fi
|
||||
|
||||
- name: Sync Wiki
|
||||
# Counter sync target moved from the retired GitHub wiki to the docs site
|
||||
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
|
||||
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
|
||||
# org steps so a token/scope gap never fails the run. Uses its own clone
|
||||
# dir (docs-count) so it cannot collide with the tag-only version step
|
||||
# below, which clones the same repo into `docs`.
|
||||
- name: Sync docs indicator count (wickra-docs)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
|
||||
cd wiki
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd docs-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki unchanged."
|
||||
echo "Docs indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md FAQ.md Streaming-vs-Batch.md
|
||||
git add index.md overview.md Indicators-Overview.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
git push
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Docs indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
# ----- 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
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Audit repo-metadata.toml drift
|
||||
|
||||
+7
-3
@@ -44,10 +44,14 @@ tarpaulin-report.html
|
||||
# Node binding artifacts
|
||||
**/node_modules/
|
||||
bindings/node/*.node
|
||||
bindings/node/index.d.ts
|
||||
bindings/node/npm-debug.log*
|
||||
# package-lock.json is committed (under bindings/node/) so contributors
|
||||
# get reproducible npm installs. Top-level lockfiles still aren't expected.
|
||||
# index.js + index.d.ts are generated by `napi build` but committed (a matched
|
||||
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
|
||||
# regenerating both when a binding's public API changes.
|
||||
# package-lock.json is committed for the tracked Node packages — bindings/node/
|
||||
# and examples/node/ — so contributors get reproducible npm installs. There is
|
||||
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
|
||||
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
|
||||
|
||||
# WASM build output
|
||||
bindings/wasm/pkg/
|
||||
|
||||
+56
-1
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
- **Release pipeline — CycloneDX SBOM generation.** `cargo-cyclonedx` has no
|
||||
`-p`/`--package` selector; it walks the whole workspace in a single pass.
|
||||
The `release.yml` SBOM step invoked it as `cargo cyclonedx … -p <crate>` and
|
||||
aborted with `error: unexpected argument '-p' found`, which failed the
|
||||
crates.io publish job *after* the crates were already published and skipped
|
||||
the GitHub Release attach-assets job (no release page, no SBOM artefacts).
|
||||
The step now runs a single workspace pass and collects the three crates.io
|
||||
crate SBOMs. No library changes relative to 0.3.0 — this patch republishes
|
||||
the same code with a working release pipeline.
|
||||
|
||||
## [0.3.0] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
|
||||
pragmatically as standard `Indicator`s rather than a separate
|
||||
@@ -817,7 +869,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.2.7...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.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.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
||||
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ message: >-
|
||||
type: software
|
||||
authors:
|
||||
- alias: kingchenc
|
||||
email: wickra.lib@gmail.com
|
||||
email: support@wickra.org
|
||||
repository-code: "https://github.com/wickra-lib/wickra"
|
||||
url: "https://wickra.org"
|
||||
abstract: >-
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ project in public spaces.
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
|
||||
at **support@wickra.org**. All reports will be reviewed and investigated
|
||||
promptly and fairly, and the maintainer will respect the privacy and security
|
||||
of the reporter.
|
||||
|
||||
|
||||
+25
-5
@@ -35,8 +35,13 @@ cargo test --workspace
|
||||
cargo test -p wickra-data --features live-binance
|
||||
```
|
||||
|
||||
The minimum supported Rust version is **1.75** for the workspace crates and
|
||||
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
|
||||
The minimum supported Rust version is **1.86** for the workspace crates and
|
||||
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
|
||||
not chosen freely — they are the lowest versions our dependencies allow
|
||||
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
|
||||
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
|
||||
the library builds for the widest possible audience; please don't raise it
|
||||
without a dependency that actually requires it.
|
||||
|
||||
### Python
|
||||
|
||||
@@ -63,6 +68,21 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
wasm-pack test --node bindings/wasm
|
||||
```
|
||||
|
||||
## Lockfile policy
|
||||
|
||||
| Component | Lockfile | Tracked? | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
|
||||
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
|
||||
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
|
||||
| `bindings/python` | — | n/a (no lockfile) | 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
|
||||
|
||||
- **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
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
- **Docs.** Update the relevant page on the
|
||||
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
|
||||
`README.md` when behaviour or the public API changes. The Wiki lives in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
[documentation site](https://docs.wickra.org) and the
|
||||
`README.md` when behaviour or the public API changes. The docs live in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
authors = ["kingchenc <wickra.lib@gmail.com>"]
|
||||
version = "0.4.0"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.3.0" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.0" }
|
||||
|
||||
thiserror = "2"
|
||||
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/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](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.**
|
||||
|
||||
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
# Roadmap
|
||||
|
||||
What Wickra is heading toward, what is explicitly out of scope, and what
|
||||
contributors can expect across the next 0.x versions. Roadmap items are
|
||||
**aspirations, not commitments** — order may shift based on real
|
||||
user-feedback and bug-priority.
|
||||
|
||||
For "what shipped already" see [`CHANGELOG.md`](CHANGELOG.md). For the
|
||||
internal structure that the roadmap items will plug into, see
|
||||
[`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
## North star
|
||||
|
||||
> A streaming-first technical-analysis core that is the obvious default
|
||||
> for anyone writing a Rust, Python, Node or browser-based trading
|
||||
> system — drop-in fast, drop-in correct, drop-in tested.
|
||||
|
||||
Three measurable proxies for "obvious default":
|
||||
|
||||
1. **Coverage.** Every textbook indicator from TA-Lib, pandas-ta and
|
||||
talipp is in Wickra and produces matching reference values.
|
||||
2. **Performance.** Streaming `update` is the fastest published number
|
||||
across Rust / Python / Node / WASM for any technical indicator.
|
||||
3. **Trust.** Releases are reproducible, signed, SBOM-attached, with a
|
||||
public 100% test/branch coverage badge.
|
||||
|
||||
## 0.3.0 — target window: Q3 2026
|
||||
|
||||
The first release after the org migration to `wickra-lib` lands and the
|
||||
project portal at `wickra.org` is live.
|
||||
|
||||
### Headline goals
|
||||
|
||||
- **WASM has automated tests.** `wasm-bindgen-test` job in CI exercising
|
||||
a representative subset (~30 indicators across all families). Today
|
||||
WASM is only smoke-validated through manual examples.
|
||||
- **Release-pipeline trust.** SBOM (CycloneDX) generated per release,
|
||||
Sigstore cosign signatures attached to every published artifact,
|
||||
npm `--provenance` flag enabled, PyPI Trusted Publishers configured.
|
||||
- **Hosted documentation portal.** `wickra.org` (Cloudflare Pages,
|
||||
VitePress) replaces the GitHub Wiki as the canonical doc surface.
|
||||
Per-indicator deep-dives, quickstarts, FAQ, search.
|
||||
- **End-to-end strategy examples.** 3 runnable examples that wire
|
||||
Wickra indicators into a full mean-reversion / trend-following /
|
||||
breakout strategy with PnL and equity-curve output.
|
||||
- **`ARCHITECTURE.md` + `ROADMAP.md` + `CITATION.cff`** governance
|
||||
baseline (this is it).
|
||||
|
||||
### Stretch goals (might slip to 0.4.0)
|
||||
|
||||
- **Per-binding hosted API reference.** TypeDoc for Node/WASM,
|
||||
Sphinx for Python, both hosted on `wickra.org/api/*`. Rust stays on
|
||||
`docs.rs`.
|
||||
- **Property-tests (`proptest`) for mathematical invariants.** Bound
|
||||
checks on RSI ∈ [0, 100], Bollinger ordering, batch-streaming
|
||||
equivalence on random inputs.
|
||||
- **Nightly long-fuzz workflow.** Each fuzz target gets ~1h overnight,
|
||||
findings auto-converted to issues.
|
||||
|
||||
## 0.4.0 — target window: Q4 2026
|
||||
|
||||
### Indicator catalogue expansion
|
||||
|
||||
The current 214 indicators cover the textbook canon. The next wave is
|
||||
the "stuff people actually ask for but skip because it's painful in
|
||||
other libs":
|
||||
|
||||
- **Anchored indicators.** AnchoredVwap is already in, but anchored
|
||||
variants of common indicators (AnchoredATR, AnchoredVolatility) are
|
||||
on the wishlist for explicit session-based analysis.
|
||||
- **Multi-timeframe (MTF) chaining helpers.** A `Mtf<Indicator>` wrapper
|
||||
that runs an indicator on a different timeframe of the same input
|
||||
stream — solves the most common reason people drop down to ad-hoc
|
||||
buffering code.
|
||||
- **Order-flow primitives** (gated on tick-data ingestion landing in
|
||||
`wickra-data`): CumulativeDelta, BidAskImbalance, VolumeAtPrice.
|
||||
- **Pivot-confirmation patterns.** WilliamsFractals is already in;
|
||||
ZigZag is in. Next: PivotHigh/PivotLow with configurable
|
||||
left/right-bar confirmation, used as a feature for higher-level
|
||||
pattern detectors.
|
||||
- **Harmonic-chart patterns** (Gartley, Bat, Butterfly, Crab, Shark).
|
||||
These need the pivot-detector + ratio-matcher framework first; the
|
||||
candlestick-pattern family from 0.2.8 is the precedent.
|
||||
|
||||
### Live-data layer
|
||||
|
||||
- **Tick-data ingestion.** Extend `wickra-data` from OHLCV-only to
|
||||
also accept raw ticks; the existing `Aggregator` already aggregates
|
||||
ticks into bars but isn't exposed in the public live-feed API yet.
|
||||
- **Generic exchange trait.** `LiveFeed { fn subscribe(...) -> impl
|
||||
Stream<Item = Candle> }` so the Binance adapter is one implementor
|
||||
among many. **Note**: Wickra will not aggregate exchanges itself
|
||||
(use `ccxt` if you need that) — the trait exists so user code can
|
||||
swap feeds without touching indicator code.
|
||||
|
||||
### Performance & reliability
|
||||
|
||||
- **Performance-regression tracking.** `bench.yml` outputs deployed to
|
||||
a `gh-pages` branch, `github-action-benchmark` plot over time,
|
||||
threshold-based alerts on regression.
|
||||
- **Indicator parity test suite.** Every indicator that has a TA-Lib
|
||||
/ pandas-ta / talipp equivalent must pass a golden-value test
|
||||
against that reference. Currently most do but the test names are
|
||||
scattered — consolidate into one `parity_tests` module.
|
||||
|
||||
## 0.5.0 — target window: 2027 H1
|
||||
|
||||
### Possible new bindings
|
||||
|
||||
Open questions, prioritised by demand signals (≈ GitHub stars + issue
|
||||
requests + community polls):
|
||||
|
||||
- **Java / Kotlin** binding via UniFFI — interest from Android-side
|
||||
trading-app developers and the JVM-quant community.
|
||||
- **Go** binding — interest from algorithmic-trading shops running on
|
||||
Linux + Go infra.
|
||||
- **C / C++** header export (`cbindgen`) — drops Wickra into existing
|
||||
C/C++ trading stacks (e.g. older Bloomberg / FIX-protocol shops).
|
||||
- **Swift** — niche but real, iOS / macOS native trading apps.
|
||||
- **.NET** — closes the Windows-native gap that NAPI-Node doesn't fill
|
||||
(some shops are still on .NET Framework).
|
||||
|
||||
None of these are committed — each is a 1-2-month project on its own,
|
||||
and bindings without active maintenance are a liability. Priority will
|
||||
be driven by which language community shows up with PRs.
|
||||
|
||||
### `wickra-data` widening
|
||||
|
||||
- **Historical fetch from > 1 source.** Today only Binance REST/WS.
|
||||
Add Coinbase and Kraken as reference implementations — explicitly
|
||||
not exchange-aggregation, just "here are two more adapters using
|
||||
the trait".
|
||||
|
||||
## Beyond — long-term aspirations
|
||||
|
||||
- **`wickra-backtest` sub-crate** (decision pending). A minimal
|
||||
event-driven backtester wrapping signal generation + position
|
||||
sizing + fees + slippage. Decision factor: do users keep building
|
||||
these ad-hoc from `examples/`? If yes, codifying it saves the
|
||||
ecosystem time. If most users plug Wickra into existing backtesters
|
||||
(vectorbt, backtrader, Lean, Hummingbot), keep Wickra focused.
|
||||
- **`wickra-plot` companion** (low priority). `plotters`-backed Rust
|
||||
rendering of indicator outputs. Mainly useful for static report
|
||||
generation; live charting belongs in the JS/web layer.
|
||||
- **Academic adoption.** Citable `CITATION.cff`; targeting at least
|
||||
one peer-reviewed paper using Wickra as the reference indicator
|
||||
engine.
|
||||
|
||||
## What is explicitly **not** on the roadmap
|
||||
|
||||
These are recurring requests that Wickra will decline so contributors
|
||||
don't waste time on them:
|
||||
|
||||
| Feature | Why declined |
|
||||
|---|---|
|
||||
| Exchange aggregation across N venues | That's `ccxt`'s job. Wickra ships *one* feed implementor (Binance) for tests and demo; users plug their preferred exchange. |
|
||||
| Full backtesting framework (à la Lean, vectorbt) | Scope creep. May land as `wickra-backtest` *if* a clear minimal API surfaces from user demand, but Wickra core stays an indicator library. |
|
||||
| Strategy auto-tuning / hyperparameter search | Wrong abstraction layer — belongs in user-side ML/backtester. |
|
||||
| Order-management / broker integration | Even further out of scope. |
|
||||
| GUI / web-based studio | Marketing-site demos are fine; full IDE is not. |
|
||||
| Indicator implementations that require optional Python deps (e.g. scipy KDE) | Bindings must work on a clean install. Pure-Rust math only. |
|
||||
| Proprietary indicator implementations (e.g. paywalled vendor formulas) | License conflicts + audit burden. |
|
||||
| GPU / CUDA acceleration | O(1) per update means the bottleneck is API overhead, not compute. SIMD-batch is a maybe; GPU adds no value for streaming. |
|
||||
|
||||
## How indicator wishlist requests are handled
|
||||
|
||||
1. **Open an issue** using the `feature_request` template, naming the
|
||||
indicator, citing one of:
|
||||
- TA-Lib reference
|
||||
- pandas-ta reference
|
||||
- peer-reviewed paper
|
||||
- widely-published trading book
|
||||
2. Maintainer triages within ~1 week. Acceptance criteria:
|
||||
- Formula has at least one written reference (no random
|
||||
YouTuber-only indicators)
|
||||
- Implementation can be O(1) streaming
|
||||
- Test vectors are obtainable (from reference lib, hand-computed,
|
||||
or paper-provided)
|
||||
3. Once accepted, the indicator gets added to the next family-batch
|
||||
PR. Family-batches typically ship 5-20 indicators at a time.
|
||||
|
||||
## Versioning
|
||||
|
||||
Wickra follows [SemVer](https://semver.org/). The promise:
|
||||
|
||||
- **0.x.0 (minor):** new indicators, new bindings, new optional features,
|
||||
new optional config knobs. Adding methods to `Indicator` with default
|
||||
impls is minor.
|
||||
- **0.x.y (patch):** bug fixes, performance improvements, doc fixes.
|
||||
- **Major (1.0.0 and later):** changes to the `Indicator` trait
|
||||
signature, removal of indicators, breaking changes to `Candle` /
|
||||
`OHLCV` field order.
|
||||
|
||||
The 1.0 milestone is reserved for when the indicator catalogue is
|
||||
considered "stable enough" and the bindings API has stabilised — likely
|
||||
~2027 once 2-3 more bindings have shipped and stress-tested the trait.
|
||||
|
||||
## Discussion
|
||||
|
||||
For roadmap discussion, open a [Discussions](https://github.com/wickra-lib/wickra/discussions)
|
||||
thread tagged `roadmap`. Specific indicator wishlist items go in
|
||||
[Issues](https://github.com/wickra-lib/wickra/issues) via the
|
||||
`feature_request` template.
|
||||
+1
-1
@@ -18,7 +18,7 @@ Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||
("Report a vulnerability" under the repository's *Security* tab), or
|
||||
- email to **wickra.lib@gmail.com** with a subject line starting with
|
||||
- email to **support@wickra.org** with a subject line starting with
|
||||
`[wickra security]`.
|
||||
|
||||
Please include:
|
||||
|
||||
+45
-286
@@ -1,314 +1,73 @@
|
||||
# Wickra
|
||||
# Wickra — Node.js
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/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
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||
it exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ 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:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
npm install wickra
|
||||
```
|
||||
|
||||
## Indicators
|
||||
The native addon ships as a prebuilt binary per platform (Linux, macOS,
|
||||
Windows — x64 and arm64), selected automatically through optional
|
||||
dependencies. There is nothing to compile.
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
## Quick start
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
```js
|
||||
const wickra = require('wickra');
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
// Batch: run an indicator over a whole array.
|
||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
|
||||
const values = new wickra.RSI(14).batch(prices); // null during warmup
|
||||
|
||||
## Languages
|
||||
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||
|
||||
// Streaming or batch — same trait, same code.
|
||||
let mut sma = Sma::new(14)?;
|
||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
for price in live_feed {
|
||||
if let Some(v) = rsi.update(price) {
|
||||
println!("RSI = {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||
chain.update(price);
|
||||
```
|
||||
|
||||
## Live data sources
|
||||
|
||||
`wickra-data` (separate crate, opt-in) ships:
|
||||
|
||||
- A streaming OHLCV **CSV reader**.
|
||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, Rsi};
|
||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||
|
||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
while let Some(event) = stream.next_event().await? {
|
||||
if event.is_closed {
|
||||
if let Some(v) = rsi.update(event.candle.close) {
|
||||
println!("RSI = {v:.2}");
|
||||
}
|
||||
}
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
const rsi = new wickra.RSI(14);
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // no recomputation over history
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Python live-trading example using the public `websockets` package lives at
|
||||
`examples/python/live_trading.py`.
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
## Project layout
|
||||
## Documentation
|
||||
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||
|
||||
## Building everything from source
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
```bash
|
||||
# Rust core + tests
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo bench -p wickra
|
||||
## Disclaimer
|
||||
|
||||
# Python binding (requires Rust toolchain + maturin)
|
||||
cd bindings/python
|
||||
maturin develop --release
|
||||
pytest
|
||||
|
||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||
proptest. The four bindings inherit your indicator automatically once
|
||||
you expose it in the language wrappers.
|
||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||
most regressions; please don't disable them.
|
||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||
its own tests; please keep the `batch == streaming` invariant.
|
||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
are CI gates; running them locally before pushing keeps reviews short.
|
||||
|
||||
For larger architectural changes, open an issue first so we can sketch the
|
||||
shape together before you invest the time.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Completeness contract for the Wickra Node bindings: every exported indicator
|
||||
// class must expose the full streaming + batch + lifecycle interface. This
|
||||
// catches a new indicator being wired into the binding without the standard
|
||||
// methods (or an export silently disappearing) without needing a hand-written
|
||||
// test per indicator.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function) and any
|
||||
// non-indicator export.
|
||||
function indicatorClasses() {
|
||||
return Object.keys(wickra).filter((name) => {
|
||||
const value = wickra[name];
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.prototype &&
|
||||
typeof value.prototype.update === 'function'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the binding exports the full indicator catalogue', () => {
|
||||
const names = indicatorClasses();
|
||||
// The published catalogue is 214 indicators. Guard against a regression that
|
||||
// silently drops exported classes (e.g. a stale or partial native build).
|
||||
assert.ok(
|
||||
names.length >= 200,
|
||||
`expected at least 200 indicator classes, got ${names.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
|
||||
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
|
||||
const missing = [];
|
||||
for (const name of indicatorClasses()) {
|
||||
const proto = wickra[name].prototype;
|
||||
for (const method of required) {
|
||||
if (typeof proto[method] !== 'function') {
|
||||
missing.push(`${name}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`indicator classes missing required methods: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
|
||||
// Every indicator that takes no constructor arguments must still satisfy the
|
||||
// pre-warmup contract. (Indicators with required parameters are exercised by
|
||||
// the dedicated suites; here we cover the zero-arg ones generically.)
|
||||
let checked = 0;
|
||||
for (const name of indicatorClasses()) {
|
||||
let instance;
|
||||
try {
|
||||
instance = new wickra[name]();
|
||||
} catch {
|
||||
continue; // needs constructor arguments — covered elsewhere
|
||||
}
|
||||
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
|
||||
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
|
||||
checked += 1;
|
||||
}
|
||||
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
|
||||
});
|
||||
@@ -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', () => {
|
||||
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
|
||||
// clamp pathological values like period=0 to the smallest valid window.
|
||||
const sma = new wickra.SMA(0);
|
||||
assert.equal(sma.warmupPeriod(), 1);
|
||||
assert.equal(sma.update(42), 42);
|
||||
test('zero period is rejected at construction', () => {
|
||||
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
|
||||
// it as a thrown JS error, consistent with the Python and WASM bindings.
|
||||
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
|
||||
// A valid period still constructs and runs.
|
||||
assert.equal(new wickra.SMA(1).update(42), 42);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0"
|
||||
"wickra-darwin-arm64": "0.4.0",
|
||||
"wickra-darwin-x64": "0.4.0",
|
||||
"wickra-linux-arm64-gnu": "0.4.0",
|
||||
"wickra-linux-x64-gnu": "0.4.0",
|
||||
"wickra-win32-arm64-msvc": "0.4.0",
|
||||
"wickra-win32-x64-msvc": "0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.0.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.0.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.0.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.0.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.0.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.0.tgz",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.0.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <wickra.lib@gmail.com>",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0"
|
||||
"wickra-linux-x64-gnu": "0.4.0",
|
||||
"wickra-linux-arm64-gnu": "0.4.0",
|
||||
"wickra-darwin-x64": "0.4.0",
|
||||
"wickra-darwin-arm64": "0.4.0",
|
||||
"wickra-win32-x64-msvc": "0.4.0",
|
||||
"wickra-win32-arm64-msvc": "0.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
@@ -60,7 +60,8 @@
|
||||
"artifacts": "napi artifacts",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
"test": "node --test __tests__/",
|
||||
"bench": "node benchmarks/throughput.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
|
||||
+43
-78
@@ -22,26 +22,6 @@ fn map_err(e: wc::Error) -> NapiError {
|
||||
NapiError::new(Status::InvalidArg, e.to_string())
|
||||
}
|
||||
|
||||
/// Helper for the scalar-indicator macro only. Scalar `new` functions can fail
|
||||
/// solely on `period == 0`, which `clamp_period` already rules out, so the
|
||||
/// `Result` is provably `Ok` here. Candle indicators and the multi-parameter
|
||||
/// indicators have genuinely fallible parameters and instead use fallible
|
||||
/// `#[napi(constructor)]`s that return `napi::Result<Self>` and throw a JS error.
|
||||
fn must<T>(r: Result<T, wc::Error>) -> T {
|
||||
r.expect("wickra: scalar indicator parameter clamped to a valid range")
|
||||
}
|
||||
|
||||
/// Clamp a period parameter so the underlying indicator never sees zero. JS
|
||||
/// callers who pass `0` get a window of `1` instead of a thrown exception —
|
||||
/// effectively a pass-through indicator that still produces valid outputs.
|
||||
const fn clamp_period(p: u32) -> usize {
|
||||
if p == 0 {
|
||||
1
|
||||
} else {
|
||||
p as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
|
||||
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
|
||||
}
|
||||
@@ -64,10 +44,10 @@ macro_rules! node_scalar_indicator {
|
||||
#[napi]
|
||||
impl $wrapper {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> Self {
|
||||
Self {
|
||||
inner: must(<$rust_ty>::new(clamp_period(period))),
|
||||
}
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
@@ -137,11 +117,10 @@ node_scalar_indicator!(
|
||||
);
|
||||
|
||||
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
|
||||
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
|
||||
// parameter and additionally rejects `period == 1` (a 1-bar standard
|
||||
// deviation is always zero), so the `clamp_period`-to-1 strategy from
|
||||
// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible
|
||||
// constructor instead, throws a JS error on bad period.
|
||||
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` and
|
||||
// rejects both `period == 0` and `period == 1` (a 1-bar standard deviation is
|
||||
// always zero). Hand-rolled, but behaves like `node_scalar_indicator!`: the
|
||||
// fallible `new` propagates the core error and throws a JS error on bad period.
|
||||
#[napi(js_name = "RVIVolatility")]
|
||||
pub struct RviVolatilityNode {
|
||||
inner: wc::RviVolatility,
|
||||
@@ -1351,7 +1330,7 @@ impl InertiaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period))
|
||||
inner: wc::Inertia::new(rvi_period as usize, linreg_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
@@ -1412,9 +1391,9 @@ impl ConnorsRsiNode {
|
||||
pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ConnorsRsi::new(
|
||||
clamp_period(period_rsi),
|
||||
clamp_period(period_streak),
|
||||
clamp_period(period_rank),
|
||||
period_rsi as usize,
|
||||
period_streak as usize,
|
||||
period_rank as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1484,12 +1463,8 @@ impl SmiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Smi::new(
|
||||
clamp_period(period),
|
||||
clamp_period(d_period),
|
||||
clamp_period(d2_period),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Smi::new(period as usize, d_period as usize, d2_period as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1559,15 +1534,15 @@ impl KstNode {
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Kst::new(
|
||||
clamp_period(roc1),
|
||||
clamp_period(roc2),
|
||||
clamp_period(roc3),
|
||||
clamp_period(roc4),
|
||||
clamp_period(sma1),
|
||||
clamp_period(sma2),
|
||||
clamp_period(sma3),
|
||||
clamp_period(sma4),
|
||||
clamp_period(signal),
|
||||
roc1 as usize,
|
||||
roc2 as usize,
|
||||
roc3 as usize,
|
||||
roc4 as usize,
|
||||
sma1 as usize,
|
||||
sma2 as usize,
|
||||
sma3 as usize,
|
||||
sma4 as usize,
|
||||
signal as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1620,7 +1595,7 @@ impl PgoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Pgo::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Pgo::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1672,7 +1647,7 @@ impl RviNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Rvi::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1732,9 +1707,9 @@ impl AwesomeOscillatorHistogramNode {
|
||||
pub fn new(fast: u32, slow: u32, sma_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::AwesomeOscillatorHistogram::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(sma_period),
|
||||
fast as usize,
|
||||
slow as usize,
|
||||
sma_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1783,13 +1758,8 @@ impl StcNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, schaff_period: u32, factor: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Stc::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(schaff_period),
|
||||
factor,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Stc::new(fast as usize, slow as usize, schaff_period as usize, factor)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1829,10 +1799,10 @@ impl ElderImpulseNode {
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderImpulse::new(
|
||||
clamp_period(ema_period),
|
||||
clamp_period(macd_fast),
|
||||
clamp_period(macd_slow),
|
||||
clamp_period(macd_signal),
|
||||
ema_period as usize,
|
||||
macd_fast as usize,
|
||||
macd_slow as usize,
|
||||
macd_signal as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
@@ -1875,12 +1845,8 @@ impl ZeroLagMacdNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ZeroLagMacd::new(
|
||||
clamp_period(fast),
|
||||
clamp_period(slow),
|
||||
clamp_period(signal),
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
inner: wc::ZeroLagMacd::new(fast as usize, slow as usize, signal as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1927,7 +1893,7 @@ impl CfoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Cfo::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -1961,7 +1927,7 @@ impl ApoNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Apo::new(clamp_period(fast), clamp_period(slow)).map_err(map_err)?,
|
||||
inner: wc::Apo::new(fast as usize, slow as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2032,7 +1998,7 @@ impl EvwmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Evwma::new(clamp_period(period)).map_err(map_err)?,
|
||||
inner: wc::Evwma::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2088,7 +2054,7 @@ impl AlligatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(jaw: u32, teeth: u32, lips: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Alligator::new(clamp_period(jaw), clamp_period(teeth), clamp_period(lips))
|
||||
inner: wc::Alligator::new(jaw as usize, teeth as usize, lips as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
@@ -2146,7 +2112,7 @@ impl JmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, phase: f64, power: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Jma::new(clamp_period(period), phase, power).map_err(map_err)?,
|
||||
inner: wc::Jma::new(period as usize, phase, power).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2182,8 +2148,7 @@ impl VidyaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, cmo_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Vidya::new(clamp_period(period), clamp_period(cmo_period))
|
||||
.map_err(map_err)?,
|
||||
inner: wc::Vidya::new(period as usize, cmo_period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
@@ -2219,7 +2184,7 @@ impl AlmaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, offset: f64, sigma: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Alma::new(clamp_period(period), offset, sigma).map_err(map_err)?,
|
||||
inner: wc::Alma::new(period as usize, offset, sigma).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
|
||||
+41
-283
@@ -1,314 +1,72 @@
|
||||
# Wickra
|
||||
# Wickra — Python
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/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 Python. `pip install wickra` — no
|
||||
system dependencies, no C build tooling.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Python binding (PyO3); it
|
||||
exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install wickra
|
||||
```
|
||||
|
||||
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
|
||||
compile and no C library to track down.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
# Batch: classic TA-Lib-style usage over a whole array.
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
# Streaming: the same indicator, fed tick by tick in O(1).
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
value = rsi.update(price) # no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
## Documentation
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
## Disclaimer
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | **★ 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.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
|
||||
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
|
||||
+45
-287
@@ -1,314 +1,72 @@
|
||||
# Wickra
|
||||
# Wickra — WebAssembly
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](https://www.npmjs.com/package/wickra-wasm)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
**Streaming-first technical indicators in the browser. `npm install
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||
machine that updates in O(1) per new data point, so live trading bots and
|
||||
historical backtests share the exact same implementation.
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading dashboards and historical backtests
|
||||
share the exact same implementation. This package is the WebAssembly binding
|
||||
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||
indicators across sixteen families.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch: classic TA-Lib-style usage
|
||||
prices = np.linspace(100, 200, 1000)
|
||||
rsi = ta.RSI(14)
|
||||
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||
|
||||
# Streaming: same indicator, fed tick by tick
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # O(1) — no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ 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:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
npm install wickra-wasm
|
||||
```
|
||||
|
||||
## Indicators
|
||||
## Quick start
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
The module ships a default `init` export that loads the `.wasm` payload; await
|
||||
it once before constructing indicators.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
```js
|
||||
import init, { RSI } from 'wickra-wasm';
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
await init(); // load the WebAssembly module once
|
||||
|
||||
## Languages
|
||||
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
|
||||
## Rust API
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||
|
||||
// Streaming or batch — same trait, same code.
|
||||
let mut sma = Sma::new(14)?;
|
||||
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
for price in live_feed {
|
||||
if let Some(v) = rsi.update(price) {
|
||||
println!("RSI = {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||
chain.update(price);
|
||||
```
|
||||
|
||||
## Live data sources
|
||||
|
||||
`wickra-data` (separate crate, opt-in) ships:
|
||||
|
||||
- A streaming OHLCV **CSV reader**.
|
||||
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||
|
||||
```rust
|
||||
use wickra::{Indicator, Rsi};
|
||||
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||
|
||||
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||
let mut rsi = Rsi::new(14)?;
|
||||
while let Some(event) = stream.next_event().await? {
|
||||
if event.is_closed {
|
||||
if let Some(v) = rsi.update(event.candle.close) {
|
||||
println!("RSI = {v:.2}");
|
||||
}
|
||||
}
|
||||
// Streaming: feed prices tick by tick in O(1).
|
||||
const rsi = new RSI(14);
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // null during warmup
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Python live-trading example using the public `websockets` package lives at
|
||||
`examples/python/live_trading.py`.
|
||||
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
|
||||
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
|
||||
`null` while the indicator is still warming up.
|
||||
|
||||
## Project layout
|
||||
## Documentation
|
||||
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||
|
||||
## Building everything from source
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
```bash
|
||||
# Rust core + tests
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo bench -p wickra
|
||||
## Disclaimer
|
||||
|
||||
# Python binding (requires Rust toolchain + maturin)
|
||||
cd bindings/python
|
||||
maturin develop --release
|
||||
pytest
|
||||
|
||||
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
# Node binding (requires @napi-rs/cli)
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||
proptest. The four bindings inherit your indicator automatically once
|
||||
you expose it in the language wrappers.
|
||||
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||
most regressions; please don't disable them.
|
||||
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||
its own tests; please keep the `batch == streaming` invariant.
|
||||
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
are CI gates; running them locally before pushing keeps reviews short.
|
||||
|
||||
For larger architectural changes, open an issue first so we can sketch the
|
||||
shape together before you invest the time.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -6139,6 +6139,12 @@ mod tests {
|
||||
fn close_enough(a: f64, b: f64) -> bool {
|
||||
if a.is_nan() {
|
||||
b.is_nan()
|
||||
} else if a == b {
|
||||
// Exact equality, including matching infinities (e.g. ProfitFactor
|
||||
// with no losing trades is +inf in both the streaming and batch
|
||||
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
|
||||
// would otherwise reject two equal infinities.
|
||||
true
|
||||
} else {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
@@ -6630,6 +6636,146 @@ mod tests {
|
||||
"ready after 5 finite inputs even with prior NaNs"
|
||||
);
|
||||
}
|
||||
|
||||
// Streaming `update` must reproduce `batch` value-for-value for every scalar
|
||||
// indicator — the core O(1) state-machine invariant. Each entry builds a
|
||||
// fresh instance for the batch pass and another for the streaming pass. The
|
||||
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
|
||||
// factories, so they are known-valid.
|
||||
macro_rules! assert_scalar_stream_eq {
|
||||
($ctor:expr, $prices:expr) => {{
|
||||
let prices: &[f64] = $prices;
|
||||
let batch = { $ctor }.batch(prices);
|
||||
let mut streaming = { $ctor };
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let b = batch.get_index(i as u32);
|
||||
match streaming.update(p) {
|
||||
Some(v) => assert!(
|
||||
close_enough(v, b),
|
||||
"{} streaming != batch at {i}: {v} vs {b}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
None => assert!(
|
||||
b.is_nan(),
|
||||
"{} expected NaN warmup at {i}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[wasm_bindgen_test]
|
||||
fn scalar_streaming_matches_batch_broad() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
|
||||
})
|
||||
.collect();
|
||||
let p = prices.as_slice();
|
||||
|
||||
// Moving averages.
|
||||
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
|
||||
|
||||
// Momentum / oscillators.
|
||||
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
|
||||
|
||||
// Volatility / statistics / regression.
|
||||
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
|
||||
|
||||
// Ehlers / cycle.
|
||||
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(
|
||||
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
|
||||
p
|
||||
);
|
||||
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
|
||||
|
||||
// Risk / performance (scalar f64 input).
|
||||
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
}
|
||||
|
||||
// Additional invalid-constructor coverage. These wrap the same fallible core
|
||||
// `new` as the Python / Node bindings, where the equivalent calls are
|
||||
// confirmed to error.
|
||||
#[wasm_bindgen_test]
|
||||
fn additional_invalid_constructors_are_rejected() {
|
||||
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
|
||||
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
|
||||
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
|
||||
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
|
||||
}
|
||||
}
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
|
||||
+18
-17
@@ -1,30 +1,31 @@
|
||||
# Documentation
|
||||
|
||||
Wickra's full documentation lives in the **[GitHub Wiki](https://github.com/wickra-lib/wickra/wiki)**.
|
||||
Wickra's full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**.
|
||||
|
||||
That includes:
|
||||
|
||||
- **Quickstarts** for [Rust](https://github.com/wickra-lib/wickra/wiki/Quickstart-Rust.md),
|
||||
[Python](https://github.com/wickra-lib/wickra/wiki/Quickstart-Python.md),
|
||||
[Node](https://github.com/wickra-lib/wickra/wiki/Quickstart-Node.md), and
|
||||
[WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md).
|
||||
- **Quickstarts** for [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- A per-indicator deep dive for every one of the **214 indicators** across
|
||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
|
||||
Risk / Performance) — see the
|
||||
[indicators overview](https://github.com/wickra-lib/wickra/wiki/Indicators-Overview.md).
|
||||
- **Reference pages**: [warmup periods](https://github.com/wickra-lib/wickra/wiki/Warmup-Periods.md),
|
||||
[streaming vs batch](https://github.com/wickra-lib/wickra/wiki/Streaming-vs-Batch.md),
|
||||
[indicator chaining](https://github.com/wickra-lib/wickra/wiki/Indicator-Chaining.md), and the
|
||||
[data layer](https://github.com/wickra-lib/wickra/wiki/Data-Layer.md).
|
||||
- **Guides**: [Cookbook](https://github.com/wickra-lib/wickra/wiki/Cookbook.md),
|
||||
[TA-Lib migration](https://github.com/wickra-lib/wickra/wiki/TA-Lib-Migration.md),
|
||||
[FAQ](https://github.com/wickra-lib/wickra/wiki/FAQ.md).
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference pages**: [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), and the
|
||||
[data layer](https://docs.wickra.org/Data-Layer).
|
||||
- **Guides**: [Cookbook](https://docs.wickra.org/Cookbook),
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Editing the wiki
|
||||
## Editing the docs
|
||||
|
||||
The wiki is a separate git repository at `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
Clone it locally if you want to bulk-edit; otherwise the GitHub web UI's "Edit" button on any
|
||||
wiki page is fine for one-off changes.
|
||||
The documentation site is a separate git repository at
|
||||
`https://github.com/wickra-lib/wickra-docs`. Open a pull request there to
|
||||
propose changes; the site is built with VitePress and deploys to
|
||||
`docs.wickra.org`.
|
||||
|
||||
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
|
||||
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
|
||||
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
|
||||
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
|
||||
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
|
||||
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
|
||||
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
|
||||
|
||||
## WebAssembly — `examples/wasm/`
|
||||
|
||||
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
|
||||
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
|
||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
|
||||
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
|
||||
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long < 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
|
||||
|
||||
|
||||
Generated
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "wickra-examples-node",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra-examples-node",
|
||||
"version": "0.0.0",
|
||||
"license": "SEE LICENSE IN ../../LICENSE",
|
||||
"dependencies": {
|
||||
"wickra": "file:../../bindings/node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.0",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.0",
|
||||
"wickra-darwin-x64": "0.4.0",
|
||||
"wickra-linux-arm64-gnu": "0.4.0",
|
||||
"wickra-linux-x64-gnu": "0.4.0",
|
||||
"wickra-win32-arm64-msvc": "0.4.0",
|
||||
"wickra-win32-x64-msvc": "0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
"resolved": "../../bindings/node",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`. |
|
||||
| `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). |
|
||||
| `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
|
||||
|
||||
- [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.
|
||||
- [examples/README.md](../README.md) — cross-language index, including
|
||||
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"
|
||||
name = "wickra"
|
||||
url = "https://github.com/wickra-lib/wickra"
|
||||
wiki_url = "https://github.com/wickra-lib/wickra/wiki"
|
||||
wiki_git = "https://github.com/wickra-lib/wickra.wiki.git"
|
||||
docs_url = "https://docs.wickra.org"
|
||||
docs_git = "https://github.com/wickra-lib/wickra-docs"
|
||||
issues_url = "https://github.com/wickra-lib/wickra/issues"
|
||||
discussions = "https://github.com/wickra-lib/wickra/discussions"
|
||||
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
|
||||
# used in CODEOWNERS and prose.
|
||||
handle = "kingchenc"
|
||||
email = "wickra.lib@gmail.com"
|
||||
email = "support@wickra.org"
|
||||
github = "wickra-lib"
|
||||
|
||||
[badges]
|
||||
|
||||
Reference in New Issue
Block a user