Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4631519885 | |||
| 0b85142ad1 | |||
| 1ab9bc70d1 | |||
| 2ab578bee8 | |||
| 2be39b8b98 | |||
| 99af5f8ee1 | |||
| bff1148d20 | |||
| ebddc5e376 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }})
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -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.
|
||||
- **Lead–Lag 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 Engle–Granger two-step screen for
|
||||
pairs trading: a rolling OLS hedge ratio `β`, the spread (residual)
|
||||
`a − (α + β·b)`, and an augmented Dickey–Fuller `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
@@ -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
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[](LICENSE)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](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);
|
||||
|
||||
Vendored
+100
@@ -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 {
|
||||
/** Engle–Granger hedge ratio (OLS slope of `a` on `b`). */
|
||||
hedgeRatio: number
|
||||
/** Current spread (regression residual) `a - (alpha + beta*b)`. */
|
||||
spread: number
|
||||
/**
|
||||
* Augmented Dickey–Fuller 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
@@ -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,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,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,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": [
|
||||
|
||||
Generated
+20
-20
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
/// Engle–Granger hedge ratio (OLS slope of `a` on `b`).
|
||||
pub hedge_ratio: f64,
|
||||
/// Current spread (regression residual) `a - (alpha + beta*b)`.
|
||||
pub spread: f64,
|
||||
/// Augmented Dickey–Fuller 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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Engle–Granger 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 {
|
||||
/// Engle–Granger 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 Dickey–Fuller `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 (Engle–Granger 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 Dickey–Fuller** 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 Dickey–Fuller regression (`0` is the plain
|
||||
/// Dickey–Fuller 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 Dickey–Fuller `t`-statistic on `series`, with `lags` lagged
|
||||
/// differences and **no** constant or trend term (the Engle–Granger 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 @@
|
||||
//! Lead–Lag 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 lead–lag 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; lead–lag 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 lead–lag 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Generated
+7
-7
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user