Compare commits

..

22 Commits

Author SHA1 Message Date
kingchenc eab2649f1c release: bump 0.3.1 -> 0.4.0 (#89)
Minor release. The headline user-facing change is the Node binding now
rejecting invalid indicator periods instead of silently clamping period 0
to 1 (matches Python/WASM/core); plus per-ecosystem binding READMEs and a
corrected MSRV statement in CONTRIBUTING. See CHANGELOG [0.4.0].

- Cargo.toml (workspace.package + wickra-core dep) + Cargo.lock (6 members)
- bindings/python/pyproject.toml
- bindings/node/package.json (version + 6 optionalDependencies) + package-lock.json
- bindings/node/npm/*/package.json (6 platform subpackages)
- examples/node/package-lock.json (wickra-* platform pins)
- CHANGELOG: [Unreleased] -> [0.4.0] - 2026-05-31 + compare URL

No tag pushed (release publish is a separate, user-confirmed step).
2026-06-01 01:28:53 +02:00
kingchenc f7f947e048 docs(changelog): record provenance attestations + CI security tooling (Unreleased) (#102) 2026-06-01 01:08:14 +02:00
kingchenc 01dd08714b ci: harden workflows against network/CDN flakes (resilient cache + setup retries) (#101) 2026-06-01 01:07:56 +02:00
kingchenc 0fa70c9882 ci: pin remaining GitHub Actions to commit SHAs (P21.1c) (#100) 2026-06-01 01:07:34 +02:00
kingchenc debe4523d5 ci: pass untrusted workflow contexts via env to prevent shell injection (P21.1a) (#99) 2026-06-01 00:32:24 +02:00
kingchenc a046c441e5 docs(readme): show the Wickra banner; drop the redundant heading (#98)
Embed the brand banner at the top of the README (linked to wickra.org) and
drop the now-redundant "# Wickra" heading — the banner shows the wordmark.

The image is served from https://wickra.org/og-banner.webp, which the webpage
build regenerates on every deploy from the current indicator count (4K WebP),
so the README banner stays current with no committed binary in this repo and
renders on GitHub, crates.io and docs.rs alike.
2026-06-01 00:07:36 +02:00
kingchenc f7b91f6fa5 chore: use support@wickra.org for contact/author email; drop dead sponsor link (#97)
Now that the wickra.org catch-all mailbox exists, move the project contact +
package-author email off the personal gmail to support@wickra.org across all
surfaces: CODE_OF_CONDUCT, SECURITY, CITATION.cff, Cargo.toml, the npm + PyPI
author fields, the release.yml npm author, and repo-metadata.toml. (The
package-author changes take effect on the next published release.)

repo-metadata.toml's [audit].forbidden still pins kingchencp@gmail.com (the
private commit email) as a banned substring — unchanged.

Also remove the FUNDING.yml custom "https://wickra.org/sponsor" entry: that
page 404s, so the Sponsor button linked to a dead URL. The GitHub Sponsors
entry (github: [kingchenc]) stays.
2026-05-31 23:22:57 +02:00
kingchenc 5030360a0c ci: CodeQL SAST workflow + badge (P13.6) (#96)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)

* ci: add CodeQL SAST workflow + badge (P13.6)
2026-05-31 22:34:00 +02:00
kingchenc 1dd487fabc docs(readme): GitHub release badge (P13.4) (#94)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)

* docs(readme): add GitHub release badge (P13.4)
2026-05-31 22:21:05 +02:00
kingchenc cee174c0de ci(release): build-provenance attestations for crates + Python (P13.2) (#91)
* ci: add OpenSSF Scorecard workflow + badge (P13.1)

* ci(release): attest build provenance for crates + Python artifacts (P13.2)
2026-05-31 22:13:06 +02:00
kingchenc c8e5d8a658 ci: add OpenSSF Scorecard workflow + badge (P13.1) (#90) 2026-05-31 22:05:00 +02:00
kingchenc dc2e19e762 ci(sync-about): self-update the marketing site count + version (P12.1) (#95)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)

* ci(sync-about): self-update the marketing site (webpage) count + version (P12.1)
2026-05-31 22:04:32 +02:00
kingchenc d8212beff6 ci(sync-about): retarget count sync to wickra-docs + point About at docs.wickra.org (P8.3) (#88)
* ci(sync-about): auto-sync the published version to wickra-docs on release

* ci(sync-about): retarget indicator-count sync from wiki to wickra-docs + point About homepage at docs.wickra.org (P8.3)
2026-05-31 21:57:42 +02:00
kingchenc 86a595d505 ci(sync-about): auto-sync the published version to wickra-docs on release (#87) 2026-05-31 21:48:53 +02:00
kingchenc 9309bf9d60 docs(P6.4): repoint all doc links from the GitHub wiki to docs.wickra.org (#86)
The documentation now lives in the wickra-lib/wickra-docs VitePress repo and
will deploy to docs.wickra.org. Rewrite every tracked, user-facing wiki link
in the main repo to the new canonical site:

- docs/README.md, CONTRIBUTING.md, PULL_REQUEST_TEMPLATE.md
- bindings/{node,python,wasm}/README.md, examples/wasm/README.md
- repo-metadata.toml: wiki_url/wiki_git -> docs_url/docs_git

Page paths map 1:1 to VitePress clean URLs (e.g. /wiki/Quickstart-Rust.md ->
docs.wickra.org/Quickstart-Rust). The sync-about.yml wiki-sync step is left
untouched on purpose: it stays until the docs site is live (tracked as P8.3).
The GitHub wiki itself is not deleted yet for the same reason.
2026-05-31 21:48:24 +02:00
kingchenc 3f05342f72 docs(P7): per-ecosystem binding READMEs + correct MSRV documentation (#85)
* docs(contributing): correct MSRV to 1.86/1.88 and document the dep-forced floor (P7.1)

* docs(bindings): trim binding READMEs to per-ecosystem install + links (P7.2)

* docs(changelog): note per-ecosystem binding READMEs + MSRV doc fix (P7.1/P7.2)
2026-05-31 05:30:34 +02:00
kingchenc eb4454ab27 P5: track index.d.ts + reject invalid periods in the Node binding (#83)
* build(node): track the generated index.d.ts (P5.1)

index.js was committed but index.d.ts was gitignored, an inconsistency that
also contradicts CONTRIBUTING ('regenerate both .d.ts/.js when a binding API
changes'). Track index.d.ts too so the repo carries the TypeScript types as a
matched pair with index.js. Generated by napi build; ~214 indicator classes.

* fix(node): reject invalid periods instead of clamping them (P5.4)

The Node scalar-indicator macro clamped period 0 to 1 (via clamp_period + must)
and the multi-parameter constructors did the same, silently swallowing the
core's PeriodZero validation. The core rejects period 0 (Error::PeriodZero),
and the Python and WASM bindings already propagate it — Node was the outlier,
masking caller mistakes. Make the macro constructor fallible and let every
constructor propagate the core error via map_err, removing clamp_period/must.
Update the smoke test (period 0 now throws, matching core/Python/WASM).

* docs(changelog): note the Node period-validation change (P5.4)

Behavior change per CONTRIBUTING: Node constructors now reject invalid periods
instead of clamping. Add an [Unreleased] Changed entry.

* chore: drop accidentally committed scratch log
2026-05-31 05:22:56 +02:00
kingchenc 3093f194a2 docs: document the repo-wide lockfile policy (P4) (#82)
The .gitignore comment claimed package-lock.json is committed only under
bindings/node/, but examples/node/package-lock.json has also been tracked
since #80. Correct the comment and add a CONTRIBUTING 'Lockfile policy'
section spelling out every component: Cargo.lock + the two Node package-locks
are tracked; fuzz/Cargo.lock is ignored (cargo-fuzz default); the Python
package has no lockfile by PyO3 convention (pinned via Cargo.lock); the
ghost-ignored site keeps its lockfile local.
2026-05-31 05:11:08 +02:00
kingchenc 21c86f348f examples + bindings: Node/WASM strategy parity + test/benchmark parity (P2 + P3) (#81)
* examples(node): add RSI mean-reversion strategy

Node counterpart of strategy_rsi_mean_reversion.{py,rs}: RSI(14) < 30 long,
> 70 exit, 0.1% fees, hourly BTCUSDT. Output verified byte-identical to the
Rust reference (37 trades W24/L13, -17.84% return, 46.89% max drawdown).

* examples(node): add MACD + ADX trend-filter strategy

Node counterpart of strategy_macd_adx.{py,rs}: MACD(12,26,9) histogram
crossover entries gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (246 trades W90/L156, -47.19%
return, 53.75% max drawdown).

* examples(node): add Bollinger-squeeze breakout strategy

Node counterpart of strategy_bollinger_squeeze.{py,rs}: enter on a fresh
180-bar Bollinger-bandwidth low + close above the upper band, exit on a
2*ATR(14) stop or upper-band collapse, daily BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (1 trade, -7.82% return,
13.01% max drawdown).

* examples(wasm): add RSI mean-reversion strategy demo

Browser counterpart of strategy_rsi_mean_reversion.{py,js,rs}: RSI(14) < 30
long, > 70 exit, 0.1% fees, summary table. Same signal/fill/PnL/equity loop as
the runtime-verified Node example; loads via the established wickra_wasm.js
init + fetch-CSV pattern. (wasm32 build runs in CI.)

* examples(wasm): add MACD + ADX trend-filter strategy demo

Browser counterpart of strategy_macd_adx.{py,js,rs}: MACD(12,26,9) histogram
crossover gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Logic identical to
the runtime-verified Node example; standard wickra_wasm.js init + fetch-CSV
loader. (wasm32 build runs in CI.)

* examples(wasm): add Bollinger-squeeze breakout strategy demo

Browser counterpart of strategy_bollinger_squeeze.{py,js,rs}: fresh 180-bar
Bollinger-bandwidth low + upper-band breakout, 2*ATR(14) stop, daily BTCUSDT,
0.1% fees. Logic identical to the runtime-verified Node example; standard
wickra_wasm.js init + fetch-CSV loader. (wasm32 build runs in CI.)

* ci: add examples syntax-smoke job (P2.3)

The Rust examples are built by 'cargo build -p wickra-examples --bins'; the
Node, browser-WASM and Python examples had no build gate. New job parse-checks
every examples/{node,wasm}/*.js, extracts and node --checks each WASM .html
module script, and python -m py_compile's every examples/python/*.py — so a
broken example edit fails CI instead of landing silently.

* docs(examples): list the new Node + WASM strategy examples

Add the three Node strategy scripts and three WASM strategy demos to the
examples README tables, bringing Node and WASM to parity with the existing
Rust and Python strategy rows.

* chore(examples): refresh examples/node lockfile for the linked wickra binding

npm install rewrote the file: dependency snapshot of the local wickra binding
that the examples link against (version 0.1.4 -> 0.3.1, license + engines
fields), which had gone stale in the committed lockfile.

* test(node): add input-validation suite

Node counterpart of bindings/python/tests/test_input_validation.py: invalid
constructor parameters (ATR zero period, MACD non-increasing fast/slow,
BollingerBands negative multiplier, PSAR step > max, ValueArea period/pct,
InitialBalance/OpeningRange zero period, Ichimoku non-increasing periods,
Ehlers-family ordering) and unequal-length candle/ValueArea batch inputs all
throw a JS Error. Validated against the built binding.

* test(node): add indicator completeness contract

Introspects every exported indicator class and asserts the full interface
(update / batch / reset / isReady / warmupPeriod) plus the pre-warmup contract
for zero-arg indicators, and guards that the full catalogue (>= 200 classes)
is exported. Catches a new indicator wired without the standard methods, or a
stale/partial native build dropping exports, with no per-indicator boilerplate.

* test(wasm): broaden scalar streaming-vs-batch coverage

Extend the inline wasm-bindgen-test suite with a streaming==batch check across
~70 scalar indicators spanning moving averages, momentum, volatility,
statistics/regression, Ehlers/cycle and risk/performance families (previously
only EMA + the candle-input group were covered per-indicator), plus four more
invalid-constructor assertions. Constructor args mirror the CI-passing Node
factories. Host-compiles (cargo test -p wickra-wasm --no-run); executed in CI
via wasm-pack test --node.

* bench(node): add indicator throughput benchmark

Node counterpart of the Rust criterion benches / Python compare_libraries:
measures streaming (per-tick update) and batch throughput in Mupd/s across a
representative indicator set over a synthetic OHLCV series (--bars, default
200k). Dependency-free; wired as 'npm run bench'.

* docs(wasm): list strategy demos + document the benchmark story

Add the three new strategy demos to the WASM examples table and a Performance
section: parallel_assets.html is the in-browser benchmark, with raw throughput
covered by the Rust criterion / Python / Node benchmarks (the WASM engine is the
same core compiled to wasm32).
2026-05-31 05:09:26 +02:00
kingchenc a2ff35f5f9 ci(sync-about): auto-sync repo homepage + org profile from the indicator count (#84)
Extend the count-sync workflow to drive three more surfaces from the same
single count, so the org page never drifts again:

- Repo About **homepage** URL — enforced unconditionally via
  `gh repo edit --homepage` in the existing About step (fixes the stale
  kingchenc URL and self-heals). Uses the same Administration-write
  permission as --description, so NO extra token scope.
- Org **profile README** count (wickra-lib/.github, profile/README.md) —
  clone + sed + bot commit/push, same pattern as the Sync Wiki step.
- Org **description** field ('… N indicators, install-free.') — gh api PATCH.

The two org steps need ABOUT_SYNC_TOKEN scope the main-repo syncs do not
(write on wickra-lib/.github; admin:org for the description PATCH). Both are
written to soft-skip with a ::warning:: and continue-on-error, so the run
stays green until the scope is granted — then they sync with no further
change. Homepage swap-point to a docs domain is a one-line change.

Refs findings P9 / P10.
2026-05-31 03:02:32 +02:00
kingchenc 01aeb965d1 release: bump 0.3.0 -> 0.3.1 (#80)
* chore: remove ROADMAP.md from the public repo

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

* release: bump 0.3.0 -> 0.3.1

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

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

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

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

Replace the three invalid calls with a single workspace pass and copy
the three crates.io crate SBOMs into the upload dir.
2026-05-30 19:26:30 +02:00
49 changed files with 4936 additions and 1260 deletions
-1
View File
@@ -3,4 +3,3 @@
# Leave a key empty (e.g. patreon:) to skip a platform.
github: [kingchenc]
custom: ["https://wickra.org/sponsor"]
+3 -3
View File
@@ -24,9 +24,9 @@
- [ ] New behaviour has tests; bug fixes have a regression test.
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
and their type stubs (If applicable).
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
and the `README.md` are updated (If applicable). Wiki edits go to a
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
and the `README.md` are updated (If applicable). Docs edits go to a
separate repository: `https://github.com/wickra-lib/wickra-docs`.
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
## Notes for reviewers
+25 -2
View File
@@ -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
+138
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
name: OpenSSF Scorecard
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
# schedule, on branch-protection changes, and on push to main. `publish_results`
# uploads the score to the public OpenSSF API so the README badge resolves, and
# the SARIF is surfaced under the repo's Security → Code scanning tab.
on:
branch_protection_rule:
schedule:
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
push:
branches: [main]
workflow_dispatch:
# Read-only by default; the analysis job widens to exactly what it needs.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload the SARIF result to code-scanning
id-token: write # OIDC token to publish results to the OpenSSF API
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Run Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
- name: Upload SARIF artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
sarif_file: results.sarif
+258 -19
View File
@@ -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
+2 -2
View File
@@ -14,8 +14,8 @@ jobs:
name: metadata audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Audit repo-metadata.toml drift
+7 -3
View File
@@ -44,10 +44,14 @@ tarpaulin-report.html
# Node binding artifacts
**/node_modules/
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
# package-lock.json is committed (under bindings/node/) so contributors
# get reproducible npm installs. Top-level lockfiles still aren't expected.
# index.js + index.d.ts are generated by `napi build` but committed (a matched
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
# regenerating both when a binding's public API changes.
# package-lock.json is committed for the tracked Node packages — bindings/node/
# and examples/node/ — so contributors get reproducible npm installs. There is
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
# WASM build output
bindings/wasm/pkg/
+56 -1
View File
@@ -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
View File
@@ -6,7 +6,7 @@ message: >-
type: software
authors:
- alias: kingchenc
email: wickra.lib@gmail.com
email: support@wickra.org
repository-code: "https://github.com/wickra-lib/wickra"
url: "https://wickra.org"
abstract: >-
+1 -1
View File
@@ -33,7 +33,7 @@ project in public spaces.
## Enforcement
Instances of unacceptable behaviour may be reported to the project maintainer
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
at **support@wickra.org**. All reports will be reviewed and investigated
promptly and fairly, and the maintainer will respect the privacy and security
of the reporter.
+25 -5
View File
@@ -35,8 +35,13 @@ cargo test --workspace
cargo test -p wickra-data --features live-binance
```
The minimum supported Rust version is **1.75** for the workspace crates and
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
The minimum supported Rust version is **1.86** for the workspace crates and
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
not chosen freely — they are the lowest versions our dependencies allow
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
the library builds for the widest possible audience; please don't raise it
without a dependency that actually requires it.
### Python
@@ -63,6 +68,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
View File
@@ -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
View File
@@ -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"
+7 -1
View File
@@ -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>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![CodeQL](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://img.shields.io/github/v/release/wickra-lib/wickra?logo=github&color=green)](https://github.com/wickra-lib/wickra/releases/latest)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
-203
View File
@@ -1,203 +0,0 @@
# Roadmap
What Wickra is heading toward, what is explicitly out of scope, and what
contributors can expect across the next 0.x versions. Roadmap items are
**aspirations, not commitments** — order may shift based on real
user-feedback and bug-priority.
For "what shipped already" see [`CHANGELOG.md`](CHANGELOG.md). For the
internal structure that the roadmap items will plug into, see
[`ARCHITECTURE.md`](ARCHITECTURE.md).
## North star
> A streaming-first technical-analysis core that is the obvious default
> for anyone writing a Rust, Python, Node or browser-based trading
> system — drop-in fast, drop-in correct, drop-in tested.
Three measurable proxies for "obvious default":
1. **Coverage.** Every textbook indicator from TA-Lib, pandas-ta and
talipp is in Wickra and produces matching reference values.
2. **Performance.** Streaming `update` is the fastest published number
across Rust / Python / Node / WASM for any technical indicator.
3. **Trust.** Releases are reproducible, signed, SBOM-attached, with a
public 100% test/branch coverage badge.
## 0.3.0 — target window: Q3 2026
The first release after the org migration to `wickra-lib` lands and the
project portal at `wickra.org` is live.
### Headline goals
- **WASM has automated tests.** `wasm-bindgen-test` job in CI exercising
a representative subset (~30 indicators across all families). Today
WASM is only smoke-validated through manual examples.
- **Release-pipeline trust.** SBOM (CycloneDX) generated per release,
Sigstore cosign signatures attached to every published artifact,
npm `--provenance` flag enabled, PyPI Trusted Publishers configured.
- **Hosted documentation portal.** `wickra.org` (Cloudflare Pages,
VitePress) replaces the GitHub Wiki as the canonical doc surface.
Per-indicator deep-dives, quickstarts, FAQ, search.
- **End-to-end strategy examples.** 3 runnable examples that wire
Wickra indicators into a full mean-reversion / trend-following /
breakout strategy with PnL and equity-curve output.
- **`ARCHITECTURE.md` + `ROADMAP.md` + `CITATION.cff`** governance
baseline (this is it).
### Stretch goals (might slip to 0.4.0)
- **Per-binding hosted API reference.** TypeDoc for Node/WASM,
Sphinx for Python, both hosted on `wickra.org/api/*`. Rust stays on
`docs.rs`.
- **Property-tests (`proptest`) for mathematical invariants.** Bound
checks on RSI ∈ [0, 100], Bollinger ordering, batch-streaming
equivalence on random inputs.
- **Nightly long-fuzz workflow.** Each fuzz target gets ~1h overnight,
findings auto-converted to issues.
## 0.4.0 — target window: Q4 2026
### Indicator catalogue expansion
The current 214 indicators cover the textbook canon. The next wave is
the "stuff people actually ask for but skip because it's painful in
other libs":
- **Anchored indicators.** AnchoredVwap is already in, but anchored
variants of common indicators (AnchoredATR, AnchoredVolatility) are
on the wishlist for explicit session-based analysis.
- **Multi-timeframe (MTF) chaining helpers.** A `Mtf<Indicator>` wrapper
that runs an indicator on a different timeframe of the same input
stream — solves the most common reason people drop down to ad-hoc
buffering code.
- **Order-flow primitives** (gated on tick-data ingestion landing in
`wickra-data`): CumulativeDelta, BidAskImbalance, VolumeAtPrice.
- **Pivot-confirmation patterns.** WilliamsFractals is already in;
ZigZag is in. Next: PivotHigh/PivotLow with configurable
left/right-bar confirmation, used as a feature for higher-level
pattern detectors.
- **Harmonic-chart patterns** (Gartley, Bat, Butterfly, Crab, Shark).
These need the pivot-detector + ratio-matcher framework first; the
candlestick-pattern family from 0.2.8 is the precedent.
### Live-data layer
- **Tick-data ingestion.** Extend `wickra-data` from OHLCV-only to
also accept raw ticks; the existing `Aggregator` already aggregates
ticks into bars but isn't exposed in the public live-feed API yet.
- **Generic exchange trait.** `LiveFeed { fn subscribe(...) -> impl
Stream<Item = Candle> }` so the Binance adapter is one implementor
among many. **Note**: Wickra will not aggregate exchanges itself
(use `ccxt` if you need that) — the trait exists so user code can
swap feeds without touching indicator code.
### Performance & reliability
- **Performance-regression tracking.** `bench.yml` outputs deployed to
a `gh-pages` branch, `github-action-benchmark` plot over time,
threshold-based alerts on regression.
- **Indicator parity test suite.** Every indicator that has a TA-Lib
/ pandas-ta / talipp equivalent must pass a golden-value test
against that reference. Currently most do but the test names are
scattered — consolidate into one `parity_tests` module.
## 0.5.0 — target window: 2027 H1
### Possible new bindings
Open questions, prioritised by demand signals (≈ GitHub stars + issue
requests + community polls):
- **Java / Kotlin** binding via UniFFI — interest from Android-side
trading-app developers and the JVM-quant community.
- **Go** binding — interest from algorithmic-trading shops running on
Linux + Go infra.
- **C / C++** header export (`cbindgen`) — drops Wickra into existing
C/C++ trading stacks (e.g. older Bloomberg / FIX-protocol shops).
- **Swift** — niche but real, iOS / macOS native trading apps.
- **.NET** — closes the Windows-native gap that NAPI-Node doesn't fill
(some shops are still on .NET Framework).
None of these are committed — each is a 1-2-month project on its own,
and bindings without active maintenance are a liability. Priority will
be driven by which language community shows up with PRs.
### `wickra-data` widening
- **Historical fetch from > 1 source.** Today only Binance REST/WS.
Add Coinbase and Kraken as reference implementations — explicitly
not exchange-aggregation, just "here are two more adapters using
the trait".
## Beyond — long-term aspirations
- **`wickra-backtest` sub-crate** (decision pending). A minimal
event-driven backtester wrapping signal generation + position
sizing + fees + slippage. Decision factor: do users keep building
these ad-hoc from `examples/`? If yes, codifying it saves the
ecosystem time. If most users plug Wickra into existing backtesters
(vectorbt, backtrader, Lean, Hummingbot), keep Wickra focused.
- **`wickra-plot` companion** (low priority). `plotters`-backed Rust
rendering of indicator outputs. Mainly useful for static report
generation; live charting belongs in the JS/web layer.
- **Academic adoption.** Citable `CITATION.cff`; targeting at least
one peer-reviewed paper using Wickra as the reference indicator
engine.
## What is explicitly **not** on the roadmap
These are recurring requests that Wickra will decline so contributors
don't waste time on them:
| Feature | Why declined |
|---|---|
| Exchange aggregation across N venues | That's `ccxt`'s job. Wickra ships *one* feed implementor (Binance) for tests and demo; users plug their preferred exchange. |
| Full backtesting framework (à la Lean, vectorbt) | Scope creep. May land as `wickra-backtest` *if* a clear minimal API surfaces from user demand, but Wickra core stays an indicator library. |
| Strategy auto-tuning / hyperparameter search | Wrong abstraction layer — belongs in user-side ML/backtester. |
| Order-management / broker integration | Even further out of scope. |
| GUI / web-based studio | Marketing-site demos are fine; full IDE is not. |
| Indicator implementations that require optional Python deps (e.g. scipy KDE) | Bindings must work on a clean install. Pure-Rust math only. |
| Proprietary indicator implementations (e.g. paywalled vendor formulas) | License conflicts + audit burden. |
| GPU / CUDA acceleration | O(1) per update means the bottleneck is API overhead, not compute. SIMD-batch is a maybe; GPU adds no value for streaming. |
## How indicator wishlist requests are handled
1. **Open an issue** using the `feature_request` template, naming the
indicator, citing one of:
- TA-Lib reference
- pandas-ta reference
- peer-reviewed paper
- widely-published trading book
2. Maintainer triages within ~1 week. Acceptance criteria:
- Formula has at least one written reference (no random
YouTuber-only indicators)
- Implementation can be O(1) streaming
- Test vectors are obtainable (from reference lib, hand-computed,
or paper-provided)
3. Once accepted, the indicator gets added to the next family-batch
PR. Family-batches typically ship 5-20 indicators at a time.
## Versioning
Wickra follows [SemVer](https://semver.org/). The promise:
- **0.x.0 (minor):** new indicators, new bindings, new optional features,
new optional config knobs. Adding methods to `Indicator` with default
impls is minor.
- **0.x.y (patch):** bug fixes, performance improvements, doc fixes.
- **Major (1.0.0 and later):** changes to the `Indicator` trait
signature, removal of indicators, breaking changes to `Candle` /
`OHLCV` field order.
The 1.0 milestone is reserved for when the indicator catalogue is
considered "stable enough" and the bindings API has stabilised — likely
~2027 once 2-3 more bindings have shipped and stress-tested the trait.
## Discussion
For roadmap discussion, open a [Discussions](https://github.com/wickra-lib/wickra/discussions)
thread tagged `roadmap`. Specific indicator wishlist items go in
[Issues](https://github.com/wickra-lib/wickra/issues) via the
`feature_request` template.
+1 -1
View File
@@ -18,7 +18,7 @@ Report it privately through one of:
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
("Report a vulnerability" under the repository's *Security* tab), or
- email to **wickra.lib@gmail.com** with a subject line starting with
- email to **support@wickra.org** with a subject line starting with
`[wickra security]`.
Please include:
+45 -286
View File
@@ -1,314 +1,73 @@
# Wickra
# Wickra — Node.js
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators for Node.js. `npm install wickra`
prebuilt native binary, no system dependencies.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Node.js binding (napi-rs);
it exposes 200+ streaming-first indicators across sixteen families.
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
## Install
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
npm install wickra
```
## Indicators
The native addon ships as a prebuilt binary per platform (Linux, macOS,
Windows — x64 and arm64), selected automatically through optional
dependencies. There is nothing to compile.
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
## Quick start
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
```js
const wickra = require('wickra');
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
// Batch: run an indicator over a whole array.
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
const values = new wickra.RSI(14).batch(prices); // null during warmup
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
// Streaming: the same indicator, fed tick by tick in O(1).
const rsi = new wickra.RSI(14);
for (const price of liveFeed) {
const value = rsi.update(price); // no recomputation over history
if (value !== null && value > 70) {
console.log('overbought');
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
## Project layout
## Documentation
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
## Building everything from source
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
## Disclaimer
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
@@ -0,0 +1,70 @@
// Completeness contract for the Wickra Node bindings: every exported indicator
// class must expose the full streaming + batch + lifecycle interface. This
// catches a new indicator being wired into the binding without the standard
// methods (or an export silently disappearing) without needing a hand-written
// test per indicator.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// An "indicator class" is an exported constructor whose prototype carries the
// streaming `update` method. This excludes `version` (a plain function) and any
// non-indicator export.
function indicatorClasses() {
return Object.keys(wickra).filter((name) => {
const value = wickra[name];
return (
typeof value === 'function' &&
value.prototype &&
typeof value.prototype.update === 'function'
);
});
}
test('the binding exports the full indicator catalogue', () => {
const names = indicatorClasses();
// The published catalogue is 214 indicators. Guard against a regression that
// silently drops exported classes (e.g. a stale or partial native build).
assert.ok(
names.length >= 200,
`expected at least 200 indicator classes, got ${names.length}`,
);
});
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
const missing = [];
for (const name of indicatorClasses()) {
const proto = wickra[name].prototype;
for (const method of required) {
if (typeof proto[method] !== 'function') {
missing.push(`${name}.${method}`);
}
}
}
assert.deepEqual(
missing,
[],
`indicator classes missing required methods: ${missing.join(', ')}`,
);
});
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
// Every indicator that takes no constructor arguments must still satisfy the
// pre-warmup contract. (Indicators with required parameters are exercised by
// the dedicated suites; here we cover the zero-arg ones generically.)
let checked = 0;
for (const name of indicatorClasses()) {
let instance;
try {
instance = new wickra[name]();
} catch {
continue; // needs constructor arguments — covered elsewhere
}
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
checked += 1;
}
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
});
@@ -0,0 +1,84 @@
// Input-validation tests for the Wickra Node bindings: malformed constructor
// parameters and mismatched batch inputs must raise a JS Error (the napi
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
// Node counterpart of bindings/python/tests/test_input_validation.py.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
// --- Constructors reject invalid periods / parameters ---
test('ATR rejects a zero period at construction', () => {
// ATR validates its period (it drives the Wilder-smoothing length). The
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
// warmup-1 pass-through rather than an error, so they are not asserted here.
assert.throws(() => new wickra.ATR(0), /.*/);
});
test('MACD rejects zero and non-increasing fast/slow periods', () => {
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
// fast must be strictly less than slow.
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
});
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
});
test('PSAR rejects a step greater than its maximum', () => {
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
});
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
});
test('InitialBalance and OpeningRange reject a zero period', () => {
assert.throws(() => new wickra.InitialBalance(0), /.*/);
assert.throws(() => new wickra.OpeningRange(0), /.*/);
});
test('Ichimoku rejects zero and non-increasing periods', () => {
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
// Periods must satisfy tenkan < kijun < senkouB.
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
});
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
// InverseFisherTransform needs a non-zero scaling factor.
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
// MAMA needs fast limit > slow limit.
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
// EmpiricalModeDecomposition needs a positive fraction.
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
// binding treats their period 0 as a warmup-1 pass-through (same as the
// simple moving averages) rather than an error.
});
// --- Batch methods reject mismatched input lengths ---
test('candle batch methods reject unequal-length columns', () => {
const high = [10, 11, 12];
const low = [9, 10]; // one short
const close = [9.5, 10.5, 11.5];
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
});
test('ValueArea batch rejects unequal-length columns', () => {
const high = [1, 2, 3];
const low = [0.5, 1.5]; // short
const volume = [10, 10, 10];
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
});
+6 -6
View File
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
}
});
test('zero period is clamped to a valid window', () => {
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
// clamp pathological values like period=0 to the smallest valid window.
const sma = new wickra.SMA(0);
assert.equal(sma.warmupPeriod(), 1);
assert.equal(sma.update(42), 42);
test('zero period is rejected at construction', () => {
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
// it as a thrown JS error, consistent with the Python and WASM bindings.
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
// A valid period still constructs and runs.
assert.equal(new wickra.SMA(1).update(42), 42);
});
+99
View File
@@ -0,0 +1,99 @@
// Throughput benchmark for the Wickra Node bindings.
//
// Measures how many indicator updates per second the native binding sustains,
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
// O(1) streaming engine (there is no install-free TA library on npm with a
// comparable surface to compare against), so the headline number is raw
// throughput, not a cross-library ratio.
//
// Run after building the binding:
//
// cd bindings/node && npm install && npx napi build --platform --release
// node benchmarks/throughput.js # 200k bars (default)
// node benchmarks/throughput.js --bars 1000000
const wickra = require('..');
function parseBars() {
const idx = process.argv.indexOf('--bars');
if (idx !== -1 && process.argv[idx + 1]) {
const n = Number(process.argv[idx + 1]);
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
console.error('--bars must be a number >= 1000');
process.exit(1);
}
return 200_000;
}
const BARS = parseBars();
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
const close = new Array(BARS);
const high = new Array(BARS);
const low = new Array(BARS);
const volume = new Array(BARS);
for (let i = 0; i < BARS; i++) {
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
close[i] = mid + Math.sin(i * 0.05) * 2;
high[i] = Math.max(close[i], mid) + 1.5;
low[i] = Math.min(close[i], mid) - 1.5;
volume[i] = 1000 + (i % 97) * 13;
}
// Median elapsed-ns over a few repetitions, after one warmup pass.
function timeNs(fn, reps = 3) {
fn(); // warmup (JIT + cache)
const samples = [];
for (let r = 0; r < reps; r++) {
const t0 = process.hrtime.bigint();
fn();
samples.push(Number(process.hrtime.bigint() - t0));
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length / 2)];
}
function mupsFromNs(ns) {
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
}
// Each indicator: a streaming step and a batch call over the full series.
const indicators = [
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
];
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
console.log('-'.repeat(60));
for (const ind of indicators) {
const streamNs = timeNs(() => {
const inst = ind.make();
for (let i = 0; i < BARS; i++) ind.step(inst, i);
});
const batchNs = timeNs(() => {
ind.batch(ind.make());
});
console.log(
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
);
}
console.log(
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
'better. Numbers are machine-dependent — use them for relative comparison.',
);
+2243
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": [
+20 -20
View File
@@ -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"
+10 -9
View File
@@ -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
View File
@@ -22,26 +22,6 @@ fn map_err(e: wc::Error) -> NapiError {
NapiError::new(Status::InvalidArg, e.to_string())
}
/// Helper for the scalar-indicator macro only. Scalar `new` functions can fail
/// solely on `period == 0`, which `clamp_period` already rules out, so the
/// `Result` is provably `Ok` here. Candle indicators and the multi-parameter
/// indicators have genuinely fallible parameters and instead use fallible
/// `#[napi(constructor)]`s that return `napi::Result<Self>` and throw a JS error.
fn must<T>(r: Result<T, wc::Error>) -> T {
r.expect("wickra: scalar indicator parameter clamped to a valid range")
}
/// Clamp a period parameter so the underlying indicator never sees zero. JS
/// callers who pass `0` get a window of `1` instead of a thrown exception —
/// effectively a pass-through indicator that still produces valid outputs.
const fn clamp_period(p: u32) -> usize {
if p == 0 {
1
} else {
p as usize
}
}
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
}
@@ -64,10 +44,10 @@ macro_rules! node_scalar_indicator {
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(<$rust_ty>::new(clamp_period(period))),
}
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: <$rust_ty>::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
@@ -137,11 +117,10 @@ node_scalar_indicator!(
);
// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period`
// parameter and additionally rejects `period == 1` (a 1-bar standard
// deviation is always zero), so the `clamp_period`-to-1 strategy from
// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible
// constructor instead, throws a JS error on bad period.
// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` and
// rejects both `period == 0` and `period == 1` (a 1-bar standard deviation is
// always zero). Hand-rolled, but behaves like `node_scalar_indicator!`: the
// fallible `new` propagates the core error and throws a JS error on bad period.
#[napi(js_name = "RVIVolatility")]
pub struct RviVolatilityNode {
inner: wc::RviVolatility,
@@ -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
View File
@@ -1,314 +1,72 @@
# Wickra
# Wickra — Python
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators for Python. `pip install wickra` — no
system dependencies, no C build tooling.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading bots and historical backtests share
the exact same implementation. This package is the Python binding (PyO3); it
exposes 200+ streaming-first indicators across sixteen families.
## Install
```bash
pip install wickra
```
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
compile and no C library to track down.
## Quick start
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
# Batch: classic TA-Lib-style usage over a whole array.
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
# Streaming: the same indicator, fed tick by tick in O(1).
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
value = rsi.update(price) # no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
`batch(prices)` and feeding the same prices through `update()` produce
identical values — the equivalence is enforced by the test suite.
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
## Documentation
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
## Benchmark: how much faster is "streaming-first"?
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
## Disclaimer
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
## Indicators
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+2 -2
View File
@@ -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
View File
@@ -1,314 +1,72 @@
# Wickra
# Wickra — WebAssembly
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![npm](https://img.shields.io/npm/v/wickra-wasm.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra-wasm)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
**Streaming-first technical indicators in the browser. `npm install
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
Wickra is a multi-language technical-analysis library with a Rust core and
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
streaming state machine, so live trading dashboards and historical backtests
share the exact same implementation. This package is the WebAssembly binding
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
indicators across sixteen families.
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
## Install
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
npm install wickra-wasm
```
## Indicators
## Quick start
214 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
The module ships a default `init` export that loads the `.wasm` payload; await
it once before constructing indicators.
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
```js
import init, { RSI } from 'wickra-wasm';
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
await init(); // load the WebAssembly module once
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
Each binding ships several runnable examples (streaming, backtest, live feed);
[`examples/README.md`](examples/README.md) is the full cross-language index.
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
// Streaming: feed prices tick by tick in O(1).
const rsi = new RSI(14);
for (const price of liveFeed) {
const value = rsi.update(price); // null during warmup
if (value !== null && value > 70) {
console.log('overbought');
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
`null` while the indicator is still warming up.
## Project layout
## Documentation
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 71 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
├── examples/ examples/README.md indexes every language
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
│ ├── rust/ Rust workspace member (`wickra-examples`)
│ ├── python/ backtest, live trading, parallel assets, multi-tf
│ ├── node/ streaming, backtest, live trading (load `wickra`)
│ └── wasm/ browser demo for `wickra-wasm`
└── .github/workflows/ CI and release pipelines
```
The full indicator catalogue, guides, quickstarts, and API reference live in
the main repository and documentation site:
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
## Building everything from source
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
## Disclaimer
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Testing
Every layer is covered; run the suites with the commands in
[Building everything from source](#building-everything-from-source).
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
equivalence, reference values, lifecycle, input validation, and
dict/tuple candle inputs.
- `bindings/node`: `node --test` cases for batch, streaming, and reference
values across all indicators.
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
and reference values.
## Contributing
Contributions are very welcome — issues, bug reports, ideas, and pull requests
all land in the same place: <https://github.com/wickra-lib/wickra>.
A short orientation for first-time contributors:
- **Adding an indicator.** Implement the `Indicator` trait in
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
`indicators/mod.rs` and the crate root, and add reference-value tests,
a `batch == streaming` equivalence test, and (where it makes sense) a
proptest. The four bindings inherit your indicator automatically once
you expose it in the language wrappers.
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
first, then fix the math. Property tests in `crates/wickra-core` catch
most regressions; please don't disable them.
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
its own tests; please keep the `batch == streaming` invariant.
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
are CI gates; running them locally before pushing keeps reviews short.
For larger architectural changes, open an issue first so we can sketch the
shape together before you invest the time.
Wickra is an indicator toolkit, not a trading system. The values it computes
are deterministic transforms of the input data — they are not financial advice
and do not predict the market. Any use in a live trading context is at your own
risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
---
<p align="center">
<a href="https://github.com/wickra-lib/wickra/stargazers">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
</a>
<a href="https://github.com/wickra-lib/wickra/network/members">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
</a>
<a href="https://github.com/wickra-lib/wickra/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
</a>
</p>
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
+146
View File
@@ -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
View File
@@ -1,30 +1,31 @@
# Documentation
Wickra's full documentation lives in the **[GitHub Wiki](https://github.com/wickra-lib/wickra/wiki)**.
Wickra's full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**.
That includes:
- **Quickstarts** for [Rust](https://github.com/wickra-lib/wickra/wiki/Quickstart-Rust.md),
[Python](https://github.com/wickra-lib/wickra/wiki/Quickstart-Python.md),
[Node](https://github.com/wickra-lib/wickra/wiki/Quickstart-Node.md), and
[WASM](https://github.com/wickra-lib/wickra/wiki/Quickstart-WASM.md).
- **Quickstarts** for [Rust](https://docs.wickra.org/Quickstart-Rust),
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **214 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
S/R, DeMark, Ichimoku & Charts, Candlestick Patterns, Market Profile,
Risk / Performance) — see the
[indicators overview](https://github.com/wickra-lib/wickra/wiki/Indicators-Overview.md).
- **Reference pages**: [warmup periods](https://github.com/wickra-lib/wickra/wiki/Warmup-Periods.md),
[streaming vs batch](https://github.com/wickra-lib/wickra/wiki/Streaming-vs-Batch.md),
[indicator chaining](https://github.com/wickra-lib/wickra/wiki/Indicator-Chaining.md), and the
[data layer](https://github.com/wickra-lib/wickra/wiki/Data-Layer.md).
- **Guides**: [Cookbook](https://github.com/wickra-lib/wickra/wiki/Cookbook.md),
[TA-Lib migration](https://github.com/wickra-lib/wickra/wiki/TA-Lib-Migration.md),
[FAQ](https://github.com/wickra-lib/wickra/wiki/FAQ.md).
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference pages**: [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), and the
[data layer](https://docs.wickra.org/Data-Layer).
- **Guides**: [Cookbook](https://docs.wickra.org/Cookbook),
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Editing the wiki
## Editing the docs
The wiki is a separate git repository at `https://github.com/wickra-lib/wickra.wiki.git`.
Clone it locally if you want to bulk-edit; otherwise the GitHub web UI's "Edit" button on any
wiki page is fine for one-off changes.
The documentation site is a separate git repository at
`https://github.com/wickra-lib/wickra-docs`. Open a pull request there to
propose changes; the site is built with VitePress and deploys to
`docs.wickra.org`.
+6
View File
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
## WebAssembly — `examples/wasm/`
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long &lt; 30, exit &gt; 70); prints a PnL / Sharpe / max-DD summary table. |
| `strategy_macd_adx.html` | Hourly BTCUSDT MACD crossover gated by ADX(14) &gt; 20, with the same summary table. |
| `strategy_bollinger_squeeze.html` | Daily BTCUSDT Bollinger-squeeze breakout with a 2&times;ATR(14) stop and summary table. |
## Example datasets
+64
View File
@@ -0,0 +1,64 @@
{
"name": "wickra-examples-node",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra-examples-node",
"version": "0.0.0",
"license": "SEE LICENSE IN ../../LICENSE",
"dependencies": {
"wickra": "file:../../bindings/node"
},
"devDependencies": {
"ws": "^8.18.0"
}
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.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
}
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
// Strategy example: Bollinger-Squeeze breakout with ATR-based stop.
//
// Enters long when the Bollinger Bandwidth has just printed a fresh 6-month low
// (the squeeze) and price closes above the upper band (the release). Exits when
// price closes below entry minus 2 * ATR(14), or when the upper band rolls back
// under the entry price. 0.1% fees per trade.
//
// Educational example. NOT a live trading recommendation. The Node counterpart
// of `examples/python/strategy_bollinger_squeeze.py` and the Rust
// `examples/rust/src/bin/strategy_bollinger_squeeze.rs`, printing the same
// summary.
//
// Build the native binding once, then run it:
//
// cd bindings/node && npm install && npx napi build --platform --release
// cd ../../examples/node && npm install
// node strategy_bollinger_squeeze.js
//
// Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because daily bars
// give an interpretable 6-month-low lookback (~180 bars).
const fs = require('node:fs');
const path = require('node:path');
const wickra = require('wickra');
const FEE = 0.001;
const BB_PERIOD = 20;
const BB_K = 2.0;
const ATR_PERIOD = 14;
const ATR_STOP_MULT = 2.0;
const SQUEEZE_LOOKBACK = 180;
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
function loadCandles(csvPath) {
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
const candles = [];
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
const candle = {};
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
const raw = cells[idx[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
candle[col] = value;
}
candles.push(candle);
}
return candles;
}
function signed(value, digits) {
return (value >= 0 ? '+' : '') + value.toFixed(digits);
}
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
const buyHold = lastPrice / firstPrice;
const stratReturn = finalEquity - 1.0;
const bhReturn = buyHold - 1.0;
const wins = closedTrades.filter((r) => r > 0).length;
const losses = closedTrades.filter((r) => r < 0).length;
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
const n = closedTrades.length;
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
const varRet =
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
const stddev = Math.sqrt(varRet);
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
let peak = equityCurve.length ? equityCurve[0] : 1.0;
let maxDd = 0.0;
for (const eq of equityCurve) {
if (eq > peak) peak = eq;
const dd = (peak - eq) / peak;
if (dd > maxDd) maxDd = dd;
}
const label = (s) => s.padEnd(23);
console.log(`=== ${name} ===`);
console.log(`${label('Bars:')}${bars}`);
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
console.log(
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
);
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
console.log();
console.log(
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
'are simplified or omitted. Past performance is not indicative of future results.',
);
}
function main() {
const csvPath = process.argv[2] || DEFAULT_CSV;
let candles;
try {
candles = loadCandles(csvPath);
} catch (err) {
console.error(`error: ${err.message}`);
process.exit(1);
}
if (candles.length < SQUEEZE_LOOKBACK + BB_PERIOD) {
console.error(
`error: dataset has only ${candles.length} bars; need at least ` +
`${SQUEEZE_LOOKBACK + BB_PERIOD}`,
);
process.exit(1);
}
const bb = new wickra.BollingerBands(BB_PERIOD, BB_K);
const atr = new wickra.ATR(ATR_PERIOD);
// Rolling window of the last SQUEEZE_LOOKBACK bandwidths (Python deque(maxlen)).
const bwWindow = [];
let inPosition = false;
let entryPrice = 0.0;
let stopLevel = 0.0;
const closedTrades = [];
let equity = 1.0;
const equityCurve = [];
for (const c of candles) {
const bbOut = bb.update(c.close);
const atrVal = atr.update(c.high, c.low, c.close);
const price = c.close;
const mtm = inPosition ? equity * (price / entryPrice) : equity;
equityCurve.push(mtm);
if (bbOut == null || atrVal == null) continue;
const { upper, middle, lower } = bbOut;
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
if (Number.isNaN(bandwidth)) continue;
bwWindow.push(bandwidth);
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
if (inPosition) {
const stopHit = price < stopLevel;
const upperCollapse = upper < entryPrice;
if (stopHit || upperCollapse) {
const tradeRet = price / entryPrice - 1.0;
closedTrades.push(tradeRet);
equity *= (1.0 + tradeRet) * (1.0 - FEE);
inPosition = false;
}
} else {
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
const breakout = price > upper;
if (isNewLow && breakout) {
entryPrice = price;
stopLevel = price - ATR_STOP_MULT * atrVal;
equity *= 1.0 - FEE;
inPosition = true;
}
}
}
if (inPosition) {
const lastPrice = candles[candles.length - 1].close;
const tradeRet = lastPrice / entryPrice - 1.0;
closedTrades.push(tradeRet);
equity *= (1.0 + tradeRet) * (1.0 - FEE);
}
printSummary(
'Bollinger Squeeze Breakout (1d, BTCUSDT)',
candles[0].close,
candles[candles.length - 1].close,
candles.length,
closedTrades,
equity,
equityCurve,
);
}
main();
+189
View File
@@ -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
View File
@@ -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 &lt; 30, exit &gt; 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) &gt; 20, summary table. Mirrors `examples/python/strategy_macd_adx.py`. |
| `strategy_bollinger_squeeze.html` | Bollinger-squeeze breakout with a 2&times;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&times;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>
+203
View File
@@ -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) &gt; 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
View File
@@ -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]