Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4631519885 | ||
|
|
0b85142ad1 | ||
|
|
1ab9bc70d1 | ||
|
|
2ab578bee8 | ||
|
|
2be39b8b98 | ||
|
|
99af5f8ee1 | ||
|
|
bff1148d20 | ||
|
|
ebddc5e376 |
@@ -22,8 +22,26 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: "10"
|
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:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
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:
|
jobs:
|
||||||
cross-library-bench:
|
cross-library-bench:
|
||||||
|
|||||||
@@ -6,9 +6,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
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:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUSTFLAGS: "-D warnings"
|
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:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '31 3 * * 0' # Sundays 03:31 UTC
|
- 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:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze (${{ matrix.language }})
|
name: Analyze (${{ matrix.language }})
|
||||||
|
|||||||
@@ -5,8 +5,30 @@ on:
|
|||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
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:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
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:
|
jobs:
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -527,13 +549,24 @@ jobs:
|
|||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# GitHub Release: attach every built artefact to the tag's release page.
|
# 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:
|
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]
|
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
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:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
@@ -578,7 +611,7 @@ jobs:
|
|||||||
ls -lh release-assets/
|
ls -lh release-assets/
|
||||||
echo "asset-count=$(ls release-assets/ | wc -l)"
|
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
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.tag.outputs.tag }}
|
tag_name: ${{ steps.tag.outputs.tag }}
|
||||||
@@ -586,6 +619,9 @@ jobs:
|
|||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: false
|
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: |
|
body: |
|
||||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||||
|
|
||||||
@@ -618,18 +654,26 @@ jobs:
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
attestations:
|
attestations:
|
||||||
name: Attest build provenance
|
name: Attest build provenance
|
||||||
needs: [cargo-publish, python-wheels, python-sdist]
|
needs: [cargo-publish, python-wheels, python-sdist, github-release]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Signed SLSA build-provenance attestations for the published crates and
|
# Signed SLSA build-provenance attestations for the published crates and
|
||||||
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
|
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
|
||||||
# from `npm publish --provenance`, so they are covered there. This job is
|
# from `npm publish --provenance`, so they are covered there.
|
||||||
# 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
|
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
|
||||||
# SBOM step lacked before #79).
|
# 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:
|
permissions:
|
||||||
id-token: write # OIDC for keyless Sigstore signing
|
id-token: write # OIDC for keyless Sigstore signing
|
||||||
attestations: write # write the attestations to this repo
|
attestations: write # write the attestations to this repo
|
||||||
contents: read
|
contents: write # upload the provenance bundle as a release asset
|
||||||
steps:
|
steps:
|
||||||
- name: Download crate files
|
- name: Download crate files
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
@@ -643,9 +687,70 @@ jobs:
|
|||||||
path: artifacts/python
|
path: artifacts/python
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Attest build provenance
|
- name: Attest build provenance
|
||||||
|
id: attest
|
||||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||||
with:
|
with:
|
||||||
subject-path: |
|
subject-path: |
|
||||||
artifacts/crates/*.crate
|
artifacts/crates/*.crate
|
||||||
artifacts/python/*.whl
|
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*
|
# — synced on v* tag only*
|
||||||
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
|
# 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*
|
# 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
|
# *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
|
# 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 —
|
# 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
|
# 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.
|
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
|
||||||
@@ -55,18 +60,24 @@ on:
|
|||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# `contents: write` is needed so the workflow can push the counter
|
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
|
||||||
# fix-up commit to the PR head branch via the auto-provided
|
# write` the workflow needs — to push the counter fix-up commit to the PR head
|
||||||
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
|
# branch — is raised at the job level below, not here, so the top-level default
|
||||||
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
|
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
|
||||||
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
|
# docs / webpage / org writes still go through the fine-grained PAT
|
||||||
|
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
# On PRs from forks the head ref lives in another repo; pushing
|
# On PRs from forks the head ref lives in another repo; pushing
|
||||||
# back to it from this workflow is blocked by GitHub. We still
|
# back to it from this workflow is blocked by GitHub. We still
|
||||||
@@ -242,6 +253,40 @@ jobs:
|
|||||||
echo "Docs indicator count synced to ${n}."
|
echo "Docs indicator count synced to ${n}."
|
||||||
fi
|
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) -------
|
# ----- org-profile sync (soft-skip until PAT scope lands) -------
|
||||||
#
|
#
|
||||||
# These two steps keep the org page (github.com/wickra-lib) in sync
|
# 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."
|
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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."
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
cd docs
|
cd docs-ver
|
||||||
# Published-versions table rows (crates.io / PyPI / npm): replace only the
|
# Published-versions table rows (crates.io / PyPI / npm): replace only the
|
||||||
# version number, leaving the trailing padding + pipe intact. The '.' in
|
# version number, leaving the trailing padding + pipe intact. The '.' in
|
||||||
# the quickstart pattern matches the literal backtick around the version
|
# 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."
|
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
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."
|
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
|
||||||
exit 0
|
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/(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/(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
|
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
|
if git diff --quiet; then
|
||||||
echo "Webpage version already at ${version}."
|
echo "Webpage version already at ${version}."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git config user.name "wickra-bot"
|
git config user.name "wickra-bot"
|
||||||
git config user.email "wickra-bot@users.noreply.github.com"
|
git config user.email "wickra-bot@users.noreply.github.com"
|
||||||
git add 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}"
|
git commit -m "chore: sync published version to ${version}"
|
||||||
if ! git push 2>/dev/null; then
|
if ! git push 2>/dev/null; then
|
||||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
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]
|
## [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
|
## [0.4.0] - 2026-06-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -869,7 +900,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
optional Binance live feed.
|
optional Binance live feed.
|
||||||
- Bindings for Python, Node.js, and WebAssembly.
|
- Bindings for Python, Node.js, and WebAssembly.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.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.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
|
||||||
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
||||||
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
||||||
|
|||||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"criterion",
|
"criterion",
|
||||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-core"
|
name = "wickra-core"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"proptest",
|
"proptest",
|
||||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-data"
|
name = "wickra-data"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-node"
|
name = "wickra-node"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-python"
|
name = "wickra-python"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-wasm"
|
name = "wickra-wasm"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
|||||||
exclude = ["fuzz"]
|
exclude = ["fuzz"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
authors = ["kingchenc <support@wickra.org>"]
|
authors = ["kingchenc <support@wickra.org>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
|||||||
categories = ["finance", "mathematics", "science"]
|
categories = ["finance", "mathematics", "science"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wickra-core = { path = "crates/wickra-core", version = "0.4.0" }
|
wickra-core = { path = "crates/wickra-core", version = "0.4.1" }
|
||||||
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||||
[](https://github.com/wickra-lib/wickra/attestations)
|
[](https://github.com/wickra-lib/wickra/attestations)
|
||||||
|
[](https://docs.wickra.org)
|
||||||
|
|
||||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||||
|
|
||||||
@@ -37,6 +38,25 @@ for price in live_feed:
|
|||||||
print("overbought")
|
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
|
## Why Wickra exists
|
||||||
|
|
||||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||||
@@ -115,9 +135,10 @@ python -m benchmarks.compare_libraries
|
|||||||
|
|
||||||
## Indicators
|
## 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
|
`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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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/
|
wickra/
|
||||||
├── crates/
|
├── 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/ top-level facade crate (publishes on crates.io) + benches/
|
||||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||||
├── bindings/
|
├── bindings/
|
||||||
|
|||||||
@@ -461,6 +461,8 @@ test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
|
|||||||
const pairFactories = {
|
const pairFactories = {
|
||||||
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
||||||
Beta: () => new wickra.Beta(14),
|
Beta: () => new wickra.Beta(14),
|
||||||
|
PairwiseBeta: () => new wickra.PairwiseBeta(14),
|
||||||
|
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
|
||||||
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(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);
|
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', () => {
|
test('SpearmanCorrelation monotone non-linear is 1', () => {
|
||||||
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
||||||
const y = x.map((v) => v ** 3);
|
const y = x.map((v) => v ** 3);
|
||||||
|
|||||||
Vendored
+100
@@ -5,6 +5,34 @@
|
|||||||
|
|
||||||
/** Library version (matches the Rust crate version). */
|
/** Library version (matches the Rust crate version). */
|
||||||
export declare function version(): string
|
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. */
|
/** MACD triple: macd line, signal line, histogram. */
|
||||||
export interface MacdValue {
|
export interface MacdValue {
|
||||||
macd: number
|
macd: number
|
||||||
@@ -628,6 +656,19 @@ export declare class Beta {
|
|||||||
isReady(): boolean
|
isReady(): boolean
|
||||||
warmupPeriod(): number
|
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 type SpearmanCorrelationNode = SpearmanCorrelation
|
||||||
export declare class SpearmanCorrelation {
|
export declare class SpearmanCorrelation {
|
||||||
constructor(period: number)
|
constructor(period: number)
|
||||||
@@ -641,6 +682,65 @@ export declare class SpearmanCorrelation {
|
|||||||
isReady(): boolean
|
isReady(): boolean
|
||||||
warmupPeriod(): number
|
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 type MacdNode = MACD
|
||||||
export declare class MACD {
|
export declare class MACD {
|
||||||
constructor(fast: number, slow: number, signal: number)
|
constructor(fast: number, slow: number, signal: number)
|
||||||
|
|||||||
+54
-50
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||||||
throw new Error(`Failed to load native binding`)
|
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.version = version
|
||||||
module.exports.SMA = SMA
|
module.exports.SMA = SMA
|
||||||
@@ -332,6 +332,34 @@ module.exports.StdDev = StdDev
|
|||||||
module.exports.UlcerIndex = UlcerIndex
|
module.exports.UlcerIndex = UlcerIndex
|
||||||
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
||||||
module.exports.ZScore = ZScore
|
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.MACD = MACD
|
||||||
module.exports.BollingerBands = BollingerBands
|
module.exports.BollingerBands = BollingerBands
|
||||||
module.exports.ATR = ATR
|
module.exports.ATR = ATR
|
||||||
@@ -349,27 +377,25 @@ module.exports.VWAP = VWAP
|
|||||||
module.exports.RollingVWAP = RollingVWAP
|
module.exports.RollingVWAP = RollingVWAP
|
||||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||||
module.exports.Aroon = Aroon
|
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.Inertia = Inertia
|
||||||
module.exports.ALMA = ALMA
|
module.exports.ConnorsRSI = ConnorsRSI
|
||||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
module.exports.LaguerreRSI = LaguerreRSI
|
||||||
module.exports.FRAMA = FRAMA
|
module.exports.SMI = SMI
|
||||||
module.exports.VIDYA = VIDYA
|
module.exports.KST = KST
|
||||||
module.exports.JMA = JMA
|
module.exports.PGO = PGO
|
||||||
module.exports.Alligator = Alligator
|
module.exports.RVI = RVI
|
||||||
module.exports.EVWMA = EVWMA
|
|
||||||
module.exports.APO = APO
|
|
||||||
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
||||||
module.exports.CFO = CFO
|
|
||||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
|
||||||
module.exports.ElderImpulse = ElderImpulse
|
|
||||||
module.exports.STC = STC
|
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.T3 = T3
|
||||||
module.exports.TSI = TSI
|
module.exports.TSI = TSI
|
||||||
module.exports.PMO = PMO
|
module.exports.PMO = PMO
|
||||||
@@ -379,17 +405,17 @@ module.exports.VolumePriceTrend = VolumePriceTrend
|
|||||||
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
|
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
|
||||||
module.exports.ChaikinOscillator = ChaikinOscillator
|
module.exports.ChaikinOscillator = ChaikinOscillator
|
||||||
module.exports.ForceIndex = ForceIndex
|
module.exports.ForceIndex = ForceIndex
|
||||||
module.exports.EaseOfMovement = EaseOfMovement
|
|
||||||
module.exports.KVO = KVO
|
|
||||||
module.exports.VolumeOscillator = VolumeOscillator
|
|
||||||
module.exports.NVI = NVI
|
module.exports.NVI = NVI
|
||||||
module.exports.PVI = PVI
|
module.exports.PVI = PVI
|
||||||
|
module.exports.VolumeOscillator = VolumeOscillator
|
||||||
|
module.exports.KVO = KVO
|
||||||
module.exports.WilliamsAD = WilliamsAD
|
module.exports.WilliamsAD = WilliamsAD
|
||||||
module.exports.AnchoredVWAP = AnchoredVWAP
|
module.exports.AnchoredVWAP = AnchoredVWAP
|
||||||
module.exports.DemandIndex = DemandIndex
|
module.exports.DemandIndex = DemandIndex
|
||||||
module.exports.TSV = TSV
|
module.exports.TSV = TSV
|
||||||
module.exports.VZO = VZO
|
module.exports.VZO = VZO
|
||||||
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
||||||
|
module.exports.EaseOfMovement = EaseOfMovement
|
||||||
module.exports.SuperTrend = SuperTrend
|
module.exports.SuperTrend = SuperTrend
|
||||||
module.exports.ChandelierExit = ChandelierExit
|
module.exports.ChandelierExit = ChandelierExit
|
||||||
module.exports.ChandeKrollStop = ChandeKrollStop
|
module.exports.ChandeKrollStop = ChandeKrollStop
|
||||||
@@ -411,26 +437,25 @@ module.exports.BalanceOfPower = BalanceOfPower
|
|||||||
module.exports.ChoppinessIndex = ChoppinessIndex
|
module.exports.ChoppinessIndex = ChoppinessIndex
|
||||||
module.exports.TrueRange = TrueRange
|
module.exports.TrueRange = TrueRange
|
||||||
module.exports.ChaikinVolatility = ChaikinVolatility
|
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.LinRegAngle = LinRegAngle
|
||||||
module.exports.BollingerBandwidth = BollingerBandwidth
|
module.exports.BollingerBandwidth = BollingerBandwidth
|
||||||
module.exports.PercentB = PercentB
|
module.exports.PercentB = PercentB
|
||||||
module.exports.NATR = NATR
|
module.exports.NATR = NATR
|
||||||
module.exports.HistoricalVolatility = HistoricalVolatility
|
module.exports.HistoricalVolatility = HistoricalVolatility
|
||||||
module.exports.AroonOscillator = AroonOscillator
|
module.exports.AroonOscillator = AroonOscillator
|
||||||
module.exports.Vortex = Vortex
|
|
||||||
module.exports.RWI = RWI
|
|
||||||
module.exports.WaveTrend = WaveTrend
|
module.exports.WaveTrend = WaveTrend
|
||||||
|
module.exports.RWI = RWI
|
||||||
|
module.exports.Vortex = Vortex
|
||||||
module.exports.MassIndex = MassIndex
|
module.exports.MassIndex = MassIndex
|
||||||
module.exports.StochRSI = StochRSI
|
module.exports.StochRSI = StochRSI
|
||||||
module.exports.UltimateOscillator = UltimateOscillator
|
module.exports.UltimateOscillator = UltimateOscillator
|
||||||
module.exports.PPO = PPO
|
module.exports.PPO = PPO
|
||||||
module.exports.Coppock = Coppock
|
module.exports.Coppock = Coppock
|
||||||
module.exports.VWMA = VWMA
|
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.MaEnvelope = MaEnvelope
|
||||||
module.exports.AccelerationBands = AccelerationBands
|
module.exports.AccelerationBands = AccelerationBands
|
||||||
module.exports.StarcBands = StarcBands
|
module.exports.StarcBands = StarcBands
|
||||||
@@ -461,16 +486,9 @@ module.exports.TDRangeProjection = TDRangeProjection
|
|||||||
module.exports.TDDifferential = TDDifferential
|
module.exports.TDDifferential = TDDifferential
|
||||||
module.exports.TDOpen = TDOpen
|
module.exports.TDOpen = TDOpen
|
||||||
module.exports.TDRiskLevel = TDRiskLevel
|
module.exports.TDRiskLevel = TDRiskLevel
|
||||||
module.exports.SuperSmoother = SuperSmoother
|
|
||||||
module.exports.FisherTransform = FisherTransform
|
|
||||||
module.exports.InverseFisherTransform = InverseFisherTransform
|
module.exports.InverseFisherTransform = InverseFisherTransform
|
||||||
module.exports.Decycler = Decycler
|
|
||||||
module.exports.DecyclerOscillator = DecyclerOscillator
|
module.exports.DecyclerOscillator = DecyclerOscillator
|
||||||
module.exports.RoofingFilter = RoofingFilter
|
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.EmpiricalModeDecomposition = EmpiricalModeDecomposition
|
||||||
module.exports.HilbertDominantCycle = HilbertDominantCycle
|
module.exports.HilbertDominantCycle = HilbertDominantCycle
|
||||||
module.exports.AdaptiveCycle = AdaptiveCycle
|
module.exports.AdaptiveCycle = AdaptiveCycle
|
||||||
@@ -479,19 +497,6 @@ module.exports.MAMA = MAMA
|
|||||||
module.exports.FAMA = FAMA
|
module.exports.FAMA = FAMA
|
||||||
module.exports.Ichimoku = Ichimoku
|
module.exports.Ichimoku = Ichimoku
|
||||||
module.exports.HeikinAshi = HeikinAshi
|
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.ValueArea = ValueArea
|
||||||
module.exports.InitialBalance = InitialBalance
|
module.exports.InitialBalance = InitialBalance
|
||||||
module.exports.OpeningRange = OpeningRange
|
module.exports.OpeningRange = OpeningRange
|
||||||
@@ -510,7 +515,6 @@ module.exports.Tweezer = Tweezer
|
|||||||
module.exports.SpinningTop = SpinningTop
|
module.exports.SpinningTop = SpinningTop
|
||||||
module.exports.ThreeInside = ThreeInside
|
module.exports.ThreeInside = ThreeInside
|
||||||
module.exports.ThreeOutside = ThreeOutside
|
module.exports.ThreeOutside = ThreeOutside
|
||||||
// Family 15: Risk / Performance metrics
|
|
||||||
module.exports.SharpeRatio = SharpeRatio
|
module.exports.SharpeRatio = SharpeRatio
|
||||||
module.exports.SortinoRatio = SortinoRatio
|
module.exports.SortinoRatio = SortinoRatio
|
||||||
module.exports.CalmarRatio = CalmarRatio
|
module.exports.CalmarRatio = CalmarRatio
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-arm64",
|
"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.",
|
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-arm64.node",
|
"main": "wickra.darwin-arm64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-x64",
|
"name": "wickra-darwin-x64",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-x64.node",
|
"main": "wickra.darwin-x64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-arm64-gnu",
|
"name": "wickra-linux-arm64-gnu",
|
||||||
"version": "0.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.",
|
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.linux-arm64-gnu.node",
|
"main": "wickra.linux-arm64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-x64-gnu",
|
"name": "wickra-linux-x64-gnu",
|
||||||
"version": "0.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.",
|
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.linux-x64-gnu.node",
|
"main": "wickra.linux-x64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-win32-arm64-msvc",
|
"name": "wickra-win32-arm64-msvc",
|
||||||
"version": "0.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.",
|
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.win32-arm64-msvc.node",
|
"main": "wickra.win32-arm64-msvc.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-win32-x64-msvc",
|
"name": "wickra-win32-x64-msvc",
|
||||||
"version": "0.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.",
|
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.win32-x64-msvc.node",
|
"main": "wickra.win32-x64-msvc.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@napi-rs/cli": "^2.18.0"
|
"@napi-rs/cli": "^2.18.0"
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-darwin-arm64": "0.4.0",
|
"wickra-darwin-arm64": "0.4.1",
|
||||||
"wickra-darwin-x64": "0.4.0",
|
"wickra-darwin-x64": "0.4.1",
|
||||||
"wickra-linux-arm64-gnu": "0.4.0",
|
"wickra-linux-arm64-gnu": "0.4.1",
|
||||||
"wickra-linux-x64-gnu": "0.4.0",
|
"wickra-linux-x64-gnu": "0.4.1",
|
||||||
"wickra-win32-arm64-msvc": "0.4.0",
|
"wickra-win32-arm64-msvc": "0.4.1",
|
||||||
"wickra-win32-x64-msvc": "0.4.0"
|
"wickra-win32-x64-msvc": "0.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/cli": {
|
"node_modules/@napi-rs/cli": {
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-darwin-arm64": {
|
"node_modules/wickra-darwin-arm64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.1.tgz",
|
||||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-darwin-x64": {
|
"node_modules/wickra-darwin-x64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.1.tgz",
|
||||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-linux-arm64-gnu": {
|
"node_modules/wickra-linux-arm64-gnu": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.1.tgz",
|
||||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-linux-x64-gnu": {
|
"node_modules/wickra-linux-x64-gnu": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.1.tgz",
|
||||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-win32-arm64-msvc": {
|
"node_modules/wickra-win32-arm64-msvc": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.1.tgz",
|
||||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra-win32-x64-msvc": {
|
"node_modules/wickra-win32-x64-msvc": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.1.tgz",
|
||||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||||
"author": "kingchenc <support@wickra.org>",
|
"author": "kingchenc <support@wickra.org>",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -47,12 +47,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-linux-x64-gnu": "0.4.0",
|
"wickra-linux-x64-gnu": "0.4.1",
|
||||||
"wickra-linux-arm64-gnu": "0.4.0",
|
"wickra-linux-arm64-gnu": "0.4.1",
|
||||||
"wickra-darwin-x64": "0.4.0",
|
"wickra-darwin-x64": "0.4.1",
|
||||||
"wickra-darwin-arm64": "0.4.0",
|
"wickra-darwin-arm64": "0.4.1",
|
||||||
"wickra-win32-x64-msvc": "0.4.0",
|
"wickra-win32-x64-msvc": "0.4.1",
|
||||||
"wickra-win32-arm64-msvc": "0.4.0"
|
"wickra-win32-arm64-msvc": "0.4.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "napi build --platform --release",
|
"build": "napi build --platform --release",
|
||||||
|
|||||||
@@ -306,12 +306,271 @@ node_pair_indicator!(
|
|||||||
wc::PearsonCorrelation
|
wc::PearsonCorrelation
|
||||||
);
|
);
|
||||||
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
|
node_pair_indicator!(BetaNode, "Beta", wc::Beta);
|
||||||
|
node_pair_indicator!(PairwiseBetaNode, "PairwiseBeta", wc::PairwiseBeta);
|
||||||
node_pair_indicator!(
|
node_pair_indicator!(
|
||||||
SpearmanCorrelationNode,
|
SpearmanCorrelationNode,
|
||||||
"SpearmanCorrelation",
|
"SpearmanCorrelation",
|
||||||
wc::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 ==============================
|
||||||
|
|
||||||
/// MACD triple: macd line, signal line, histogram.
|
/// MACD triple: macd line, signal line, histogram.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ from ._wickra import (
|
|||||||
HurstExponent,
|
HurstExponent,
|
||||||
PearsonCorrelation,
|
PearsonCorrelation,
|
||||||
Beta,
|
Beta,
|
||||||
|
PairwiseBeta,
|
||||||
|
PairSpreadZScore,
|
||||||
|
LeadLagCrossCorrelation,
|
||||||
|
Cointegration,
|
||||||
|
RelativeStrengthAB,
|
||||||
SpearmanCorrelation,
|
SpearmanCorrelation,
|
||||||
# Ehlers / Cycle
|
# Ehlers / Cycle
|
||||||
SuperSmoother,
|
SuperSmoother,
|
||||||
@@ -393,6 +398,11 @@ __all__ = [
|
|||||||
"HurstExponent",
|
"HurstExponent",
|
||||||
"PearsonCorrelation",
|
"PearsonCorrelation",
|
||||||
"Beta",
|
"Beta",
|
||||||
|
"PairwiseBeta",
|
||||||
|
"PairSpreadZScore",
|
||||||
|
"LeadLagCrossCorrelation",
|
||||||
|
"Cointegration",
|
||||||
|
"RelativeStrengthAB",
|
||||||
"SpearmanCorrelation",
|
"SpearmanCorrelation",
|
||||||
# Ehlers / Cycle
|
# Ehlers / Cycle
|
||||||
"SuperSmoother",
|
"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 ==============================
|
// ============================== SpearmanCorrelation ==============================
|
||||||
|
|
||||||
#[pyclass(
|
#[pyclass(
|
||||||
@@ -12236,6 +12613,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<PyHurstExponent>()?;
|
m.add_class::<PyHurstExponent>()?;
|
||||||
m.add_class::<PyPearsonCorrelation>()?;
|
m.add_class::<PyPearsonCorrelation>()?;
|
||||||
m.add_class::<PyBeta>()?;
|
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::<PySpearmanCorrelation>()?;
|
||||||
m.add_class::<PyValueArea>()?;
|
m.add_class::<PyValueArea>()?;
|
||||||
m.add_class::<PyInitialBalance>()?;
|
m.add_class::<PyInitialBalance>()?;
|
||||||
|
|||||||
@@ -35,6 +35,72 @@ def test_unequal_length_candle_batch_raises(ohlc_series):
|
|||||||
ta.Aroon(14).batch(high, short)
|
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():
|
def test_roc_and_trix_have_default_periods():
|
||||||
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
|
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
|
||||||
assert ta.ROC().period == 10
|
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)
|
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():
|
def test_value_at_risk_known_window():
|
||||||
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
|
# 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)])
|
returns = np.array([i * 0.01 for i in range(-5, 5)])
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ PAIR = [
|
|||||||
(ta.TreynorRatio, (20, 0.0)),
|
(ta.TreynorRatio, (20, 0.0)),
|
||||||
(ta.InformationRatio, (20,)),
|
(ta.InformationRatio, (20,)),
|
||||||
(ta.Alpha, (20, 0.0)),
|
(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))
|
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 -------------------------------
|
# --- Candle-input, single-output indicators -------------------------------
|
||||||
#
|
#
|
||||||
# Each entry is (factory, batch-call). Streaming always feeds the full
|
# Each entry is (factory, batch-call). Streaming always feeds the full
|
||||||
|
|||||||
@@ -525,12 +525,229 @@ wasm_pair_indicator!(
|
|||||||
wc::PearsonCorrelation
|
wc::PearsonCorrelation
|
||||||
);
|
);
|
||||||
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
|
wasm_pair_indicator!(WasmBeta, "Beta", wc::Beta);
|
||||||
|
wasm_pair_indicator!(WasmPairwiseBeta, "PairwiseBeta", wc::PairwiseBeta);
|
||||||
wasm_pair_indicator!(
|
wasm_pair_indicator!(
|
||||||
WasmSpearmanCorrelation,
|
WasmSpearmanCorrelation,
|
||||||
"SpearmanCorrelation",
|
"SpearmanCorrelation",
|
||||||
wc::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) ----------
|
// ---------- KAMA (three params) ----------
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = KAMA)]
|
#[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 cmf;
|
||||||
mod cmo;
|
mod cmo;
|
||||||
mod coefficient_of_variation;
|
mod coefficient_of_variation;
|
||||||
|
mod cointegration;
|
||||||
mod conditional_value_at_risk;
|
mod conditional_value_at_risk;
|
||||||
mod connors_rsi;
|
mod connors_rsi;
|
||||||
mod coppock;
|
mod coppock;
|
||||||
@@ -99,6 +100,7 @@ mod kst;
|
|||||||
mod kurtosis;
|
mod kurtosis;
|
||||||
mod kvo;
|
mod kvo;
|
||||||
mod laguerre_rsi;
|
mod laguerre_rsi;
|
||||||
|
mod lead_lag_cross_correlation;
|
||||||
mod linreg;
|
mod linreg;
|
||||||
mod linreg_angle;
|
mod linreg_angle;
|
||||||
mod linreg_channel;
|
mod linreg_channel;
|
||||||
@@ -122,6 +124,8 @@ mod obv;
|
|||||||
mod omega_ratio;
|
mod omega_ratio;
|
||||||
mod opening_range;
|
mod opening_range;
|
||||||
mod pain_index;
|
mod pain_index;
|
||||||
|
mod pair_spread_zscore;
|
||||||
|
mod pairwise_beta;
|
||||||
mod parkinson;
|
mod parkinson;
|
||||||
mod pearson_correlation;
|
mod pearson_correlation;
|
||||||
mod percent_b;
|
mod percent_b;
|
||||||
@@ -135,6 +139,7 @@ mod psar;
|
|||||||
mod pvi;
|
mod pvi;
|
||||||
mod r_squared;
|
mod r_squared;
|
||||||
mod recovery_factor;
|
mod recovery_factor;
|
||||||
|
mod relative_strength_ab;
|
||||||
mod renko_trailing_stop;
|
mod renko_trailing_stop;
|
||||||
mod roc;
|
mod roc;
|
||||||
mod rogers_satchell;
|
mod rogers_satchell;
|
||||||
@@ -257,6 +262,7 @@ pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
|
|||||||
pub use cmf::ChaikinMoneyFlow;
|
pub use cmf::ChaikinMoneyFlow;
|
||||||
pub use cmo::Cmo;
|
pub use cmo::Cmo;
|
||||||
pub use coefficient_of_variation::CoefficientOfVariation;
|
pub use coefficient_of_variation::CoefficientOfVariation;
|
||||||
|
pub use cointegration::{Cointegration, CointegrationOutput};
|
||||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||||
pub use connors_rsi::ConnorsRsi;
|
pub use connors_rsi::ConnorsRsi;
|
||||||
pub use coppock::Coppock;
|
pub use coppock::Coppock;
|
||||||
@@ -313,6 +319,7 @@ pub use kst::{Kst, KstOutput};
|
|||||||
pub use kurtosis::Kurtosis;
|
pub use kurtosis::Kurtosis;
|
||||||
pub use kvo::Kvo;
|
pub use kvo::Kvo;
|
||||||
pub use laguerre_rsi::LaguerreRsi;
|
pub use laguerre_rsi::LaguerreRsi;
|
||||||
|
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
|
||||||
pub use linreg::LinearRegression;
|
pub use linreg::LinearRegression;
|
||||||
pub use linreg_angle::LinRegAngle;
|
pub use linreg_angle::LinRegAngle;
|
||||||
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
|
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
|
||||||
@@ -336,6 +343,8 @@ pub use obv::Obv;
|
|||||||
pub use omega_ratio::OmegaRatio;
|
pub use omega_ratio::OmegaRatio;
|
||||||
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
||||||
pub use pain_index::PainIndex;
|
pub use pain_index::PainIndex;
|
||||||
|
pub use pair_spread_zscore::PairSpreadZScore;
|
||||||
|
pub use pairwise_beta::PairwiseBeta;
|
||||||
pub use parkinson::ParkinsonVolatility;
|
pub use parkinson::ParkinsonVolatility;
|
||||||
pub use pearson_correlation::PearsonCorrelation;
|
pub use pearson_correlation::PearsonCorrelation;
|
||||||
pub use percent_b::PercentB;
|
pub use percent_b::PercentB;
|
||||||
@@ -349,6 +358,7 @@ pub use psar::Psar;
|
|||||||
pub use pvi::Pvi;
|
pub use pvi::Pvi;
|
||||||
pub use r_squared::RSquared;
|
pub use r_squared::RSquared;
|
||||||
pub use recovery_factor::RecoveryFactor;
|
pub use recovery_factor::RecoveryFactor;
|
||||||
|
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||||
pub use roc::Roc;
|
pub use roc::Roc;
|
||||||
pub use rogers_satchell::RogersSatchellVolatility;
|
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,
|
CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
|
||||||
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
|
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
|
||||||
CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler,
|
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
|
||||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji,
|
Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
|
||||||
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
|
DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput, DonchianStop,
|
||||||
DoubleBollingerOutput, Dpo, DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse,
|
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, DrawdownDuration,
|
||||||
Ema, EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots,
|
EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing,
|
||||||
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
|
Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, ForceIndex,
|
||||||
Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi,
|
FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility,
|
||||||
HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel,
|
Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle,
|
||||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio,
|
HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku,
|
||||||
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform,
|
IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||||
InvertedHammer, Jma, Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis,
|
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
|
||||||
Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
|
Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LeadLagCrossCorrelation,
|
||||||
|
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
|
||||||
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||||
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
|
MarketFacilitationIndex, Marubozu, MassIndex, MaxDrawdown, McGinleyDynamic,
|
||||||
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
|
MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, MorningEveningStar, Natr, Nvi, Obv, OmegaRatio,
|
||||||
OpeningRange, OpeningRangeOutput, PainIndex, ParkinsonVolatility, PearsonCorrelation, PercentB,
|
OpeningRange, OpeningRangeOutput, PainIndex, PairSpreadZScore, PairwiseBeta,
|
||||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared,
|
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
|
||||||
RecoveryFactor, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
|
PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, RSquared, RecoveryFactor,
|
||||||
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SineWave, Skewness, Sma,
|
RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility,
|
||||||
Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError, StandardErrorBands,
|
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
|
||||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||||
StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo,
|
StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc,
|
||||||
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure,
|
StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||||
TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput,
|
SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
|
||||||
TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
|
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
||||||
|
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
|
||||||
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
|
ThreeSoldiersOrCrows, Tii, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
|
||||||
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
|
TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
|
||||||
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
|
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
|
||||||
|
|||||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"../../bindings/node": {
|
"../../bindings/node": {
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@napi-rs/cli": "^2.18.0"
|
"@napi-rs/cli": "^2.18.0"
|
||||||
@@ -26,12 +26,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-darwin-arm64": "0.4.0",
|
"wickra-darwin-arm64": "0.4.1",
|
||||||
"wickra-darwin-x64": "0.4.0",
|
"wickra-darwin-x64": "0.4.1",
|
||||||
"wickra-linux-arm64-gnu": "0.4.0",
|
"wickra-linux-arm64-gnu": "0.4.1",
|
||||||
"wickra-linux-x64-gnu": "0.4.0",
|
"wickra-linux-x64-gnu": "0.4.1",
|
||||||
"wickra-win32-arm64-msvc": "0.4.0",
|
"wickra-win32-arm64-msvc": "0.4.1",
|
||||||
"wickra-win32-x64-msvc": "0.4.0"
|
"wickra-win32-x64-msvc": "0.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wickra": {
|
"node_modules/wickra": {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
//! panic.
|
//! panic.
|
||||||
|
|
||||||
use libfuzzer_sys::fuzz_target;
|
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)]
|
#[inline(never)]
|
||||||
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
|
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(|| TreynorRatio::new(10, 0.0).unwrap(), &pairs);
|
||||||
drive(|| InformationRatio::new(10).unwrap(), &pairs);
|
drive(|| InformationRatio::new(10).unwrap(), &pairs);
|
||||||
drive(|| Alpha::new(10, 0.0).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