Compare commits

..

8 Commits

Author SHA1 Message Date
kingchenc 4631519885 release: bump 0.4.0 -> 0.4.1 (#110)
Releases the cross-asset / pairwise indicator family (PR #109):
PairwiseBeta, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration,
RelativeStrengthAB. Indicator count 214 -> 219.

Bumps workspace + binding versions and the CHANGELOG ([Unreleased] ->
[0.4.1]) with the new compare URL.
2026-06-01 13:58:50 +02:00
kingchenc 0b85142ad1 feat: cross-asset / pairwise indicators (5 new) (#109)
* feat(core): add PairwiseBeta cross-asset indicator

Rolling OLS slope of one asset's log-returns on another's. Unlike Beta,
which regresses the raw inputs it is fed, PairwiseBeta differences
consecutive prices into log-returns internally -- the conventional way to
measure cross-asset beta, where a beta on price levels would be dominated
by the shared trend.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with unit/known-value/streaming tests and a pair fuzz target.

* feat(core): add PairSpreadZScore cross-asset indicator

Standardised log-spread ln(a) - beta*ln(b) of a pair, where beta is a
rolling-OLS hedge ratio and the spread is z-scored over its own look-back.
The canonical mean-reversion / statistical-arbitrage entry signal, with
independent beta_period and z_period windows.

Two-series Indicator<Input = (f64, f64)>, exposed in Rust, Python, Node
and WASM, with sign/known-value/streaming tests and a pair fuzz target.

* feat(core): add LeadLagCrossCorrelation cross-asset indicator

Reports the integer offset k in [-max_lag, max_lag] that maximises
|corr(a[t], b[t+k])|, answering which of two assets leads the other and by
how many bars. A positive lag means a leads b. Fully causal: a's window is
held centred while b's window slides across the buffered history, so every
lag is evaluated only against data already seen.

Struct output { lag, correlation }, exposed in Rust, Python, Node and WASM
with lead-detection/streaming tests and a pair fuzz driver.

* feat(core): add Cointegration (Engle-Granger + ADF) indicator

Rolling pairs-trading screen: an OLS hedge ratio of a on b, the spread
(residual) a - (alpha + beta*b), and an augmented Dickey-Fuller t-statistic
on the spread with configurable lags. A strongly negative statistic flags a
mean-reverting, tradeable spread. Includes a small Gaussian-elimination
solver for the augmented regression.

Struct output { hedge_ratio, spread, adf_stat }, exposed in Rust, Python,
Node and WASM with stationarity/hedge-ratio/streaming tests and a pair fuzz
driver.

* feat(core): add RelativeStrengthAB cross-asset indicator

Comparative relative strength of two assets: the ratio line a/b together
with its moving average and its RSI, the classic asset-vs-asset /
asset-vs-index rotation screen. Composes the existing Sma and Rsi over the
ratio; a zero denominator or non-finite price is skipped.

Struct output { ratio, ratio_ma, ratio_rsi }, exposed in Rust, Python, Node
and WASM with flat/rising-ratio/streaming tests and a pair fuzz driver.

* test(cointegration): cover ADF guard branches

The ADF helper's short-series and degrees-of-freedom guards and the
zero-dispersion (perfect AR) path are unreachable through the public
Cointegration API (period >= 2*adf_lags + 4), so exercise them with direct
unit tests on adf_no_constant. The second linear solve cannot be singular
once the coefficient solve on the same matrix has succeeded, so it now uses
expect() instead of a dead error branch.
2026-06-01 13:45:21 +02:00
kingchenc 1ab9bc70d1 ci(release): make the release immutability-ready (draft then publish) (#108)
GitHub release immutability locks a release's assets at publish time. The
current flow publishes the release in github-release and only afterwards uploads
the Sigstore provenance bundle (P21.1e) via 'gh release upload', which
immutability would reject (actions/attest-build-provenance#734).

Reorder to draft -> attach everything -> publish:
- github-release now creates the release as a draft (draft: true) with all build
  artefacts.
- attestations attaches the provenance bundle to the draft (gh release upload
  works on drafts), unchanged otherwise.
- a new publish-release job flips the draft to published + latest, gated on
  'always() && needs.github-release.result == success' so a Sigstore hiccup in
  attestations costs only the provenance asset, never the release — the same
  isolation as before. A skipped github-release (failed publish) skips this too.

Correct with immutability off (today: release ends published with every asset)
and on (later, user toggle: all assets present before the lock). No behaviour
removed; nothing deleted.
2026-06-01 12:06:20 +02:00
kingchenc 2ab578bee8 docs: surface docs.wickra.org + keep the wiki pointer count in sync (#107)
* docs(readme): surface the documentation site (docs.wickra.org)

The README never linked the canonical docs at docs.wickra.org — visitors had
no path from the repo to the per-indicator deep dives, quickstarts, and
guides. Add a docs badge, a Documentation section mirroring the binding
READMEs and docs/README.md, and an Indicators-Overview link in the indicator
section.

* ci(sync-about): keep the wiki pointer page's indicator count in sync

The GitHub wiki was collapsed to a single Home.md that points at
docs.wickra.org but still names the indicator count ('… for all N indicators').
Add a count-sync step mirroring the docs/webpage steps — clone wickra.wiki into
its own dir, sed Home.md, commit as wickra-bot, push — with the same
continue-on-error soft-skip so a missing PAT scope never fails the run.
2026-06-01 04:10:00 +02:00
kingchenc 2be39b8b98 ci(release): attach Sigstore provenance bundle as a release asset (P21.1e) (#106)
OpenSSF Scorecard's Signed-Releases check scans the GitHub Release *assets* for
signed/provenance files (`*.intoto.jsonl`, `*.sig`, ...). It does not look at
GitHub's separate attestations store, so although the attestations job has signed
the published bytes since v0.4.0, the v0.4.0 release assets carried no provenance
file and the check stayed at 0.

Attach the Sigstore provenance bundle (already produced by
actions/attest-build-provenance) to the release as `wickra-<tag>.provenance.intoto.jsonl`:

- github-release now exposes its resolved tag as a job output.
- attestations `needs: github-release` (so the Release already exists), gains
  `contents: write`, gives the attest step an id, and uploads the bundle with
  `gh release upload --clobber` (idempotent on re-runs).

Publishes stay fully isolated — cargo/PyPI/npm all run upstream of github-release,
so a Sigstore hiccup here can never block or corrupt a publish; at worst the
release just lacks the provenance asset. Signed-Releases climbs over the next
releases as each tag carries the bundle.
2026-06-01 04:09:09 +02:00
kingchenc 99af5f8ee1 ci: retry transient registry/DNS flakes at the cargo/npm/pip tool level (#105)
The v0.4.0-era CI failure was a runner network blip — `napi build` invokes cargo,
whose fetch of index.crates.io hit "Could not resolve host: index.crates.io" and
failed the Node-on-macOS job, forcing a manual re-run. The earlier flake-hardening
(setup-node/setup-python + rust-cache retries) only covered toolchain download and
cache restore, not the registry fetches inside the actual build/publish steps.

Set tool-level network retries as workflow env so every cargo/napi/maturin/
wasm-pack/npm/pip invocation in every job inherits them — including the nested
cargo calls inside napi/maturin/wasm-pack:

- CARGO_NET_RETRY=10 (default 3): cargo classes DNS-resolve / connect / timeout
  errors as spurious and retries with backoff; 10 attempts ride out a transient
  blip instead of failing the job.
- CARGO_NET_GIT_FETCH_WITH_CLI=true: more robust git-dep fetches.
- npm_config_fetch_retries=5 / maxtimeout=120s: npm ci/install registry retries.
- PIP_RETRIES=5 / PIP_DEFAULT_TIMEOUT=120: pip install resilience.

Applied to ci.yml, release.yml and bench.yml (the workflows that build). No more
manual re-runs for transient registry flakes.
2026-06-01 04:08:18 +02:00
kingchenc bff1148d20 ci(sync-about): fix docs version-sync clone collision + webpage npm race (#104)
* ci(sync-about): fix docs version-sync clone collision + webpage npm race

Two real release-time bugs surfaced by the v0.4.0 release, where the docs
"Published versions" table never updated and the marketing-site Cloudflare
build failed:

1. docs version sync never ran. The "Sync docs version (wickra-docs)" step
   cloned into a directory literally named `docs`, but on a tag push the job
   checks out the wickra repo at the workspace root, which already contains a
   top-level `docs/` directory. `git clone … docs` therefore failed with
   "destination path 'docs' already exists", silenced by `2>/dev/null` and
   misreported as a missing-token warning, so the docs version table stayed at
   the previous release. Clone into `docs-ver` instead (mirrors the `docs-count`
   dir the count step already uses); it collides with nothing in the repo.

2. webpage build broke on a version race. The "Sync webpage version" step bumps
   package.json's `wickra-wasm` pin to the released version and pushes
   immediately, but release.yml publishes wickra-wasm to npm in parallel on the
   same tag and finishes minutes later. Cloudflare's `npm clean-install` then
   hit `ETARGET: No matching version found for wickra-wasm@^0.4.0`. Poll npm for
   wickra-wasm@<version> (up to ~15 min) before committing; if it never appears
   the step skips with a warning rather than pushing a build-breaking commit.

Both steps were designed to mirror each other across docs/webpage; these fixes
restore that symmetry so every release self-heals both sites.

* ci(sync-about): regenerate webpage package-lock on version bump

Third v0.4.0 release-sync defect: the webpage version step seds package.json's
wickra-wasm pin but never touched package-lock.json, so even after wickra-wasm
went live on npm the Cloudflare build still failed with
`npm ci` EUSAGE: "lock file's wickra-wasm@0.3.1 does not satisfy
wickra-wasm@0.4.0".

After the package.json sed, run `npm install --package-lock-only` so the lockfile
(version + resolved + integrity) matches the new pin; commit package-lock.json
alongside package.json. The earlier npm-wait already guarantees the version is
resolvable. Guarded: if the regen fails the step skips the whole commit rather
than push a package.json/lock mismatch.

The live site was unblocked out-of-band by a matching lockfile commit on the
webpage repo; this makes it self-heal on every future release.
2026-06-01 02:02:19 +02:00
kingchenc ebddc5e376 ci: set least-privilege top-level token permissions (P21.1b) (#103)
The auto-injected GITHUB_TOKEN defaulted to write-all in ci.yml, bench.yml
and release.yml (no top-level permissions block), and codeql.yml declared
its scopes only at job level. Add a top-level `permissions: contents: read`
to all four so the token starts read-only and only the jobs that genuinely
write through it raise the scope:

- release.yml: github-release keeps contents: write; node-/wasm-publish and
  attestations keep their id-token / attestations: write blocks. The
  cargo/python/node publish jobs push to crates.io/PyPI/npm via their own
  registry secrets, not the GITHUB_TOKEN, so read-only is correct for them.
- codeql.yml: analyze keeps security-events: write (job level).
- ci.yml / bench.yml: no job writes back to the repo (coverage uploads via
  CODECOV_TOKEN; bench only uploads an artifact), so no job override is needed.

sync-about.yml already had a top-level block but at contents: write; demote
the top level to read and move contents: write down to the single `sync` job
(the PR-head counter push is the only GITHUB_TOKEN write). The cross-repo
About/docs/webpage/org writes are unaffected — they run through the
fine-grained ABOUT_SYNC_TOKEN, which the permissions key does not govern.

Raises OpenSSF Scorecard Token-Permissions from 0 toward 10.
2026-06-01 02:01:31 +02:00
37 changed files with 3336 additions and 146 deletions
+18
View File
@@ -22,8 +22,26 @@ on:
required: false
default: "10"
# Least-privilege default for the auto-injected GITHUB_TOKEN. The single job
# only builds and uploads an artifact (upload-artifact uses the artifact
# storage API, not the contents scope), so it never needs repo write (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / PyPI inside any build step (cargo,
# maturin, pip) retries automatically instead of failing the job. Cargo treats
# "couldn't resolve host" / connect / timeout as spurious and retries with
# backoff; 10 attempts ride out a transient DNS blip on a runner.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
cross-library-bench:
+20
View File
@@ -6,9 +6,29 @@ on:
pull_request:
branches: [main]
# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
# napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
# failing the job and needing a manual re-run. Cargo treats "couldn't resolve
# host" / connect / timeout as spurious and retries with backoff; 10 attempts
# ride out a transient DNS blip on a runner. Complements the setup-action /
# cache retries (which only covered toolchain download + cache restore).
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
rust:
+7
View File
@@ -12,6 +12,13 @@ on:
schedule:
- cron: '31 3 * * 0' # Sundays 03:31 UTC
# Least-privilege default for the auto-injected GITHUB_TOKEN. The analyze job
# raises exactly the scopes CodeQL needs (security-events: write to upload
# results) in its own job-level block below; this top-level read-only default
# covers any future job (OpenSSF Scorecard: Token-Permissions).
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
+114 -9
View File
@@ -5,8 +5,30 @@ on:
tags: ["v*"]
workflow_dispatch:
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
# (cargo/python/node) push to external registries via their own secrets
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
# they need no repo write. The jobs that genuinely write through the
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
# attestations (id-token / attestations: write) — declare those rights in their
# own job-level permissions blocks, which override this default (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm inside any build or publish step
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
# --------------------------------------------------------------------------
@@ -527,13 +549,24 @@ jobs:
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
#
# The release is created as a DRAFT here and only flipped to published by the
# downstream publish-release job, after the provenance bundle is attached. That
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
# with GitHub release immutability, which locks assets at publish time (P24):
# the old "publish, then upload provenance" order would have the provenance
# upload rejected once immutability is enabled.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the GitHub Release
name: Attach assets to the draft GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Expose the resolved tag so the attestations job can attach the provenance
# bundle to this same release without re-resolving it.
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -578,7 +611,7 @@ jobs:
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
- name: Create / update GitHub Release with assets
- name: Create / update the draft GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
@@ -586,6 +619,9 @@ jobs:
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
# Created as a draft; publish-release flips it to published + latest once
# the provenance bundle is attached (P24, immutability-ready).
draft: true
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
@@ -618,18 +654,26 @@ jobs:
# --------------------------------------------------------------------------
attestations:
name: Attest build provenance
needs: [cargo-publish, python-wheels, python-sdist]
needs: [cargo-publish, python-wheels, python-sdist, github-release]
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).
# from `npm publish --provenance`, so they are covered there.
#
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
# of github-release, so a Sigstore hiccup here can never block or corrupt a
# publish (the isolation the SBOM step lacked before #79). It additionally
# `needs: github-release` so the (still-draft) GitHub Release already exists
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
# not GitHub's separate attestations store, so the bundle has to live on the
# release. The release is published afterwards by the publish-release job
# whether or not this attestation succeeds (P24), so a failure here still only
# costs the provenance asset, never the release.
permissions:
id-token: write # OIDC for keyless Sigstore signing
attestations: write # write the attestations to this repo
contents: read
contents: write # upload the provenance bundle as a release asset
steps:
- name: Download crate files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -643,9 +687,70 @@ jobs:
path: artifacts/python
merge-multiple: true
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/crates/*.crate
artifacts/python/*.whl
artifacts/python/*.tar.gz
artifacts/python/*.tar.gz
# Attach the Sigstore provenance bundle to the GitHub Release as a
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
# signed provenance on the release itself (P21.1e). attest-build-provenance
# writes a single JSONL bundle covering every subject above; copy it to a
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
# idempotent. github.token has contents: write here, which is all gh needs.
- name: Attach provenance bundle to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot attach provenance."
exit 1
fi
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
echo "::error::attestation bundle not found at '$BUNDLE'."
exit 1
fi
dest="wickra-${TAG}.provenance.intoto.jsonl"
cp "$BUNDLE" "$dest"
echo "Uploading $dest to release $TAG"
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
# --------------------------------------------------------------------------
# Publish the drafted release LAST (P24 — immutability-ready).
#
# github-release creates the release as a draft and attestations attaches the
# provenance bundle to it; only now, with every asset in place, is it flipped to
# published + latest. With GitHub release immutability enabled, assets lock at
# this publish step — so the provenance bundle and every build artefact are
# already present and never need a (rejected) post-publish upload.
#
# `if: always() && needs.github-release.result == 'success'` preserves the old
# robustness: the release is published whenever the draft was created, even if
# the attestations job hit a Sigstore hiccup — that only costs the provenance
# asset, exactly as before. If github-release was skipped (a publish job failed)
# there is no draft, so this is skipped too and no release is published.
# --------------------------------------------------------------------------
publish-release:
name: Publish the GitHub Release
needs: [github-release, attestations]
if: always() && needs.github-release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # flip the draft release to published
steps:
- name: Flip the draft release to published (latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot publish."
exit 1
fi
echo "::notice::publishing release $TAG (draft -> published, latest)"
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
+95 -10
View File
@@ -20,10 +20,15 @@ name: Sync indicator count
# — 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*
# 9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
# collapsed to a single page that points at docs.wickra.org but still names
# the count) — synced on push to main / v* tag*
#
# *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.
# need write on wickra-lib/.github and admin:org for the org-description PATCH;
# surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
# repo's permission).
# 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.
@@ -55,18 +60,24 @@ on:
types: [opened, synchronize, reopened]
workflow_dispatch:
# `contents: write` is needed so the workflow can push the counter
# fix-up commit to the PR head branch via the auto-provided
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
# write` the workflow needs — to push the counter fix-up commit to the PR head
# branch — is raised at the job level below, not here, so the top-level default
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
# docs / webpage / org writes still go through the fine-grained PAT
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
permissions:
contents: write
contents: read
pull-requests: read
jobs:
sync:
runs-on: ubuntu-latest
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
permissions:
contents: write
pull-requests: read
steps:
# On PRs from forks the head ref lives in another repo; pushing
# back to it from this workflow is blocked by GitHub. We still
@@ -242,6 +253,40 @@ jobs:
echo "Docs indicator count synced to ${n}."
fi
# The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
# Home.md pointer page that sends visitors to docs.wickra.org, but that
# page still names the count ("… for all N indicators"), so keep it in
# sync here too. Mirrors the docs/webpage count steps: own clone dir
# (wiki-count) and the same soft-skip contract. Wiki write rides on the
# parent repo's permission, so the PAT needs write on wickra-lib/wickra;
# the wiki has no signing gate, so a plain wickra-bot commit is fine.
- name: Sync wiki pointer indicator count (wickra.wiki)
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/wickra.wiki.git" wiki-count 2>/dev/null; then
echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
exit 0
fi
cd wiki-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
if git diff --quiet; then
echo "Wiki pointer 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
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
else
echo "Wiki pointer 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
@@ -325,11 +370,19 @@ jobs:
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
# Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
# the wickra repo at the workspace root, which already contains a
# top-level `docs/` directory, so `git clone … docs` fails with
# "destination path 'docs' already exists" — silently, because of the
# 2>/dev/null below — and the version sync never runs (this is exactly
# why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
# `docs-count` dir used by the count step above and collides with
# nothing in the repo.
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 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
cd docs-ver
# Published-versions table rows (crates.io / PyPI / npm): replace only the
# version number, leaving the trailing padding + pipe intact. The '.' in
# the quickstart pattern matches the literal backtick around the version
@@ -397,6 +450,26 @@ jobs:
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
exit 0
fi
# The webpage pins wickra-wasm to the released version in package.json,
# and its Cloudflare Pages build runs `npm clean-install`. release.yml
# publishes wickra-wasm to npm in parallel on this same tag and finishes
# minutes later, so committing the bump immediately would point the site
# at a version npm cannot resolve yet (ETARGET) and break the build —
# exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
# actually live on npm before committing; if it never appears (the wasm
# publish failed), skip rather than push a build-breaking commit.
echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
attempts=0
until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
exit 0
fi
echo " not on npm yet (attempt ${attempts}/30); waiting 30s..."
sleep 30
done
echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
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
@@ -409,13 +482,25 @@ jobs:
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
# Keep package-lock.json in sync with the package.json bump. The site's
# Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
# with EUSAGE if the lockfile still pins the previous wickra-wasm —
# editing package.json alone is not enough. The npm-wait above already
# proved wickra-wasm@$version is resolvable, so --package-lock-only
# regenerates the lock (version + resolved + integrity) without fetching
# node_modules. Guard it: if the regen fails, skip the whole commit so we
# never push a package.json/lock mismatch that would break the build.
if ! npm install --package-lock-only --no-audit --no-fund; then
echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
exit 0
fi
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 add api/*.md .vitepress/config.ts package.json package-lock.json
git commit -m "chore: sync published version to ${version}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
+33 -1
View File
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.1] - 2026-06-01
### Added
- **Cross-asset pairwise indicators.** A new two-series family of
`Indicator<Input = (f64, f64)>` implementations that relate two distinct
assets rather than a single OHLCV stream. Each is exposed in Rust, Python,
Node, and WASM:
- **Pairwise Beta** (`PairwiseBeta`) — rolling OLS slope of one asset's
**log-returns** on another's. Unlike `Beta`, which regresses the raw inputs
it is fed, `PairwiseBeta` differences consecutive prices into log-returns
internally — the conventional way to measure cross-asset beta, where a beta
on price levels would be dominated by the shared trend.
- **Pair Spread Z-Score** (`PairSpreadZScore`) — the standardised log-spread
`ln(a) β·ln(b)` of a pair, where `β` is a rolling-OLS hedge ratio and the
spread is z-scored over its own look-back. The canonical mean-reversion /
statistical-arbitrage entry signal, with independent `beta_period` and
`z_period` windows.
- **LeadLag Cross-Correlation** (`LeadLagCrossCorrelation`) — the integer
offset `k ∈ [max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`,
answering which of two assets leads the other and by how many bars. Emits
`{ lag, correlation }`; a positive lag means `a` leads `b`.
- **Cointegration** (`Cointegration`) — the EngleGranger two-step screen for
pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
`a (α + β·b)`, and an augmented DickeyFuller `t`-statistic on the spread
(configurable `adf_lags`). A strongly negative statistic flags a
mean-reverting, tradeable spread. Emits `{ hedge_ratio, spread, adf_stat }`.
- **Relative Strength A-vs-B** (`RelativeStrengthAB`) — the comparative
relative strength of two assets: the ratio line `a / b` together with its
moving average and its RSI, the classic asset-vs-asset / asset-vs-index
rotation screen. Emits `{ ratio, ratio_ma, ratio_rsi }`.
## [0.4.0] - 2026-06-01
### Added
@@ -869,7 +900,8 @@ 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.4.0...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/wickra-lib/wickra/compare/v0.4.0...v0.4.1
[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
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.0"
version = "0.4.1"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -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.4.0" }
wickra-core = { path = "crates/wickra-core", version = "0.4.1" }
thiserror = "2"
rayon = "1.10"
+25 -4
View File
@@ -12,6 +12,7 @@
[![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)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
@@ -37,6 +38,25 @@ for price in live_feed:
print("overbought")
```
## Documentation
Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 219 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [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), 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).
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
@@ -115,9 +135,10 @@ python -m benchmarks.compare_libraries
## Indicators
214 streaming-first indicators across sixteen families. Every one passes the
219 streaming-first indicators across sixteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
@@ -129,7 +150,7 @@ semantics tests.
| 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, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
| 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, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, 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 |
@@ -209,7 +230,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 214 indicators
│ ├── wickra-core/ core engine + all 219 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -461,6 +461,8 @@ test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
const pairFactories = {
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
Beta: () => new wickra.Beta(14),
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
};
@@ -492,6 +494,85 @@ test('Beta perfect two-to-one', () => {
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('PairwiseBeta squared price is two', () => {
// b needs varying returns; a = b² ⇒ a's log-returns are exactly 2× b's.
const bench = Array.from({ length: 20 }, (_, i) => 100 + 10 * Math.sin(i * 0.5));
const asset = bench.map((v) => v * v);
const out = new wickra.PairwiseBeta(5).batch(asset, bench);
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
});
test('PairSpreadZScore flat benchmark is sign of last move', () => {
// Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of move.
const a = [100, 100, 110, 105, 130];
const b = [100, 100, 100, 100, 100];
const out = new wickra.PairSpreadZScore(2, 2).batch(a, b);
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
assert.ok(Math.abs(out[out.length - 2] + 1) < 1e-9);
});
const llSignal = (t) =>
Math.sin(t * 0.4) + 0.4 * Math.sin(t * 1.1) + 0.2 * Math.cos(t * 0.27);
test('LeadLagCrossCorrelation detects positive lead (object output)', () => {
const ll = new wickra.LeadLagCrossCorrelation(12, 5);
let last = null;
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3.
for (let t = 0; t < 60; t++) last = ll.update(llSignal(t), llSignal(t - 3));
assert.equal(last.lag, 3);
assert.ok(last.correlation > 0.99);
});
test('LeadLagCrossCorrelation batch is flat 2*n with last row matching', () => {
const n = 60;
const a = Array.from({ length: n }, (_, t) => llSignal(t));
const b = Array.from({ length: n }, (_, t) => llSignal(t - 3));
const out = new wickra.LeadLagCrossCorrelation(12, 5).batch(a, b);
assert.equal(out.length, 2 * n);
assert.equal(out[2 * (n - 1)], 3);
assert.ok(out[2 * (n - 1) + 1] > 0.99);
});
test('Cointegration detects mean-reverting pair (object output)', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const co = new wickra.Cointegration(40, 1);
let last = null;
for (let i = 0; i < n; i++) last = co.update(a[i], b[i]);
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.1);
assert.ok(last.adfStat < -2);
});
test('Cointegration batch is flat 3*n with last row matching', () => {
const n = 80;
const b = Array.from({ length: n }, (_, t) => 50 + 0.5 * t);
const a = b.map((v, t) => 2 * v + 1 + 0.5 * Math.sin(t * 0.6));
const out = new wickra.Cointegration(40, 1).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.1);
assert.ok(out[3 * (n - 1) + 2] < -2);
});
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
const rs = new wickra.RelativeStrengthAB(5, 5);
let last = null;
for (let i = 0; i < 30; i++) last = rs.update(200, 100); // ratio is a constant 2
assert.ok(Math.abs(last.ratio - 2) < 1e-12);
assert.ok(Math.abs(last.ratioMa - 2) < 1e-12);
assert.ok(Math.abs(last.ratioRsi - 50) < 1e-9);
});
test('RelativeStrengthAB batch is flat 3*n with last row matching', () => {
const n = 30;
const a = Array.from({ length: n }, () => 200);
const b = Array.from({ length: n }, () => 100);
const out = new wickra.RelativeStrengthAB(5, 5).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 1e-12);
assert.ok(Math.abs(out[3 * (n - 1) + 2] - 50) < 1e-9);
});
test('SpearmanCorrelation monotone non-linear is 1', () => {
const x = Array.from({ length: 10 }, (_, i) => i + 1);
const y = x.map((v) => v ** 3);
+100
View File
@@ -5,6 +5,34 @@
/** Library version (matches the Rust crate version). */
export declare function version(): string
/** Lead/lag result: the offset that maximises correlation, and that correlation. */
export interface LeadLagValue {
/** Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`. */
lag: number
/** Signed correlation at that lag, in `[-1, 1]`. */
correlation: number
}
/** Cointegration result: hedge ratio, current spread, and the ADF statistic. */
export interface CointegrationValue {
/** EngleGranger hedge ratio (OLS slope of `a` on `b`). */
hedgeRatio: number
/** Current spread (regression residual) `a - (alpha + beta*b)`. */
spread: number
/**
* Augmented DickeyFuller statistic on the spread; more negative more
* strongly mean-reverting.
*/
adfStat: number
}
/** Relative-strength triple: the a/b ratio, its moving average, and its RSI. */
export interface RelativeStrengthValue {
/** Raw ratio `a / b`. */
ratio: number
/** Moving average of the ratio. */
ratioMa: number
/** RSI of the ratio. */
ratioRsi: number
}
/** MACD triple: macd line, signal line, histogram. */
export interface MacdValue {
macd: number
@@ -628,6 +656,19 @@ export declare class Beta {
isReady(): boolean
warmupPeriod(): number
}
export type PairwiseBetaNode = PairwiseBeta
export declare class PairwiseBeta {
constructor(period: number)
update(x: number, y: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array
* with `NaN` for warmup positions.
*/
batch(x: Array<number>, y: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SpearmanCorrelationNode = SpearmanCorrelation
export declare class SpearmanCorrelation {
constructor(period: number)
@@ -641,6 +682,65 @@ export declare class SpearmanCorrelation {
isReady(): boolean
warmupPeriod(): number
}
export type PairSpreadZScoreNode = PairSpreadZScore
/**
* Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
* price pair per update, a single z-score out.
*/
export declare class PairSpreadZScore {
constructor(betaPeriod: number, zPeriod: number)
update(a: number, b: number): number | null
/**
* Batch over two equally-sized arrays of prices. Returns a length-`n`
* array with `NaN` for warmup positions.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type LeadLagCrossCorrelationNode = LeadLagCrossCorrelation
export declare class LeadLagCrossCorrelation {
constructor(window: number, maxLag: number)
update(a: number, b: number): LeadLagValue | null
/**
* Batch over two equally-sized arrays. Returns a flat array of length
* `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
* column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CointegrationNode = Cointegration
export declare class Cointegration {
constructor(period: number, adfLags: number)
update(a: number, b: number): CointegrationValue | null
/**
* Batch over two equally-sized arrays. Returns a flat array of length
* `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
* Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RelativeStrengthAbNode = RelativeStrengthAB
export declare class RelativeStrengthAB {
constructor(maPeriod: number, rsiPeriod: number)
update(a: number, b: number): RelativeStrengthValue | null
/**
* Batch over two equally-sized arrays. Returns a flat array of length
* `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
* Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MacdNode = MACD
export declare class MACD {
constructor(fast: number, slow: number, signal: number)
+54 -50
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.ZScore = ZScore
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.Decycler = Decycler
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.RVIVolatility = RVIVolatility
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
module.exports.RollingVWAP = RollingVWAP
module.exports.AwesomeOscillator = AwesomeOscillator
module.exports.Aroon = Aroon
module.exports.KAMA = KAMA
module.exports.RVI = RVI
module.exports.PGO = PGO
module.exports.KST = KST
module.exports.SMI = SMI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.ConnorsRSI = ConnorsRSI
module.exports.Inertia = Inertia
module.exports.ALMA = ALMA
module.exports.McGinleyDynamic = McGinleyDynamic
module.exports.FRAMA = FRAMA
module.exports.VIDYA = VIDYA
module.exports.JMA = JMA
module.exports.Alligator = Alligator
module.exports.EVWMA = EVWMA
module.exports.APO = APO
module.exports.ConnorsRSI = ConnorsRSI
module.exports.LaguerreRSI = LaguerreRSI
module.exports.SMI = SMI
module.exports.KST = KST
module.exports.PGO = PGO
module.exports.RVI = RVI
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
module.exports.CFO = CFO
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.ElderImpulse = ElderImpulse
module.exports.STC = STC
module.exports.ElderImpulse = ElderImpulse
module.exports.ZeroLagMACD = ZeroLagMACD
module.exports.CFO = CFO
module.exports.APO = APO
module.exports.KAMA = KAMA
module.exports.EVWMA = EVWMA
module.exports.Alligator = Alligator
module.exports.JMA = JMA
module.exports.VIDYA = VIDYA
module.exports.ALMA = ALMA
module.exports.T3 = T3
module.exports.TSI = TSI
module.exports.PMO = PMO
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.KVO = KVO
module.exports.VolumeOscillator = VolumeOscillator
module.exports.NVI = NVI
module.exports.PVI = PVI
module.exports.VolumeOscillator = VolumeOscillator
module.exports.KVO = KVO
module.exports.WilliamsAD = WilliamsAD
module.exports.AnchoredVWAP = AnchoredVWAP
module.exports.DemandIndex = DemandIndex
module.exports.TSV = TSV
module.exports.VZO = VZO
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.SuperTrend = SuperTrend
module.exports.ChandelierExit = ChandelierExit
module.exports.ChandeKrollStop = ChandeKrollStop
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.TrueRange = TrueRange
module.exports.ChaikinVolatility = ChaikinVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.LinRegAngle = LinRegAngle
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.AroonOscillator = AroonOscillator
module.exports.Vortex = Vortex
module.exports.RWI = RWI
module.exports.WaveTrend = WaveTrend
module.exports.RWI = RWI
module.exports.Vortex = Vortex
module.exports.MassIndex = MassIndex
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.Coppock = Coppock
module.exports.VWMA = VWMA
module.exports.RVIVolatility = RVIVolatility
module.exports.ParkinsonVolatility = ParkinsonVolatility
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
module.exports.YangZhangVolatility = YangZhangVolatility
module.exports.MaEnvelope = MaEnvelope
module.exports.AccelerationBands = AccelerationBands
module.exports.StarcBands = StarcBands
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
module.exports.TDDifferential = TDDifferential
module.exports.TDOpen = TDOpen
module.exports.TDRiskLevel = TDRiskLevel
module.exports.SuperSmoother = SuperSmoother
module.exports.FisherTransform = FisherTransform
module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.Decycler = Decycler
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.CenterOfGravity = CenterOfGravity
module.exports.CyberneticCycle = CyberneticCycle
module.exports.InstantaneousTrendline = InstantaneousTrendline
module.exports.EhlersStochastic = EhlersStochastic
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
@@ -479,19 +497,6 @@ module.exports.MAMA = MAMA
module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.Variance = Variance
module.exports.CoefficientOfVariation = CoefficientOfVariation
module.exports.Skewness = Skewness
module.exports.Kurtosis = Kurtosis
module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.ValueArea = ValueArea
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
@@ -510,7 +515,6 @@ module.exports.Tweezer = Tweezer
module.exports.SpinningTop = SpinningTop
module.exports.ThreeInside = ThreeInside
module.exports.ThreeOutside = ThreeOutside
// Family 15: Risk / Performance metrics
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"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.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.0",
"version": "0.4.1",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"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"
"wickra-darwin-arm64": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-linux-x64-gnu": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.1.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.1.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.1.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.1.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.1.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.0.tgz",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.1.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.4.0",
"version": "0.4.1",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.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"
"wickra-linux-x64-gnu": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-darwin-arm64": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1"
},
"scripts": {
"build": "napi build --platform --release",
+259
View File
@@ -306,12 +306,271 @@ node_pair_indicator!(
wc::PearsonCorrelation
);
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
node_pair_indicator!(
SpearmanCorrelationNode,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
// ============================== PairSpreadZScore ==============================
/// Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
/// price pair per update, a single z-score out.
#[napi(js_name = "PairSpreadZScore")]
pub struct PairSpreadZScoreNode {
inner: wc::PairSpreadZScore,
}
#[napi]
impl PairSpreadZScoreNode {
#[napi(constructor)]
pub fn new(beta_period: u32, z_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period as usize, z_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns a length-`n`
/// array with `NaN` for warmup positions.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== LeadLagCrossCorrelation ==============================
/// Lead/lag result: the offset that maximises correlation, and that correlation.
#[napi(object)]
pub struct LeadLagValue {
/// Offset that maximises `|corr(a, b shifted)|`. Positive ⇒ `a` leads `b`.
pub lag: i32,
/// Signed correlation at that lag, in `[-1, 1]`.
pub correlation: f64,
}
#[napi(js_name = "LeadLagCrossCorrelation")]
pub struct LeadLagCrossCorrelationNode {
inner: wc::LeadLagCrossCorrelation,
}
#[napi]
impl LeadLagCrossCorrelationNode {
#[napi(constructor)]
pub fn new(window: u32, max_lag: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window as usize, max_lag as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<LeadLagValue> {
self.inner.update((a, b)).map(|o| LeadLagValue {
lag: o.lag as i32,
correlation: o.correlation,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `2 * n`, interleaved per row as `[lag0, corr0, lag1, corr1, ...]`. Read
/// column `j` of row `i` as `result[i * 2 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 2];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Cointegration ==============================
/// Cointegration result: hedge ratio, current spread, and the ADF statistic.
#[napi(object)]
pub struct CointegrationValue {
/// EngleGranger hedge ratio (OLS slope of `a` on `b`).
pub hedge_ratio: f64,
/// Current spread (regression residual) `a - (alpha + beta*b)`.
pub spread: f64,
/// Augmented DickeyFuller statistic on the spread; more negative ⇒ more
/// strongly mean-reverting.
pub adf_stat: f64,
}
#[napi(js_name = "Cointegration")]
pub struct CointegrationNode {
inner: wc::Cointegration,
}
#[napi]
impl CointegrationNode {
#[napi(constructor)]
pub fn new(period: u32, adf_lags: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Cointegration::new(period as usize, adf_lags as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<CointegrationValue> {
self.inner.update((a, b)).map(|o| CointegrationValue {
hedge_ratio: o.hedge_ratio,
spread: o.spread,
adf_stat: o.adf_stat,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `3 * n`, interleaved per row as `[hedgeRatio0, spread0, adfStat0, ...]`.
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 3];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== RelativeStrengthAB ==============================
/// Relative-strength triple: the a/b ratio, its moving average, and its RSI.
#[napi(object)]
pub struct RelativeStrengthValue {
/// Raw ratio `a / b`.
pub ratio: f64,
/// Moving average of the ratio.
pub ratio_ma: f64,
/// RSI of the ratio.
pub ratio_rsi: f64,
}
#[napi(js_name = "RelativeStrengthAB")]
pub struct RelativeStrengthAbNode {
inner: wc::RelativeStrengthAB,
}
#[napi]
impl RelativeStrengthAbNode {
#[napi(constructor)]
pub fn new(ma_period: u32, rsi_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period as usize, rsi_period as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<RelativeStrengthValue> {
self.inner.update((a, b)).map(|o| RelativeStrengthValue {
ratio: o.ratio,
ratio_ma: o.ratio_ma,
ratio_rsi: o.ratio_rsi,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `3 * n`, interleaved per row as `[ratio0, ratioMa0, ratioRsi0, ...]`.
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 3];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== MACD ==============================
/// MACD triple: macd line, signal line, histogram.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.0"
version = "0.4.1"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0" }
+10
View File
@@ -161,6 +161,11 @@ from ._wickra import (
HurstExponent,
PearsonCorrelation,
Beta,
PairwiseBeta,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
RelativeStrengthAB,
SpearmanCorrelation,
# Ehlers / Cycle
SuperSmoother,
@@ -393,6 +398,11 @@ __all__ = [
"HurstExponent",
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
"RelativeStrengthAB",
"SpearmanCorrelation",
# Ehlers / Cycle
"SuperSmoother",
+382
View File
@@ -10751,6 +10751,383 @@ impl PyBeta {
}
}
// ============================== PairwiseBeta ==============================
#[pyclass(name = "PairwiseBeta", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyPairwiseBeta {
inner: wc::PairwiseBeta,
}
#[pymethods]
impl PyPairwiseBeta {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairwiseBeta::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("PairwiseBeta(period={})", self.inner.period())
}
}
// ============================== PairSpreadZScore ==============================
#[pyclass(
name = "PairSpreadZScore",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[pymethods]
impl PyPairSpreadZScore {
#[new]
#[pyo3(signature = (beta_period=20, z_period=20))]
fn new(beta_period: usize, z_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays of prices: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn beta_period(&self) -> usize {
self.inner.beta_period()
}
#[getter]
fn z_period(&self) -> usize {
self.inner.z_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"PairSpreadZScore(beta_period={}, z_period={})",
self.inner.beta_period(),
self.inner.z_period()
)
}
}
// ============================== LeadLagCrossCorrelation ==============================
#[pyclass(
name = "LeadLagCrossCorrelation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[pymethods]
impl PyLeadLagCrossCorrelation {
#[new]
#[pyo3(signature = (window=20, max_lag=10))]
fn new(window: usize, max_lag: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `(lag, correlation)` or `None` during warmup. A positive lag
/// means `a` leads `b`.
fn update(&mut self, a: f64, b: f64) -> Option<(i64, f64)> {
self.inner.update((a, b)).map(|o| (o.lag, o.correlation))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 2)` with columns `[lag, correlation]`. Warmup rows are NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn window(&self) -> usize {
self.inner.window()
}
#[getter]
fn max_lag(&self) -> usize {
self.inner.max_lag()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"LeadLagCrossCorrelation(window={}, max_lag={})",
self.inner.window(),
self.inner.max_lag()
)
}
}
// ============================== Cointegration ==============================
#[pyclass(name = "Cointegration", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyCointegration {
inner: wc::Cointegration,
}
#[pymethods]
impl PyCointegration {
#[new]
#[pyo3(signature = (period=30, adf_lags=1))]
fn new(period: usize, adf_lags: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `(hedge_ratio, spread, adf_stat)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.hedge_ratio, o.spread, o.adf_stat))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[hedge_ratio, spread, adf_stat]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn adf_lags(&self) -> usize {
self.inner.adf_lags()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"Cointegration(period={}, adf_lags={})",
self.inner.period(),
self.inner.adf_lags()
)
}
}
// ============================== RelativeStrengthAB ==============================
#[pyclass(
name = "RelativeStrengthAB",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRelativeStrengthAB {
inner: wc::RelativeStrengthAB,
}
#[pymethods]
impl PyRelativeStrengthAB {
#[new]
#[pyo3(signature = (ma_period=20, rsi_period=14))]
fn new(ma_period: usize, rsi_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `(ratio, ratio_ma, ratio_rsi)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.ratio, o.ratio_ma, o.ratio_rsi))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[ratio, ratio_ma, ratio_rsi]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn ma_period(&self) -> usize {
self.inner.ma_period()
}
#[getter]
fn rsi_period(&self) -> usize {
self.inner.rsi_period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"RelativeStrengthAB(ma_period={}, rsi_period={})",
self.inner.ma_period(),
self.inner.rsi_period()
)
}
}
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -12236,6 +12613,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHurstExponent>()?;
m.add_class::<PyPearsonCorrelation>()?;
m.add_class::<PyBeta>()?;
m.add_class::<PyPairwiseBeta>()?;
m.add_class::<PyPairSpreadZScore>()?;
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyInitialBalance>()?;
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
ta.Aroon(14).batch(high, short)
def test_pairwise_beta_rejects_bad_period():
with pytest.raises(ValueError):
ta.PairwiseBeta(0)
with pytest.raises(ValueError):
ta.PairwiseBeta(1)
def test_unequal_length_pair_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.PairwiseBeta(20).batch(a, b)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 20).batch(a, b)
def test_pair_spread_zscore_rejects_bad_periods():
with pytest.raises(ValueError):
ta.PairSpreadZScore(1, 20)
with pytest.raises(ValueError):
ta.PairSpreadZScore(20, 1)
def test_lead_lag_rejects_bad_params():
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(1, 5)
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(10, 0)
def test_lead_lag_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
def test_cointegration_rejects_too_small_period():
# period must be >= 2*adf_lags + 4.
with pytest.raises(ValueError):
ta.Cointegration(3, 0)
with pytest.raises(ValueError):
ta.Cointegration(5, 1)
def test_cointegration_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.Cointegration(20, 1).batch(a, b)
def test_relative_strength_rejects_zero_periods():
with pytest.raises(ValueError):
ta.RelativeStrengthAB(0, 14)
with pytest.raises(ValueError):
ta.RelativeStrengthAB(20, 0)
def test_relative_strength_unequal_length_batch_raises(sine_prices):
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
b = a[:-1]
with pytest.raises(ValueError):
ta.RelativeStrengthAB(10, 14).batch(a, b)
def test_roc_and_trix_have_default_periods():
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
assert ta.ROC().period == 10
@@ -429,6 +429,67 @@ def test_information_ratio_known_window():
assert math.isclose(out[-1], expected, rel_tol=1e-9)
def test_pairwise_beta_squared_price_is_two():
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
# b must have *varying* returns (a constant-return path has zero variance
# and an undefined slope, which the indicator reports as 0).
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = b**2
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
def test_pairwise_beta_inverse_price_is_minus_one():
# a = 1/b ⇒ a's log-returns are 1× b's ⇒ pairwise beta = 1.
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
a = 1.0 / b
out = ta.PairwiseBeta(5).batch(a, b)
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
def test_pair_spread_zscore_flat_benchmark_sign():
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ 1.
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
b = np.full_like(a, 100.0)
out = ta.PairSpreadZScore(2, 2).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
def test_lead_lag_cross_correlation_negative_lead():
# a is a delayed copy of b ⇒ b leads a ⇒ lag = 2, correlation ≈ 1.
def sig(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
n = 60
a = np.array([sig(t - 2) for t in range(n)])
b = np.array([sig(t) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert int(out[-1, 0]) == -2
assert out[-1, 1] > 0.99
def test_cointegration_perfect_pair():
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
b = np.array([100.0 + t for t in range(40)])
a = 2.0 * b + 5.0
out = ta.Cointegration(20, 1).batch(a, b)
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
def test_relative_strength_rising_ratio_is_overbought():
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
n = 20
a = np.array([100.0 + 2.0 * t for t in range(n)])
b = np.full(n, 100.0)
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out[-1, 0] > 1.0
assert math.isclose(out[-1, 2], 100.0, abs_tol=1e-9)
def test_value_at_risk_known_window():
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
returns = np.array([i * 0.01 for i in range(-5, 5)])
@@ -159,6 +159,8 @@ PAIR = [
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
(ta.PairwiseBeta, (20,)),
(ta.PairSpreadZScore, (20, 20)),
]
@@ -178,6 +180,95 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
def _ll_signal(t):
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
def test_lead_lag_detects_lead():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
b = np.array([_ll_signal(t - 3) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
assert out.shape == (n, 2)
assert int(out[-1, 0]) == 3
assert out[-1, 1] > 0.99
def test_lead_lag_streaming_matches_batch():
n = 60
a = np.array([_ll_signal(t) for t in range(n)])
b = np.array([_ll_signal(t - 2) for t in range(n)])
ind = ta.LeadLagCrossCorrelation(12, 5)
batch = ind.batch(a, b)
streamer = ta.LeadLagCrossCorrelation(12, 5)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert math.isnan(batch[i, 0]) and math.isnan(batch[i, 1])
else:
lag, corr = v
assert int(batch[i, 0]) == lag
assert math.isclose(batch[i, 1], corr, rel_tol=1e-12, abs_tol=1e-12)
def test_cointegration_detects_mean_reverting_pair():
n = 80
b = np.array([50.0 + 0.5 * t for t in range(n)])
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
out = ta.Cointegration(40, 1).batch(a, b)
assert out.shape == (n, 3)
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
def test_cointegration_streaming_matches_batch():
n = 70
b = np.array([30.0 + 0.7 * t for t in range(n)])
a = 1.8 * b + 2.0 + 0.5 * np.sin(np.arange(n) * 0.4)
batch = ta.Cointegration(25, 2).batch(a, b)
streamer = ta.Cointegration(25, 2)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
hr, sp, adf = v
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], sp, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
b = np.full(n, 100.0) # ratio is a constant 2
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
assert out.shape == (n, 3)
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
assert math.isclose(out[-1, 2], 50.0, abs_tol=1e-9) # flat ratio ⇒ RSI 50
def test_relative_strength_streaming_matches_batch():
n = 60
tt = np.arange(n)
a = 100.0 + 5.0 * np.sin(tt * 0.3)
b = 100.0 + 2.0 * np.cos(tt * 0.2)
batch = ta.RelativeStrengthAB(10, 14).batch(a, b)
streamer = ta.RelativeStrengthAB(10, 14)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
ratio, ma, rsi = v
assert math.isclose(batch[i, 0], ratio, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ma, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], rsi, rel_tol=1e-12, abs_tol=1e-12)
# --- Candle-input, single-output indicators -------------------------------
#
# Each entry is (factory, batch-call). Streaming always feeds the full
+217
View File
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
wc::PearsonCorrelation
);
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
wasm_pair_indicator!(
WasmSpearmanCorrelation,
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
// ---------- PairSpreadZScore (two params) ----------
#[wasm_bindgen(js_name = "PairSpreadZScore")]
pub struct WasmPairSpreadZScore {
inner: wc::PairSpreadZScore,
}
#[wasm_bindgen(js_class = "PairSpreadZScore")]
impl WasmPairSpreadZScore {
#[wasm_bindgen(constructor)]
pub fn new(beta_period: usize, z_period: usize) -> Result<WasmPairSpreadZScore, JsError> {
Ok(Self {
inner: wc::PairSpreadZScore::new(beta_period, z_period).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
/// input position (`NaN` during warmup).
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- LeadLagCrossCorrelation (two params, object output) ----------
#[wasm_bindgen(js_name = "LeadLagCrossCorrelation")]
pub struct WasmLeadLagCrossCorrelation {
inner: wc::LeadLagCrossCorrelation,
}
#[wasm_bindgen(js_class = "LeadLagCrossCorrelation")]
impl WasmLeadLagCrossCorrelation {
#[wasm_bindgen(constructor)]
pub fn new(window: usize, max_lag: usize) -> Result<WasmLeadLagCrossCorrelation, JsError> {
Ok(Self {
inner: wc::LeadLagCrossCorrelation::new(window, max_lag).map_err(map_err)?,
})
}
/// Returns `{ lag, correlation }`, or `null` during warmup. Positive lag
/// means `a` leads `b`.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"lag".into(), &(o.lag as f64).into()).ok();
Reflect::set(&obj, &"correlation".into(), &o.correlation.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `2 * n`: `[lag0, corr0, lag1, corr1, ...]`.
/// Warmup positions are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 2] = o.lag as f64;
out[i * 2 + 1] = o.correlation;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Cointegration (two params, object output) ----------
#[wasm_bindgen(js_name = "Cointegration")]
pub struct WasmCointegration {
inner: wc::Cointegration,
}
#[wasm_bindgen(js_class = "Cointegration")]
impl WasmCointegration {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, adf_lags: usize) -> Result<WasmCointegration, JsError> {
Ok(Self {
inner: wc::Cointegration::new(period, adf_lags).map_err(map_err)?,
})
}
/// Returns `{ hedgeRatio, spread, adfStat }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
Reflect::set(&obj, &"adfStat".into(), &o.adf_stat.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[hedgeRatio0, spread0, adfStat0, hedgeRatio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.spread;
out[i * 3 + 2] = o.adf_stat;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- RelativeStrengthAB (two params, object output) ----------
#[wasm_bindgen(js_name = "RelativeStrengthAB")]
pub struct WasmRelativeStrengthAb {
inner: wc::RelativeStrengthAB,
}
#[wasm_bindgen(js_class = "RelativeStrengthAB")]
impl WasmRelativeStrengthAb {
#[wasm_bindgen(constructor)]
pub fn new(ma_period: usize, rsi_period: usize) -> Result<WasmRelativeStrengthAb, JsError> {
Ok(Self {
inner: wc::RelativeStrengthAB::new(ma_period, rsi_period).map_err(map_err)?,
})
}
/// Returns `{ ratio, ratioMa, ratioRsi }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"ratio".into(), &o.ratio.into()).ok();
Reflect::set(&obj, &"ratioMa".into(), &o.ratio_ma.into()).ok();
Reflect::set(&obj, &"ratioRsi".into(), &o.ratio_rsi.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[ratio0, ratioMa0, ratioRsi0, ratio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.ratio;
out[i * 3 + 1] = o.ratio_ma;
out[i * 3 + 2] = o.ratio_rsi;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
@@ -0,0 +1,446 @@
//! Cointegration — rolling EngleGranger hedge ratio plus an ADF stationarity test.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`Cointegration`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CointegrationOutput {
/// EngleGranger hedge ratio `β`: the rolling OLS slope of `a` on `b`.
pub hedge_ratio: f64,
/// The current spread (regression residual) `a (α + β·b)`.
pub spread: f64,
/// Augmented DickeyFuller `t`-statistic on the spread. **More negative**
/// means more strongly mean-reverting (cointegrated); compare against the
/// usual ADF/MacKinnon critical values (e.g. roughly `2.9` at 5%). `0`
/// when the test is undefined (a degenerate, zero-variance spread).
pub adf_stat: f64,
}
/// Rolling cointegration test for a pair of assets (EngleGranger two-step).
///
/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you
/// prefer). Over the trailing window of `period` pairs the indicator:
///
/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least
/// squares of `a` on `b`, and forms the **spread** `eₜ = aₜ (α + β·bₜ)`;
/// 2. runs an **augmented DickeyFuller** test (no constant, no trend, with
/// `adf_lags` lagged differences) on the spread series and reports its
/// `t`-statistic.
///
/// A strongly negative ADF statistic means the spread reverts to its mean — the
/// pair is cointegrated and the spread is tradeable. A statistic near zero
/// means the spread wanders like a random walk (no cointegration). This is the
/// classic pairs-trading screen: `β` tells you the hedge size, the spread is
/// what you trade, and the ADF statistic tells you whether it is worth trading.
///
/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from
/// running sums, while the spread series and the small ADF regression are
/// recomputed over the window — both bounded by the fixed parameters, not the
/// series length.
///
/// # Example
///
/// ```
/// use wickra_core::{Cointegration, Indicator};
///
/// let mut c = Cointegration::new(30, 1).unwrap();
/// let mut last = None;
/// for t in 0..60 {
/// let b = 100.0 + f64::from(t);
/// // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated.
/// let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin();
/// last = c.update((a, b));
/// }
/// let out = last.unwrap();
/// assert!((out.hedge_ratio - 2.0).abs() < 0.1);
/// assert!(out.adf_stat < 0.0); // mean-reverting spread
/// ```
#[derive(Debug, Clone)]
pub struct Cointegration {
period: usize,
adf_lags: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Cointegration {
/// Construct a new rolling cointegration test.
///
/// `period` is the look-back window; `adf_lags` is the number of lagged
/// differences in the augmented DickeyFuller regression (`0` is the plain
/// DickeyFuller test).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is
/// the smallest window that leaves the ADF regression at least one degree
/// of freedom.
pub fn new(period: usize, adf_lags: usize) -> Result<Self> {
let min_period = 2 * adf_lags + 4;
if period < min_period {
return Err(Error::InvalidPeriod {
message: "cointegration needs period >= 2*adf_lags + 4",
});
}
Ok(Self {
period,
adf_lags,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Look-back window length.
pub const fn period(&self) -> usize {
self.period
}
/// Number of lagged differences in the ADF regression.
pub const fn adf_lags(&self) -> usize {
self.adf_lags
}
}
impl Indicator for Cointegration {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = CointegrationOutput;
fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
let (a, b) = input;
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let (hedge_ratio, intercept) = if var_b == 0.0 {
// A flat `b` window has no defined slope; fall back to a level shift.
(0.0, mean_a)
} else {
let cov = self.sum_ab / n - mean_a * mean_b;
let beta = cov / var_b;
(beta, mean_a - beta * mean_b)
};
// Build the spread (residual) series over the window, oldest → newest.
let spreads: Vec<f64> = self
.window
.iter()
.map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi))
.collect();
let spread = *spreads.last().expect("window is full");
let adf_stat = adf_no_constant(&spreads, self.adf_lags);
Some(CointegrationOutput {
hedge_ratio,
spread,
adf_stat,
})
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Cointegration"
}
}
/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian
/// elimination, returning `None` if the matrix is (numerically) singular.
///
/// `mat` is row-major and consumed; `rhs` is the right-hand side.
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
let dim = rhs.len();
for col in 0..dim {
let pivot = mat[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let pivot_row = mat[col].clone();
for row in (col + 1)..dim {
let factor = mat[row][col] / pivot;
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
*cell -= factor * above;
}
rhs[row] -= factor * rhs[col];
}
}
let mut sol = vec![0.0; dim];
for row in (0..dim).rev() {
let known: f64 = mat[row]
.iter()
.zip(&sol)
.skip(row + 1)
.map(|(coeff, value)| coeff * value)
.sum();
sol[row] = (rhs[row] - known) / mat[row][row];
}
Some(sol)
}
/// Augmented DickeyFuller `t`-statistic on `series`, with `lags` lagged
/// differences and **no** constant or trend term (the EngleGranger residual
/// form). Returns `0.0` when the regression is degenerate.
///
/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic
/// is `ρ̂ / se(ρ̂)`.
fn adf_no_constant(series: &[f64], lags: usize) -> f64 {
let len = series.len();
let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences
let first = lags + 1; // first usable observation index
if len <= first {
return 0.0;
}
let num_obs = len - first;
if num_obs <= num_reg {
return 0.0; // need at least one residual degree of freedom
}
let regressors = |idx: usize| -> Vec<f64> {
let mut row = vec![0.0; num_reg];
row[0] = series[idx - 1];
for lag in 1..=lags {
row[lag] = series[idx - lag] - series[idx - lag - 1];
}
row
};
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
let mut xty = vec![0.0; num_reg];
for idx in first..len {
let diff = series[idx] - series[idx - 1];
let row = regressors(idx);
for (ri, &left) in row.iter().enumerate() {
xty[ri] += left * diff;
for (ci, &right) in row.iter().enumerate() {
xtx[ri][ci] += left * right;
}
}
}
let Some(theta) = solve(xtx.clone(), xty) else {
return 0.0;
};
let rho = theta[0];
let mut rss = 0.0;
for idx in first..len {
let diff = series[idx] - series[idx - 1];
let pred: f64 = regressors(idx)
.iter()
.zip(&theta)
.map(|(coeff, value)| coeff * value)
.sum();
let resid = diff - pred;
rss += resid * resid;
}
let dof = (num_obs - num_reg) as f64;
let sigma2 = rss / dof;
// (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first
// solve already factored successfully, so this one cannot be singular.
let mut unit = vec![0.0; num_reg];
unit[0] = 1.0;
let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded");
let var_rho = sigma2 * inverse[0];
if var_rho <= 0.0 {
return 0.0;
}
rho / var_rho.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_too_small_period() {
// period must be >= 2*lags + 4.
assert!(Cointegration::new(3, 0).is_err()); // needs >= 4
assert!(Cointegration::new(4, 0).is_ok());
assert!(Cointegration::new(5, 1).is_err()); // needs >= 6
assert!(Cointegration::new(6, 1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let c = Cointegration::new(30, 2).unwrap();
assert_eq!(c.period(), 30);
assert_eq!(c.adf_lags(), 2);
assert_eq!(c.warmup_period(), 30);
assert_eq!(c.name(), "Cointegration");
}
#[test]
fn adf_guards_and_degenerate_spread() {
// Series too short for any observation ⇒ 0.
assert_eq!(adf_no_constant(&[1.0], 1), 0.0);
// Long enough but too few degrees of freedom ⇒ 0.
assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0);
// A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly,
// so the residual variance — and hence the t-statistic — is 0.
let geom: Vec<f64> = (0..8).map(|t| 0.5_f64.powi(t)).collect();
assert_eq!(adf_no_constant(&geom, 0), 0.0);
}
#[test]
fn recovers_hedge_ratio() {
// a = 2·b + 5 + small wobble ⇒ β ≈ 2.
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let b = 100.0 + f64::from(t);
let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin();
(a, b)
})
.collect();
let out = Cointegration::new(30, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(
(out.hedge_ratio - 2.0).abs() < 0.1,
"beta {}",
out.hedge_ratio
);
}
#[test]
fn stationary_spread_is_strongly_negative() {
// A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF.
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 50.0 + 0.5 * f64::from(t);
let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin();
(a, b)
})
.collect();
let out = Cointegration::new(40, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat);
}
#[test]
fn perfect_cointegration_has_zero_spread_and_defined_ratio() {
// a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
let out = Cointegration::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9);
assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6);
assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12);
}
#[test]
fn flat_b_falls_back_to_level() {
// Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a mean(a).
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0))
.collect();
let out = Cointegration::new(10, 0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12);
}
#[test]
fn plain_dickey_fuller_lags_zero() {
// Exercise the lags = 0 path (1×1 ADF system).
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 20.0 + 0.4 * f64::from(t);
let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin();
(a, b)
})
.collect();
let out = Cointegration::new(20, 0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!((out.hedge_ratio - 1.5).abs() < 0.1);
assert!(out.adf_stat < 0.0);
}
#[test]
fn reset_clears_state() {
let mut c = Cointegration::new(10, 1).unwrap();
for t in 0..20 {
let b = 100.0 + f64::from(t);
c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b));
}
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin();
(a, b)
})
.collect();
let batch = Cointegration::new(25, 2).unwrap().batch(&pairs);
let mut c = Cointegration::new(25, 2).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,324 @@
//! LeadLag Cross-Correlation — which of two assets leads the other, and by how much.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`LeadLagCrossCorrelation`]: the lead/lag offset and its correlation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LeadLagCrossCorrelationOutput {
/// The offset `k ∈ [max_lag, max_lag]` that maximises `|corr(a[t], b[t+k])|`.
///
/// A **positive** lag means `a` leads `b` by `lag` samples (a's pattern
/// shows up in `b` that many steps later); a **negative** lag means `b`
/// leads `a`; `0` means the two are most correlated contemporaneously.
pub lag: i64,
/// The (signed) Pearson correlation at that lag, in `[1, +1]`.
pub correlation: f64,
}
/// Rolling leadlag cross-correlation between two synchronised series.
///
/// Each `update` receives one `(a, b)` pair. The indicator keeps the most
/// recent `window + 2·max_lag` samples of each series and, once full, reports
/// the integer offset `k ∈ [max_lag, +max_lag]` that maximises the absolute
/// Pearson correlation between `a` and a copy of `b` shifted by `k`:
///
/// ```text
/// lag = argmax_k | corr( a[t], b[t+k] ) |
/// ```
///
/// This answers "does BTC lead ETH on this timescale, and by how many bars?".
/// A positive lag means `a` leads `b`; a negative lag means `b` leads `a`. The
/// reported `correlation` is the signed correlation at that lag, so its sign
/// tells you whether the lead relationship is positive or inverse.
///
/// The comparison is fully causal: `a`'s window is held fixed in the centre of
/// the buffer and `b`'s window slides across it, so every lag — positive and
/// negative — is evaluated only against data already seen. The candidate lags
/// are scanned in order of increasing `|k|`, so ties resolve to the smallest
/// absolute offset (lag `0` wins an exact tie).
///
/// Each `update` is `O(window · max_lag)` — proportional to the fixed
/// parameters, not the series length. A flat window in either channel makes a
/// correlation undefined; it is reported as `0` rather than `NaN`.
///
/// Feed raw prices or returns depending on your convention; leadlag on
/// returns is the more common choice for relating two assets.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LeadLagCrossCorrelation};
///
/// let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
/// let mut last = None;
/// for t in 0..60 {
/// let a = (f64::from(t) * 0.4).sin() + 0.4 * (f64::from(t) * 1.1).sin();
/// // `b` is `a` delayed by 3 samples, so `a` leads `b` by 3.
/// let b = (f64::from(t - 3) * 0.4).sin() + 0.4 * (f64::from(t - 3) * 1.1).sin();
/// last = ll.update((a, b));
/// }
/// let out = last.unwrap();
/// assert_eq!(out.lag, 3);
/// assert!(out.correlation > 0.99);
/// ```
#[derive(Debug, Clone)]
pub struct LeadLagCrossCorrelation {
window: usize,
max_lag: usize,
len: usize,
a_buf: VecDeque<f64>,
b_buf: VecDeque<f64>,
}
impl LeadLagCrossCorrelation {
/// Construct a new leadlag cross-correlation.
///
/// `window` is the number of overlapping points each correlation is
/// computed over; `max_lag` is the largest offset (in either direction)
/// that is searched.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `window < 2` or `max_lag == 0`.
pub fn new(window: usize, max_lag: usize) -> Result<Self> {
if window < 2 {
return Err(Error::InvalidPeriod {
message: "lead-lag cross-correlation needs window >= 2",
});
}
if max_lag == 0 {
return Err(Error::InvalidPeriod {
message: "lead-lag cross-correlation needs max_lag >= 1",
});
}
let len = window + 2 * max_lag;
Ok(Self {
window,
max_lag,
len,
a_buf: VecDeque::with_capacity(len),
b_buf: VecDeque::with_capacity(len),
})
}
/// Number of overlapping points per correlation.
pub const fn window(&self) -> usize {
self.window
}
/// Largest offset searched in either direction.
pub const fn max_lag(&self) -> usize {
self.max_lag
}
/// Pearson correlation between `a[a_start .. a_start+window]` and
/// `b[b_start .. b_start+window]`, clamped to `[1, 1]`. Returns `0` when
/// either window has zero variance.
fn corr_at(&self, a_start: usize, b_start: usize) -> f64 {
let n = self.window as f64;
let mut sa = 0.0;
let mut sb = 0.0;
let mut saa = 0.0;
let mut sbb = 0.0;
let mut sab = 0.0;
for j in 0..self.window {
let x = self.a_buf[a_start + j];
let y = self.b_buf[b_start + j];
sa += x;
sb += y;
saa += x * x;
sbb += y * y;
sab += x * y;
}
let mean_a = sa / n;
let mean_b = sb / n;
let var_a = (saa / n - mean_a * mean_a).max(0.0);
let var_b = (sbb / n - mean_b * mean_b).max(0.0);
let denom = (var_a * var_b).sqrt();
if denom == 0.0 {
return 0.0;
}
let cov = sab / n - mean_a * mean_b;
(cov / denom).clamp(-1.0, 1.0)
}
}
impl Indicator for LeadLagCrossCorrelation {
/// `(a, b)` pair.
type Input = (f64, f64);
type Output = LeadLagCrossCorrelationOutput;
fn update(&mut self, input: (f64, f64)) -> Option<LeadLagCrossCorrelationOutput> {
let (a, b) = input;
if self.a_buf.len() == self.len {
self.a_buf.pop_front();
self.b_buf.pop_front();
}
self.a_buf.push_back(a);
self.b_buf.push_back(b);
if self.a_buf.len() < self.len {
return None;
}
// `a`'s window sits in the centre; `b`'s window slides ±max_lag.
let a_start = self.max_lag;
// Start at lag 0, then widen outward so ties prefer the smallest |lag|.
// The lag is tracked as a signed counter incremented by ±1, so no
// unsigned index is ever cast to a signed type.
let mut best_lag: i64 = 0;
let mut best_corr = self.corr_at(a_start, a_start);
let mut best_abs = best_corr.abs();
let mut lag_neg: i64 = 0;
let mut lag_pos: i64 = 0;
for d in 1..=self.max_lag {
lag_neg -= 1;
lag_pos += 1;
// Negative lag: b shifted earlier (b leads a).
let c_neg = self.corr_at(a_start, a_start - d);
if c_neg.abs() > best_abs {
best_abs = c_neg.abs();
best_corr = c_neg;
best_lag = lag_neg;
}
// Positive lag: b shifted later (a leads b).
let c_pos = self.corr_at(a_start, a_start + d);
if c_pos.abs() > best_abs {
best_abs = c_pos.abs();
best_corr = c_pos;
best_lag = lag_pos;
}
}
Some(LeadLagCrossCorrelationOutput {
lag: best_lag,
correlation: best_corr,
})
}
fn reset(&mut self) {
self.a_buf.clear();
self.b_buf.clear();
}
fn warmup_period(&self) -> usize {
self.len
}
fn is_ready(&self) -> bool {
self.a_buf.len() == self.len
}
fn name(&self) -> &'static str {
"LeadLagCrossCorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn signal(t: i64) -> f64 {
let t = t as f64;
(t * 0.4).sin() + 0.4 * (t * 1.1).sin() + 0.2 * (t * 0.27).cos()
}
#[test]
fn rejects_invalid_params() {
assert!(LeadLagCrossCorrelation::new(1, 5).is_err());
assert!(LeadLagCrossCorrelation::new(10, 0).is_err());
assert!(LeadLagCrossCorrelation::new(10, 5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
assert_eq!(ll.window(), 10);
assert_eq!(ll.max_lag(), 4);
// len = window + 2*max_lag = 10 + 8 = 18.
assert_eq!(ll.warmup_period(), 18);
assert_eq!(ll.name(), "LeadLagCrossCorrelation");
}
#[test]
fn detects_positive_lead() {
// b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t - 3))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 3);
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
}
#[test]
fn detects_negative_lead() {
// a is a delayed copy of b ⇒ b leads a ⇒ lag = 2.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t - 2), signal(t))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, -2);
assert!(out.correlation > 0.99, "corr was {}", out.correlation);
}
#[test]
fn contemporaneous_is_lag_zero() {
// Identical streams correlate best at lag 0 with correlation 1.
let pairs: Vec<(f64, f64)> = (0..60).map(|t| (signal(t), signal(t))).collect();
let out = LeadLagCrossCorrelation::new(12, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 0);
assert_relative_eq!(out.correlation, 1.0, epsilon = 1e-9);
}
#[test]
fn flat_channel_yields_zero_correlation() {
// A constant `a` has no variance ⇒ every correlation is 0 ⇒ lag 0.
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, signal(t))).collect();
let out = LeadLagCrossCorrelation::new(10, 4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(out.lag, 0);
assert_relative_eq!(out.correlation, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut ll = LeadLagCrossCorrelation::new(10, 4).unwrap();
for t in 0..40 {
ll.update((signal(t), signal(t - 2)));
}
assert!(ll.is_ready());
ll.reset();
assert!(!ll.is_ready());
assert_eq!(ll.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80).map(|t| (signal(t), signal(t - 1))).collect();
let batch = LeadLagCrossCorrelation::new(12, 5).unwrap().batch(&pairs);
let mut ll = LeadLagCrossCorrelation::new(12, 5).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+10
View File
@@ -43,6 +43,7 @@ mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
@@ -99,6 +100,7 @@ mod kst;
mod kurtosis;
mod kvo;
mod laguerre_rsi;
mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
@@ -122,6 +124,8 @@ mod obv;
mod omega_ratio;
mod opening_range;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -135,6 +139,7 @@ mod psar;
mod pvi;
mod r_squared;
mod recovery_factor;
mod relative_strength_ab;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -257,6 +262,7 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
@@ -313,6 +319,7 @@ pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use laguerre_rsi::LaguerreRsi;
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
@@ -336,6 +343,8 @@ pub use obv::Obv;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -349,6 +358,7 @@ pub use psar::Psar;
pub use pvi::Pvi;
pub use r_squared::RSquared;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -0,0 +1,299 @@
//! Pair Spread Z-Score — the standardised log-spread of two cointegrated assets.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Z-score of the log-spread `ln(a) β·ln(b)` between two assets.
///
/// This is the canonical mean-reversion / statistical-arbitrage signal for a
/// pair. Each `update` receives one `(a, b)` pair of raw **prices** and the
/// indicator does two things:
///
/// 1. **Hedge ratio.** A rolling ordinary-least-squares regression of
/// `ln(a)` on `ln(b)` over the trailing `beta_period` samples gives the
/// slope `β = cov(ln a, ln b) / var(ln b)`. The instantaneous spread is the
/// residual against the origin, `s = ln(a) β·ln(b)`.
/// 2. **Standardisation.** The spread is then z-scored over the trailing
/// `z_period` spreads: `z = (s mean_s) / std_s`.
///
/// A large positive `z` means `a` is rich relative to `b` (sell the spread); a
/// large negative `z` means `a` is cheap (buy the spread); `z` near zero means
/// the pair is at its typical relationship. The two windows are independent:
/// `beta_period` controls how much history the hedge ratio adapts over, and
/// `z_period` controls the look-back for the mean and dispersion of the spread.
///
/// Each `update` is O(1): five running sums maintain the rolling OLS and two
/// more maintain the rolling spread mean/variance. A flat `ln(b)` window has
/// zero variance and the hedge ratio is undefined; `β` is then taken as `0`,
/// reducing the spread to `ln(a)`. A flat spread window (zero dispersion)
/// yields a z-score of `0` rather than `NaN`.
///
/// Prices must be strictly positive and finite for the logarithm to be
/// defined; a non-positive or non-finite price is skipped (it does not enter
/// either window), exactly as a real feed would discard a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairSpreadZScore};
///
/// let mut zs = PairSpreadZScore::new(2, 2).unwrap();
/// // A flat benchmark gives hedge ratio 0, so the spread is just ln(a); with
/// // a 2-sample z-window the z-score collapses to the sign of the last move.
/// let mut last = None;
/// for a in [100.0, 100.0, 110.0, 120.0] {
/// last = zs.update((a, 100.0));
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairSpreadZScore {
beta_period: usize,
z_period: usize,
// Rolling OLS of y = ln(a) on x = ln(b).
reg: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xx: f64,
sum_xy: f64,
// Rolling mean/variance of the spread.
spreads: VecDeque<f64>,
sum_s: f64,
sum_ss: f64,
}
impl PairSpreadZScore {
/// Construct a new pair spread z-score.
///
/// `beta_period` is the look-back for the rolling hedge ratio; `z_period`
/// is the look-back for standardising the spread.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if either period is below `2`
/// (variance needs at least two points).
pub fn new(beta_period: usize, z_period: usize) -> Result<Self> {
if beta_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs beta_period >= 2",
});
}
if z_period < 2 {
return Err(Error::InvalidPeriod {
message: "pair spread z-score needs z_period >= 2",
});
}
Ok(Self {
beta_period,
z_period,
reg: VecDeque::with_capacity(beta_period),
sum_x: 0.0,
sum_y: 0.0,
sum_xx: 0.0,
sum_xy: 0.0,
spreads: VecDeque::with_capacity(z_period),
sum_s: 0.0,
sum_ss: 0.0,
})
}
/// Look-back of the rolling hedge-ratio regression.
pub const fn beta_period(&self) -> usize {
self.beta_period
}
/// Look-back of the rolling spread standardisation.
pub const fn z_period(&self) -> usize {
self.z_period
}
/// The current hedge ratio `β`, or `None` while the regression is warming
/// up. A flat `ln(b)` window reports `0`.
fn hedge_ratio(&self) -> Option<f64> {
if self.reg.len() < self.beta_period {
return None;
}
let n = self.beta_period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
if var_x == 0.0 {
return Some(0.0);
}
let cov = self.sum_xy / n - mean_x * mean_y;
Some(cov / var_x)
}
fn push_spread(&mut self, s: f64) -> Option<f64> {
if self.spreads.len() == self.z_period {
let old = self.spreads.pop_front().expect("non-empty");
self.sum_s -= old;
self.sum_ss -= old * old;
}
self.spreads.push_back(s);
self.sum_s += s;
self.sum_ss += s * s;
if self.spreads.len() < self.z_period {
return None;
}
let m = self.z_period as f64;
let mean_s = self.sum_s / m;
let var_s = (self.sum_ss / m - mean_s * mean_s).max(0.0);
let std_s = var_s.sqrt();
if std_s == 0.0 {
// A flat spread window has no dispersion to standardise against.
return Some(0.0);
}
Some((s - mean_s) / std_s)
}
}
impl Indicator for PairSpreadZScore {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: skip it without disturbing either window.
return None;
}
let x = b.ln();
let y = a.ln();
if self.reg.len() == self.beta_period {
let (ox, oy) = self.reg.pop_front().expect("non-empty");
self.sum_x -= ox;
self.sum_y -= oy;
self.sum_xx -= ox * ox;
self.sum_xy -= ox * oy;
}
self.reg.push_back((x, y));
self.sum_x += x;
self.sum_y += y;
self.sum_xx += x * x;
self.sum_xy += x * y;
let beta = self.hedge_ratio()?;
let spread = y - beta * x;
self.push_spread(spread)
}
fn reset(&mut self) {
self.reg.clear();
self.sum_x = 0.0;
self.sum_y = 0.0;
self.sum_xx = 0.0;
self.sum_xy = 0.0;
self.spreads.clear();
self.sum_s = 0.0;
self.sum_ss = 0.0;
}
fn warmup_period(&self) -> usize {
// `beta_period` samples to define the hedge ratio (and the first
// spread), then `z_period 1` more to fill the spread window.
self.beta_period + self.z_period - 1
}
fn is_ready(&self) -> bool {
self.spreads.len() == self.z_period
}
fn name(&self) -> &'static str {
"PairSpreadZScore"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_periods_below_two() {
assert!(PairSpreadZScore::new(1, 5).is_err());
assert!(PairSpreadZScore::new(5, 1).is_err());
assert!(PairSpreadZScore::new(2, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let z = PairSpreadZScore::new(10, 20).unwrap();
assert_eq!(z.beta_period(), 10);
assert_eq!(z.z_period(), 20);
assert_eq!(z.warmup_period(), 29);
assert_eq!(z.name(), "PairSpreadZScore");
}
#[test]
fn flat_benchmark_two_sample_window_is_sign_of_move() {
// Flat b ⇒ β = 0 ⇒ spread = ln(a); z_period = 2 ⇒ z = sign of last move.
let mut z = PairSpreadZScore::new(2, 2).unwrap();
assert_eq!(z.update((100.0, 100.0)), None);
assert_eq!(z.update((100.0, 100.0)), None);
// The ±1 result is exact in real arithmetic; the variance is computed
// via Σs²−mean² so a few ulps of cancellation error remain.
assert_relative_eq!(z.update((110.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((105.0, 100.0)).unwrap(), -1.0, epsilon = 1e-9);
assert_relative_eq!(z.update((130.0, 100.0)).unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn constant_spread_yields_zero() {
// Both legs flat ⇒ spread constant ⇒ zero dispersion ⇒ z = 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|_| (50.0, 100.0)).collect();
let last = PairSpreadZScore::new(3, 4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_is_skipped() {
let mut z = PairSpreadZScore::new(2, 2).unwrap();
// A non-positive or non-finite price never enters the windows.
assert_eq!(z.update((0.0, 100.0)), None);
assert_eq!(z.update((100.0, f64::NAN)), None);
assert!(!z.is_ready());
// Valid ticks then warm the indicator normally.
z.update((100.0, 100.0));
z.update((100.0, 100.0));
z.update((110.0, 100.0));
assert!(z.is_ready());
}
#[test]
fn reset_clears_state() {
let mut z = PairSpreadZScore::new(3, 3).unwrap();
for i in 0..10 {
let b = 100.0 + 5.0 * f64::from(i).sin();
z.update((b * 1.5, b));
}
assert!(z.is_ready());
z.reset();
assert!(!z.is_ready());
assert_eq!(z.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 10.0 * (t * 0.2).sin();
let a = b * (1.0 + 0.05 * (t * 0.5).cos());
(a, b)
})
.collect();
let batch = PairSpreadZScore::new(14, 10).unwrap().batch(&pairs);
let mut z = PairSpreadZScore::new(14, 10).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| z.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,292 @@
//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
///
/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
/// indicator differences consecutive prices into log-returns
/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
/// `a`'s returns on `b`'s returns over the trailing window of `period` return
/// pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ rₐ·r_b r̄ₐ·r̄_b
/// var_b = (1/n) · Σ r_b² r̄_b²
/// Beta = cov_ab / var_b
/// ```
///
/// This is the slope of the OLS line and measures how much asset `a` moves, in
/// return space, for a unit return of asset `b`. A reading of `1.0` means the
/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
/// moves; negative readings signal an inverse relationship and the basis for a
/// hedge.
///
/// This differs from [`crate::Beta`], which regresses the raw inputs it is
/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
/// and it computes the returns for you, which is the conventional way to
/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
/// shared trend and rarely what you want).
///
/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
/// window has zero return variance and Beta is undefined; the indicator
/// returns `0` in that case rather than producing `NaN`.
///
/// Prices must be strictly positive and finite for the log-return to be
/// defined. A non-positive or non-finite price breaks the return chain: that
/// sample is dropped and the next valid price re-seeds the previous-price
/// reference, exactly as a real feed would resume after a bad tick.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PairwiseBeta};
///
/// let mut indicator = PairwiseBeta::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// // A varying (non-constant-return) positive price path.
/// let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
/// // `a = b²`, so a's log-returns are exactly twice b's.
/// last = indicator.update((b * b, b));
/// }
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PairwiseBeta {
period: usize,
prev: Option<(f64, f64)>,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl PairwiseBeta {
/// Construct a new rolling pairwise Beta over `period` return pairs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "pairwise beta needs period >= 2",
});
}
Ok(Self {
period,
prev: None,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured period (number of return pairs in the rolling window).
pub const fn period(&self) -> usize {
self.period
}
fn push_return(&mut self, ra: f64, rb: f64) -> Option<f64> {
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((ra, rb));
self.sum_a += ra;
self.sum_b += rb;
self.sum_bb += rb * rb;
self.sum_ab += ra * rb;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let cov = self.sum_ab / n - mean_a * mean_b;
if var_b == 0.0 {
// A flat benchmark-return window has no defined beta.
return Some(0.0);
}
Some(cov / var_b)
}
}
impl Indicator for PairwiseBeta {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
// Bad tick: drop it and restart the return chain.
self.prev = None;
return None;
}
let Some((pa, pb)) = self.prev else {
self.prev = Some((a, b));
return None;
};
self.prev = Some((a, b));
let ra = (a / pa).ln();
let rb = (b / pb).ln();
self.push_return(ra, rb)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
// One prior price to seed, then `period` return pairs.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"PairwiseBeta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(PairwiseBeta::new(0).is_err());
assert!(PairwiseBeta::new(1).is_err());
assert!(PairwiseBeta::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let b = PairwiseBeta::new(14).unwrap();
assert_eq!(b.period(), 14);
assert_eq!(b.warmup_period(), 15);
assert_eq!(b.name(), "PairwiseBeta");
}
#[test]
fn squared_price_gives_beta_two() {
// a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(b * b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
}
#[test]
fn inverse_price_gives_beta_minus_one() {
// a = 1/b ⇒ a's log-returns are 1× b's ⇒ beta = 1.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
(1.0 / b, b)
})
.collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_benchmark_returns_zero() {
// b constant ⇒ zero return variance ⇒ beta defined as 0.
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
let last = PairwiseBeta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn bad_tick_breaks_return_chain() {
let mut b = PairwiseBeta::new(3).unwrap();
// Seed, one good return, then a non-positive price drops the chain.
assert_eq!(b.update((100.0, 100.0)), None);
assert_eq!(b.update((101.0, 101.0)), None);
assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
assert!(!b.is_ready());
// A non-finite price is rejected the same way.
assert_eq!(b.update((f64::NAN, 50.0)), None);
assert!(!b.is_ready());
// Recovery: subsequent valid prices rebuild the window cleanly.
for i in 0..5 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
}
#[test]
fn reset_clears_state() {
let mut b = PairwiseBeta::new(3).unwrap();
for i in 0..6 {
let p = 100.0 * 1.01_f64.powi(i);
b.update((p * p, p));
}
assert!(b.is_ready());
b.reset();
assert!(!b.is_ready());
assert_eq!(b.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
let b = 100.0 + 5.0 * t.sin();
let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
(a, b)
})
.collect();
let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
let mut b = PairwiseBeta::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,233 @@
//! Relative Strength A-vs-B — the price ratio of two assets, plus its MA and RSI.
use crate::error::Result;
use crate::indicators::{Rsi, Sma};
use crate::traits::Indicator;
/// Output of [`RelativeStrengthAB`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RelativeStrengthOutput {
/// The raw relative-strength ratio `a / b`.
pub ratio: f64,
/// Simple moving average of the ratio over `ma_period`.
pub ratio_ma: f64,
/// Relative Strength Index of the ratio over `rsi_period`.
pub ratio_rsi: f64,
}
/// Comparative relative strength of asset `a` against asset `b`.
///
/// Each `update` receives one `(a, b)` price pair and forms the **ratio line**
/// `a / b`. The ratio is then smoothed with a simple moving average and run
/// through an RSI, so a single indicator gives you the relative-strength level,
/// its trend, and whether that trend is overbought or oversold:
///
/// ```text
/// ratio = a / b
/// ratio_ma = SMA(ratio, ma_period)
/// ratio_rsi = RSI(ratio, rsi_period)
/// ```
///
/// A rising ratio means `a` is outperforming `b`; `ratio_ma` shows the trend of
/// that outperformance and `ratio_rsi` flags exhaustion (e.g. `> 70` after a
/// strong run of `a` over `b`). This is the classic "asset-vs-asset" or
/// "asset-vs-index" rotation screen.
///
/// The first output appears once both the moving average and the RSI have
/// warmed up; the ratio itself is computed from the first valid pair. A
/// non-finite price or a zero denominator (`b == 0`) makes the ratio undefined
/// and is skipped, leaving the internal averages untouched.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RelativeStrengthAB};
///
/// let mut rs = RelativeStrengthAB::new(5, 5).unwrap();
/// let mut last = None;
/// for _ in 0..20 {
/// last = rs.update((200.0, 100.0)); // ratio is a constant 2.0
/// }
/// let out = last.unwrap();
/// assert!((out.ratio - 2.0).abs() < 1e-12);
/// assert!((out.ratio_ma - 2.0).abs() < 1e-12);
/// // A flat ratio has no gains or losses, so its RSI sits at the neutral 50.
/// assert!((out.ratio_rsi - 50.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RelativeStrengthAB {
ma_period: usize,
rsi_period: usize,
ma: Sma,
rsi: Rsi,
}
impl RelativeStrengthAB {
/// Construct a new comparative relative-strength indicator.
///
/// `ma_period` is the moving-average look-back of the ratio; `rsi_period`
/// is the RSI look-back of the ratio.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
/// is zero.
pub fn new(ma_period: usize, rsi_period: usize) -> Result<Self> {
Ok(Self {
ma_period,
rsi_period,
ma: Sma::new(ma_period)?,
rsi: Rsi::new(rsi_period)?,
})
}
/// Moving-average look-back of the ratio.
pub const fn ma_period(&self) -> usize {
self.ma_period
}
/// RSI look-back of the ratio.
pub const fn rsi_period(&self) -> usize {
self.rsi_period
}
}
impl Indicator for RelativeStrengthAB {
/// `(a, b)` price pair.
type Input = (f64, f64);
type Output = RelativeStrengthOutput;
fn update(&mut self, input: (f64, f64)) -> Option<RelativeStrengthOutput> {
let (a, b) = input;
if b == 0.0 || !a.is_finite() || !b.is_finite() {
// Undefined ratio: skip without disturbing the internal averages.
return None;
}
let ratio = a / b;
let ma = self.ma.update(ratio);
let rsi = self.rsi.update(ratio);
match (ma, rsi) {
(Some(ratio_ma), Some(ratio_rsi)) => Some(RelativeStrengthOutput {
ratio,
ratio_ma,
ratio_rsi,
}),
_ => None,
}
}
fn reset(&mut self) {
self.ma.reset();
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.ma.warmup_period().max(self.rsi.warmup_period())
}
fn is_ready(&self) -> bool {
self.ma.is_ready() && self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"RelativeStrengthAB"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(RelativeStrengthAB::new(0, 5).is_err());
assert!(RelativeStrengthAB::new(5, 0).is_err());
assert!(RelativeStrengthAB::new(5, 5).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rs = RelativeStrengthAB::new(10, 14).unwrap();
assert_eq!(rs.ma_period(), 10);
assert_eq!(rs.rsi_period(), 14);
// SMA warmup = 10, RSI warmup = 15 ⇒ combined = 15.
assert_eq!(rs.warmup_period(), 15);
assert_eq!(rs.name(), "RelativeStrengthAB");
}
#[test]
fn constant_ratio_is_flat() {
// a = 2·b ⇒ ratio is a constant 2 ⇒ MA = 2, RSI = neutral 50.
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (200.0, 100.0)).collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.ratio, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_ma, 2.0, epsilon = 1e-12);
assert_relative_eq!(out.ratio_rsi, 50.0, epsilon = 1e-9);
}
#[test]
fn rising_ratio_is_overbought() {
// a grows while b is flat ⇒ ratio strictly rises ⇒ RSI saturates at 100.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (100.0 + 2.0 * f64::from(t), 100.0))
.collect();
let out = RelativeStrengthAB::new(5, 5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.ratio > 1.0);
assert_relative_eq!(out.ratio_rsi, 100.0, epsilon = 1e-9);
}
#[test]
fn zero_denominator_is_skipped() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
// b == 0 and non-finite inputs never reach the internal averages.
assert_eq!(rs.update((100.0, 0.0)), None);
assert_eq!(rs.update((f64::NAN, 100.0)), None);
assert!(!rs.is_ready());
for _ in 0..8 {
rs.update((150.0, 100.0));
}
assert!(rs.is_ready());
}
#[test]
fn reset_clears_state() {
let mut rs = RelativeStrengthAB::new(3, 3).unwrap();
for t in 0..10 {
rs.update((100.0 + f64::from(t), 100.0));
}
assert!(rs.is_ready());
rs.reset();
assert!(!rs.is_ready());
assert_eq!(rs.update((100.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let tt = f64::from(t);
(
100.0 + 5.0 * (tt * 0.3).sin(),
100.0 + 2.0 * (tt * 0.2).cos(),
)
})
.collect();
let batch = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| rs.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+24 -22
View File
@@ -52,31 +52,33 @@ pub use indicators::{
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DrawdownDuration,
EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing,
Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility,
Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle,
HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku,
IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor,
RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc,
StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.0",
"version": "0.4.1",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"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"
"wickra-darwin-arm64": "0.4.1",
"wickra-darwin-x64": "0.4.1",
"wickra-linux-arm64-gnu": "0.4.1",
"wickra-linux-x64-gnu": "0.4.1",
"wickra-win32-arm64-msvc": "0.4.1",
"wickra-win32-x64-msvc": "0.4.1"
}
},
"node_modules/wickra": {
+26 -1
View File
@@ -8,7 +8,10 @@
//! panic.
use libfuzzer_sys::fuzz_target;
use wickra_core::{Alpha, BatchExt, Indicator, InformationRatio, TreynorRatio};
use wickra_core::{
Alpha, BatchExt, Cointegration, Indicator, InformationRatio, LeadLagCrossCorrelation,
PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, TreynorRatio,
};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
@@ -36,4 +39,26 @@ fuzz_target!(|data: &[u8]| {
drive(|| TreynorRatio::new(10, 0.0).unwrap(), &pairs);
drive(|| InformationRatio::new(10).unwrap(), &pairs);
drive(|| Alpha::new(10, 0.0).unwrap(), &pairs);
drive(|| PairwiseBeta::new(10).unwrap(), &pairs);
drive(|| PairSpreadZScore::new(10, 10).unwrap(), &pairs);
// Struct-output pair indicator: drive update + batch directly (the generic
// `drive` above only covers `Output = f64`).
let mut ll = LeadLagCrossCorrelation::new(8, 3).unwrap();
for &x in &pairs {
let _ = ll.update(x);
}
let _ = LeadLagCrossCorrelation::new(8, 3).unwrap().batch(&pairs);
let mut co = Cointegration::new(12, 1).unwrap();
for &x in &pairs {
let _ = co.update(x);
}
let _ = Cointegration::new(12, 1).unwrap().batch(&pairs);
let mut rs = RelativeStrengthAB::new(10, 14).unwrap();
for &x in &pairs {
let _ = rs.update(x);
}
let _ = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
});