Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01aeb965d1 | |||
| f1fed6cdd5 | |||
| 70e9cbb397 | |||
| 37e5e19b57 | |||
| c189075491 | |||
| 74dd82a867 | |||
| b654db312e | |||
| 88f119109d | |||
| 2945b47e1a | |||
| c212f91256 | |||
| 6c2ddf319f | |||
| 9db1ff8023 | |||
| fb6eae7fe2 | |||
| 0edb9f4857 | |||
| ea684e0d48 | |||
| 62ab84c472 | |||
| 0f2ff9c3c7 | |||
| e85334a2e9 | |||
| 4e3c41ea80 | |||
| 55284a3042 | |||
| 9b8e1346ed | |||
| 05fcdd9a5e | |||
| 5aa0949bce | |||
| b971e671b4 | |||
| 7a18a26daf | |||
| 4f9ed34884 | |||
| 7e1e988596 | |||
| f10b8c2e2d | |||
| 880a0e7430 | |||
| 6287bd48c1 | |||
| 54194a4ff8 | |||
| 3ea0f12b7a | |||
| d9d3ad18aa | |||
| 7f1a6df202 | |||
| 1ea05fb2a1 | |||
| 24e723fa7d | |||
| a39adb9dae | |||
| 1cd5d1d8da | |||
| 466faddd87 | |||
| 178fbfd68e | |||
| e30b3c6b35 | |||
| 070be2eb27 |
@@ -0,0 +1,34 @@
|
||||
# EditorConfig: https://editorconfig.org
|
||||
# Keeps indentation and line-endings consistent across IDEs.
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
|
||||
# Rust + Python + most config files use 4-space indents.
|
||||
[*.{rs,py,toml}]
|
||||
indent_size = 4
|
||||
|
||||
# JS / TS / JSON / YAML / Markdown use 2-space indents per ecosystem conventions.
|
||||
[*.{js,ts,jsx,tsx,json,yml,yaml,md}]
|
||||
indent_size = 2
|
||||
|
||||
# Markdown allows trailing whitespace as a hard line break — keep it intact.
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Makefiles must use tabs.
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
# Generated files are not authored by humans; leave them alone.
|
||||
[bindings/node/index.{js,d.ts}]
|
||||
indent_style = unset
|
||||
indent_size = unset
|
||||
trim_trailing_whitespace = unset
|
||||
insert_final_newline = unset
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
# The owner listed here is requested for review automatically on every pull
|
||||
# request. See https://docs.github.com/articles/about-code-owners.
|
||||
|
||||
* @kingchenc
|
||||
* @wickra-lib
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Funding sources surfaced on the repository "Sponsor" button.
|
||||
# Each platform's value is the username/handle on that platform.
|
||||
# Leave a key empty (e.g. patreon:) to skip a platform.
|
||||
|
||||
github: [kingchenc]
|
||||
custom: ["https://wickra.org/sponsor"]
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/kingchenc/wickra/security/advisories/new
|
||||
url: https://github.com/wickra-lib/wickra/security/advisories/new
|
||||
about: Report security issues privately — do not open a public issue.
|
||||
- name: Question or discussion
|
||||
url: https://github.com/kingchenc/wickra/discussions
|
||||
url: https://github.com/wickra-lib/wickra/discussions
|
||||
about: Ask usage questions and discuss ideas here.
|
||||
|
||||
@@ -44,7 +44,7 @@ ema/update time: [38.5 ns 38.7 ns 38.9 ns]
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | -------------------------------------- |
|
||||
| CPU | `e.g. Ryzen 9 7950X, AVX2 + AVX512` |
|
||||
| CPU | `e.g. Ryzen 9 9950X, AVX2 + AVX512` |
|
||||
| OS / arch | `e.g. Linux 6.8 x86_64` |
|
||||
| Toolchain | `rustc 1.x.y` |
|
||||
| Build flags | `RUSTFLAGS=...`, `--release`, profile |
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/kingchenc/wickra/wiki)
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
|
||||
and the `README.md` are updated (If applicable). Wiki edits go to a
|
||||
separate repository: `https://github.com/kingchenc/wickra.wiki.git`.
|
||||
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Audit that no file in the repo contains the pre-migration org slug or
|
||||
maintainer email. Driven by `repo-metadata.toml` at the repo root.
|
||||
|
||||
This is the read-only side of the metadata pipeline. It does not patch any
|
||||
files — it just fails CI when drift sneaks in. Pair with a future
|
||||
`--write` mode (auto-fix + signed commit on main) once the migration has
|
||||
settled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
METADATA_PATH = REPO_ROOT / "repo-metadata.toml"
|
||||
|
||||
|
||||
def load_metadata() -> dict:
|
||||
with METADATA_PATH.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def is_allowlisted(rel_path: str, allowlist: list[str]) -> bool:
|
||||
norm = rel_path.replace(os.sep, "/")
|
||||
for entry in allowlist:
|
||||
entry_norm = entry.replace(os.sep, "/")
|
||||
if entry_norm.endswith("/"):
|
||||
if norm.startswith(entry_norm):
|
||||
return True
|
||||
else:
|
||||
if norm == entry_norm:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def tracked_files() -> list[str]:
|
||||
"""List git-tracked files relative to the repo root."""
|
||||
out = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return [line for line in out.stdout.splitlines() if line]
|
||||
|
||||
|
||||
def scan(forbidden: list[str], allowlist: list[str]) -> list[tuple[str, int, str, str]]:
|
||||
"""Return a list of (rel_path, line_no, needle, line_text) findings.
|
||||
|
||||
Only git-tracked files are scanned, so local-only ghost-ignored files
|
||||
(`.claude/`, drafts) never trigger false positives.
|
||||
"""
|
||||
findings: list[tuple[str, int, str, str]] = []
|
||||
for rel_path in tracked_files():
|
||||
if is_allowlisted(rel_path, allowlist):
|
||||
continue
|
||||
abs_path = REPO_ROOT / rel_path
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
try:
|
||||
lines = abs_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
for lineno, line in enumerate(lines, start=1):
|
||||
for needle in forbidden:
|
||||
if needle in line:
|
||||
findings.append((rel_path, lineno, needle, line.strip()))
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true", help="audit-only (default)")
|
||||
args = parser.parse_args()
|
||||
_ = args # currently only --check is supported
|
||||
|
||||
meta = load_metadata()
|
||||
audit = meta.get("audit", {})
|
||||
forbidden: list[str] = list(audit.get("forbidden", []))
|
||||
allowlist: list[str] = list(audit.get("allowlist", []))
|
||||
|
||||
if not forbidden:
|
||||
print("repo-metadata.toml [audit].forbidden is empty — nothing to scan.")
|
||||
return 0
|
||||
|
||||
findings = scan(forbidden, allowlist)
|
||||
if findings:
|
||||
print(f"sync-metadata: {len(findings)} forbidden-substring hits:", file=sys.stderr)
|
||||
for rel_path, lineno, needle, text in findings:
|
||||
print(f" {rel_path}:{lineno}: matched {needle!r}", file=sys.stderr)
|
||||
print(f" {text}", file=sys.stderr)
|
||||
print(
|
||||
"\nUpdate the offending lines to use the values from repo-metadata.toml,",
|
||||
"or add the path to [audit].allowlist if the reference is intentional",
|
||||
"(e.g. historical CHANGELOG entries).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
org = meta["repo"]["org"]
|
||||
email = meta["maintainer"]["email"]
|
||||
print(f"sync-metadata: clean. org={org!r} email={email!r}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -55,6 +55,37 @@ jobs:
|
||||
# streaming.
|
||||
run: cargo build -p wickra-examples --bins
|
||||
|
||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||
# Ubuntu-only is sufficient: the lints are platform-independent.
|
||||
clippy-bindings:
|
||||
name: Clippy bindings
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
|
||||
# Verify the crates still build and test on their declared minimum supported
|
||||
# Rust version. The workspace pins rust-version = "1.86" — that floor is
|
||||
# set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
|
||||
@@ -110,7 +141,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
@@ -138,7 +169,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 # v2.0.19
|
||||
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
|
||||
with:
|
||||
command: check
|
||||
|
||||
@@ -171,7 +202,7 @@ jobs:
|
||||
# attributes the modern nightly compiler rejects, so the install
|
||||
# never gets off the ground. The prebuilt binary avoids the entire
|
||||
# transitive-dep compile.
|
||||
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
@@ -212,7 +243,27 @@ jobs:
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# setup-python downloads the interpreter from the Actions tool cache /
|
||||
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
||||
# Run it with continue-on-error, then retry once after a backoff so a
|
||||
# single CDN flake does not fail the whole job (see also: GitHub
|
||||
# Actions runner-images#7061).
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -229,7 +280,12 @@ jobs:
|
||||
- name: Install wheel
|
||||
shell: bash
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
# --no-index forces pip to ignore PyPI; --no-deps skips re-resolving
|
||||
# numpy (already installed in the previous step). Without --no-index
|
||||
# pip prefers the PyPI 0.2.x wheel over our freshly built one when
|
||||
# platform tags overlap (e.g. macOS arm64), so tests would run
|
||||
# against the released package and miss any new symbols the PR adds.
|
||||
run: python -m pip install --no-index --find-links dist --force-reinstall --no-deps wickra
|
||||
|
||||
- name: Run Python tests
|
||||
working-directory: bindings/python
|
||||
@@ -257,7 +313,7 @@ jobs:
|
||||
# same taiki-e prebuilt-binary installer we already use for
|
||||
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||
# release, which has `--features` as a top-level flag (since 0.12).
|
||||
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -290,7 +346,26 @@ jobs:
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
||||
# Windows runners with "Attempting to download 18..." followed by a
|
||||
# silent hang or curl error. Retry once after a backoff so a single
|
||||
# CDN flake does not fail the whole job.
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -79,6 +79,34 @@ jobs:
|
||||
name: crate-files
|
||||
path: target/package/*.crate
|
||||
|
||||
# CycloneDX SBOM per published crate. Attached to the GitHub Release
|
||||
# alongside the .crate / .whl / .tgz artefacts so downstream
|
||||
# consumers can audit the published dependency tree without
|
||||
# re-resolving Cargo.lock.
|
||||
- name: Install cargo-cyclonedx
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
|
||||
- name: Generate CycloneDX SBOMs
|
||||
run: |
|
||||
# cargo-cyclonedx walks the whole workspace in a single pass and
|
||||
# writes a <package>.cdx.json next to each member's Cargo.toml; it
|
||||
# has no -p/--package selector. Collect the three crates.io crates
|
||||
# (the .crate files published by this job) into the upload dir.
|
||||
cargo cyclonedx --format json --top-level
|
||||
mkdir -p sboms
|
||||
cp crates/wickra-core/wickra-core.cdx.json sboms/
|
||||
cp crates/wickra-data/wickra-data.cdx.json sboms/
|
||||
cp crates/wickra/wickra.cdx.json sboms/
|
||||
ls -lh sboms/
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sboms
|
||||
path: sboms/*.cdx.json
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PyPI: cross-platform wheels + sdist
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -173,15 +201,7 @@ jobs:
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.5.
|
||||
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
|
||||
# npm spam-detection filter for new accounts (same situation that
|
||||
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
|
||||
# unblocked it). A support ticket is open; once the new arm64 name
|
||||
# is unblocked this matrix entry will be restored alongside the
|
||||
# corresponding optionalDependencies / napi.triples / npm/<target>
|
||||
# entries in a follow-up release.
|
||||
# - { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -216,6 +236,14 @@ jobs:
|
||||
needs: node-build
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# `id-token: write` lets npm publish embed a Sigstore provenance
|
||||
# attestation generated from the GitHub Actions OIDC token. The npm
|
||||
# registry then shows a "Verified provenance" badge and lets
|
||||
# consumers verify the package was built from this exact workflow
|
||||
# run.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -274,13 +302,13 @@ jobs:
|
||||
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
|
||||
# the package being published — a malicious or stray script would
|
||||
# execute with the npm token in the environment).
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts)
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
local rc=$?
|
||||
echo "::endgroup::"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts)
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
rc=$?
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
@@ -321,12 +349,12 @@ jobs:
|
||||
# --ignore-scripts so any leftover prepublish hooks (which would
|
||||
# otherwise try to republish the already-published platform
|
||||
# subpackages) can't sabotage the main publish.
|
||||
npm publish --access public --ignore-scripts
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
npm publish --access public --ignore-scripts
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
fi
|
||||
exit $rc
|
||||
@@ -354,10 +382,18 @@ jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
|
||||
# --------------------------------------------------------------------------
|
||||
# Note: this job's npm publish call uses `--provenance` (see below),
|
||||
# which requires the `id-token: write` permission set at the job level.
|
||||
wasm-publish:
|
||||
name: Publish wickra-wasm to npm
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# `id-token: write` lets npm publish embed a Sigstore provenance
|
||||
# attestation generated from the GitHub Actions OIDC token (same
|
||||
# mechanism as the node-publish job above).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -373,7 +409,7 @@ jobs:
|
||||
- name: Install wasm-pack (latest, via prebuilt binary)
|
||||
# See the matching note in ci.yml: jetli's default installs an old
|
||||
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
@@ -392,10 +428,10 @@ jobs:
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
||||
pkg.author = 'kingchenc <kingchencp@gmail.com>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/kingchenc/wickra' };
|
||||
pkg.homepage = 'https://github.com/kingchenc/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/kingchenc/wickra/issues' };
|
||||
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||
pkg.license = 'PolyForm-Noncommercial-1.0.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
@@ -413,7 +449,7 @@ jobs:
|
||||
- name: Publish wickra-wasm to npm (idempotent)
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: |
|
||||
out=$(npm publish --access public 2>&1) && echo "$out" \
|
||||
out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
|
||||
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|
||||
|| (echo "$out"; exit 1))
|
||||
env:
|
||||
@@ -467,6 +503,8 @@ jobs:
|
||||
find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
|
||||
# Cargo .crate files (one per workspace member).
|
||||
find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
|
||||
# CycloneDX SBOMs (one per published crate).
|
||||
find artifacts -type f -name "*.cdx.json" -exec cp {} release-assets/ \;
|
||||
ls -lh release-assets/
|
||||
echo "asset-count=$(ls release-assets/ | wc -l)"
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
name: Sync indicator count
|
||||
|
||||
# Indicator count appears in four places that must stay in sync with
|
||||
# the number of public indicator types exported from
|
||||
# crates/wickra-core/src/lib.rs (the `pub use indicators::{ ... }` block,
|
||||
# minus the `FAMILIES` constant and any `*Output` companion structs):
|
||||
#
|
||||
# 1. README.md prose — synced on PR branches (this workflow)
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
|
||||
# — synced on push to main / v* tag
|
||||
# 4. site/index.md (local-only marketing site, not synced from CI)
|
||||
#
|
||||
# We count public types (not `mod xxx;` lines) because some modules export
|
||||
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
|
||||
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
|
||||
# single source of truth for what the bindings reach.
|
||||
#
|
||||
# Design: keep README in sync *before* a PR is merged, by pushing a
|
||||
# fix-up commit to the PR head branch. After squash-merge into main
|
||||
# the bot commit is folded into the single signed merge commit, so
|
||||
# main's history never shows an unsigned "sync indicator count" entry.
|
||||
#
|
||||
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
|
||||
# explicitly do NOT trigger downstream workflows (anti-recursion
|
||||
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
|
||||
# — it does, however, re-trigger sync-about.yml on the next PR
|
||||
# `synchronize` event, which is what we want (a no-op if the counter
|
||||
# is now correct).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# `contents: write` is needed so the workflow can push the counter
|
||||
# fix-up commit to the PR head branch via the auto-provided
|
||||
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
|
||||
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
|
||||
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# On PRs from forks the head ref lives in another repo; pushing
|
||||
# back to it from this workflow is blocked by GitHub. We still
|
||||
# want the PR to surface the missing counter, so the check below
|
||||
# falls back to a hard failure when push isn't possible.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# On PRs we check out the *head* commit (not the merge ref) so
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
|
||||
# push; the About / Wiki steps re-authenticate with the PAT
|
||||
# below where needed.
|
||||
|
||||
- name: Count indicators
|
||||
id: count
|
||||
run: |
|
||||
# Parse the `pub use indicators::{ ... }` block from lib.rs, strip
|
||||
# the FAMILIES constant and any `*Output` companion structs, count
|
||||
# the remaining identifiers. Pure-shell so the workflow doesn't
|
||||
# require a python runtime.
|
||||
n=$(sed -n '/^pub use indicators::{/,/^};/p' crates/wickra-core/src/lib.rs \
|
||||
| tr ',{}' '\n' \
|
||||
| sed 's/[[:space:]]//g' \
|
||||
| grep -E '^[A-Z][A-Za-z0-9_]*$' \
|
||||
| grep -vE '^FAMILIES$|Output$' \
|
||||
| sort -u | wc -l)
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "Indicator count: $n"
|
||||
|
||||
# ----- PR flow ---------------------------------------------------
|
||||
|
||||
- name: Check README counter (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_check
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if grep -qE "^${n} streaming-first indicators" README.md; then
|
||||
echo "matches=true" >> "$GITHUB_OUTPUT"
|
||||
echo "README counter already at ${n}; nothing to do."
|
||||
else
|
||||
echo "matches=false" >> "$GITHUB_OUTPUT"
|
||||
echo "README counter does not match ${n}; will fix up."
|
||||
fi
|
||||
|
||||
- name: Fix counter on fork PR head (read-only, fail loud)
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
echo "::error::README.md says a different indicator count than mod.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please update README.md to '${n} streaming-first indicators' and push again."
|
||||
exit 1
|
||||
|
||||
- name: Patch README on PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
|
||||
id: pr_patch
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
|
||||
if git diff --quiet; then
|
||||
echo "No README changes after sed (counter regex did not match anything); skipping push."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
|
||||
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
# After a PR squash-merges, this workflow runs again on the push
|
||||
# to main. README is already correct (it was fixed on the PR
|
||||
# branch before the merge); the only outward syncs left are the
|
||||
# GitHub About description (repo metadata, not a commit) and the
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
|
||||
- name: Update GitHub About description
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
current=$(gh repo view --json description -q .description)
|
||||
if [ "$current" = "$desc" ]; then
|
||||
echo "About unchanged."
|
||||
else
|
||||
gh repo edit --description "$desc"
|
||||
echo "About updated."
|
||||
fi
|
||||
|
||||
- name: Sync Wiki
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
|
||||
cd wiki
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md FAQ.md Streaming-vs-Batch.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
git push
|
||||
@@ -0,0 +1,22 @@
|
||||
name: sync-metadata
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: metadata audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Audit repo-metadata.toml drift
|
||||
run: python .github/scripts/sync-metadata.py --check
|
||||
+2
-1
@@ -46,7 +46,8 @@ tarpaulin-report.html
|
||||
bindings/node/*.node
|
||||
bindings/node/index.d.ts
|
||||
bindings/node/npm-debug.log*
|
||||
package-lock.json
|
||||
# package-lock.json is committed (under bindings/node/) so contributors
|
||||
# get reproducible npm installs. Top-level lockfiles still aren't expected.
|
||||
|
||||
# WASM build output
|
||||
bindings/wasm/pkg/
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
# Architecture
|
||||
|
||||
A walkthrough of how Wickra is organised internally — written for new
|
||||
contributors who want to know **where the code lives, why it's split that
|
||||
way, and which invariants they must not break**. Pair it with [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
for the day-to-day workflow.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
Wickra is a Cargo workspace of three Rust crates plus three binding crates.
|
||||
The split is deliberate: every concern that one user might want to disable
|
||||
or replace lives behind a separate crate boundary.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ wickra (facade) │
|
||||
│ re-exports wickra-core::* + wickra-data::* │
|
||||
└──────────────┬──────────────────────────────────┬──────────────────┘
|
||||
│ │
|
||||
┌───────────▼──────────┐ ┌──────────▼─────────┐
|
||||
│ wickra-core │ │ wickra-data │
|
||||
│ indicator engine │ │ i/o + aggregation │
|
||||
│ • 214 indicators │ │ • CSV reader │
|
||||
│ • Indicator trait │ │ • Tick aggregator │
|
||||
│ • BatchExt impl │ │ • Resampler │
|
||||
│ • OHLCV / Candle │ │ • Live feeds │
|
||||
│ no I/O, no deps │ │ optional features │
|
||||
└──────────────────────┘ └────────────────────┘
|
||||
▲
|
||||
│ (every binding wraps the same core)
|
||||
│
|
||||
┌────────────┴───────────┬─────────────────────┐
|
||||
│ │ │
|
||||
┌──▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
|
||||
│ Python │ │ Node │ │ WASM │
|
||||
│ (PyO3) │ │ (napi-rs) │ │ (wasm-bindgen) │
|
||||
└─────────┘ └──────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
| Crate | Path | What it owns | Public deps |
|
||||
|---|---|---|---|
|
||||
| `wickra-core` | `crates/wickra-core` | every indicator, the `Indicator` trait, `BatchExt`, `Candle`/`Tick` types, `Error` | `thiserror`, `rayon` (parallel batch) |
|
||||
| `wickra` | `crates/wickra` | thin facade — re-exports everything user-facing from `wickra-core` and `wickra-data` | both internal crates |
|
||||
| `wickra-data` | `crates/wickra-data` | CSV reader, tick aggregator, resampler, live exchange feeds (feature-gated) | `tokio`, `tokio-tungstenite` (live), `serde_json` |
|
||||
| `wickra-python` | `bindings/python` | `_wickra` PyO3 module + Python package | `pyo3`, `numpy`, depends on `wickra-core` |
|
||||
| `wickra-node` | `bindings/node` | NAPI-RS native binding | `napi`, depends on `wickra-core` |
|
||||
| `wickra-wasm` | `bindings/wasm` | WebAssembly binding | `wasm-bindgen`, depends on `wickra-core` |
|
||||
| `wickra-examples` | `examples/rust` | runnable binary examples | depends on `wickra`, `wickra-data` |
|
||||
|
||||
The `fuzz/` directory is **excluded** from the workspace (it has its own
|
||||
`Cargo.toml`) because the libfuzzer-sys harness requires a nightly
|
||||
toolchain, which would otherwise infect the stable workspace lints.
|
||||
|
||||
## The `Indicator` trait
|
||||
|
||||
Every indicator in Wickra implements one trait, defined in
|
||||
`crates/wickra-core/src/traits.rs`:
|
||||
|
||||
```rust
|
||||
pub trait Indicator {
|
||||
type Input;
|
||||
type Output;
|
||||
|
||||
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
|
||||
fn reset(&mut self);
|
||||
fn warmup_period(&self) -> usize;
|
||||
fn is_ready(&self) -> bool;
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
```
|
||||
|
||||
Four design choices that are non-negotiable:
|
||||
|
||||
1. **Streaming-first.** `update` is the only computation entry point. Each
|
||||
call must be O(1) amortised — no replays over history, no `clone`s of
|
||||
the input window unless absolutely necessary.
|
||||
2. **`Option<Output>` warmup.** A new indicator returns `None` until it has
|
||||
ingested `warmup_period()` inputs. After that it returns `Some(value)`
|
||||
on every call. The `None` → `Some` transition happens exactly once per
|
||||
`reset()`.
|
||||
3. **Reset is mandatory.** Calling `reset()` returns the indicator to the
|
||||
state of a newly constructed one. Tests verify this for every indicator.
|
||||
4. **No interior mutability across `update` calls.** Indicators may hold
|
||||
`VecDeque` / array state, but no `Cell`/`RefCell`/`Mutex` should be
|
||||
needed — `&mut self` is the only mutation channel.
|
||||
|
||||
### Batch is free
|
||||
|
||||
`BatchExt` is a blanket impl over `Indicator`:
|
||||
|
||||
```rust
|
||||
impl<I: Indicator> BatchExt for I {
|
||||
fn batch<'a>(&mut self, input: &'a [I::Input]) -> Vec<Option<I::Output>>
|
||||
where I::Input: Copy
|
||||
{
|
||||
input.iter().map(|x| self.update(*x)).collect()
|
||||
}
|
||||
fn batch_parallel(...) // rayon-based for multi-asset processing
|
||||
}
|
||||
```
|
||||
|
||||
Consequence: **every indicator gets batch and parallel-batch for free** as
|
||||
soon as `Indicator` is implemented. Tests verify `batch == streaming`
|
||||
equivalence on every indicator — this is the `batch_equals_streaming` test
|
||||
that appears in every indicator module.
|
||||
|
||||
## Indicator-module convention
|
||||
|
||||
Each indicator lives in its own file under
|
||||
`crates/wickra-core/src/indicators/`. Naming: snake-case of the struct,
|
||||
e.g. `Sma` → `sma.rs`, `MacdIndicator` → `macd.rs`.
|
||||
|
||||
Layout inside an indicator file is uniform:
|
||||
|
||||
```rust
|
||||
//! Doc-comment with the formula and one-line summary.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Public struct + rustdoc with mathematical definition + a runnable example.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Foo { /* state fields */ }
|
||||
|
||||
impl Foo {
|
||||
/// Constructor with parameter validation.
|
||||
pub fn new(period: usize, ...) -> Result<Self> { ... }
|
||||
/// Const accessors for configured params.
|
||||
pub const fn period(&self) -> usize { ... }
|
||||
}
|
||||
|
||||
impl Indicator for Foo {
|
||||
type Input = f64; // or (f64, f64), or Candle
|
||||
type Output = f64; // or FooOutput { ... }
|
||||
fn update(...) -> ... { ... }
|
||||
fn reset(...) { ... }
|
||||
fn warmup_period(...) -> usize { ... }
|
||||
fn is_ready(...) -> bool { ... }
|
||||
fn name(...) -> &'static str { "Foo" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// mandatory tests (every indicator):
|
||||
// - rejects_invalid_params
|
||||
// - accessors_and_metadata
|
||||
// - reference_value (vs TA-Lib / pandas-ta / hand-calculated)
|
||||
// - ignores_non_finite_input
|
||||
// - reset_clears_state
|
||||
// - batch_equals_streaming
|
||||
// plus indicator-specific edge cases
|
||||
}
|
||||
```
|
||||
|
||||
The `FAMILIES` constant in `mod.rs` (introduced in PR #60) is the
|
||||
machine-readable index of which family every indicator belongs to. It is
|
||||
the canonical taxonomy; README and Wiki tables should be derived from it.
|
||||
|
||||
## Input types
|
||||
|
||||
| Input | Used for | Examples |
|
||||
|---|---|---|
|
||||
| `f64` | Scalar inputs — usually a price or a return | SMA, EMA, RSI, ROC |
|
||||
| `Candle` | OHLCV bar — `{open, high, low, close, volume, timestamp}` | ATR, Bollinger, Ichimoku, all candlestick patterns |
|
||||
| `(f64, f64)` | Two-series indicators — `(asset, benchmark)` or `(x, y)` | PearsonCorrelation, Beta, Alpha, TreynorRatio |
|
||||
|
||||
The `Candle` type lives in `wickra-core::ohlcv` and is the binding
|
||||
contract across bindings — Python's `Candle` namedtuple, Node's
|
||||
`Candle` object, and WASM's `Candle` JS class all map 1:1.
|
||||
|
||||
## Output types
|
||||
|
||||
Most indicators emit `f64`. Multi-output indicators emit a dedicated
|
||||
struct in the same module, named `FooOutput`:
|
||||
|
||||
```rust
|
||||
pub struct BollingerOutput {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
```
|
||||
|
||||
Bindings flatten these into matrix outputs (NumPy 2-D array for Python,
|
||||
typed object arrays for Node/WASM).
|
||||
|
||||
## Numerical-stability notes
|
||||
|
||||
A handful of indicators need care beyond naive accumulation:
|
||||
|
||||
- **Welford's online variance** is used in `StdDev`, `Variance`, `ZScore`,
|
||||
`BollingerBands`, and several others. Standard sum-of-squares is
|
||||
catastrophically lossy for low-variance inputs; Welford's recurrence
|
||||
keeps O(eps) error.
|
||||
- **Kahan summation** is used wherever rolling sums could span > 1e6
|
||||
elements without resetting — currently only Hurst-exponent's R/S
|
||||
chunks. Most rolling sums are bounded by the window size and don't need
|
||||
it.
|
||||
- **Logarithm bases** matter for some indicators (Hurst, MFI). Wickra
|
||||
uses natural log everywhere unless the reference math explicitly
|
||||
requires `log10` or `log2` — and then it documents the choice in the
|
||||
rustdoc.
|
||||
- **NaN / infinity guards.** Every indicator's `update` rejects
|
||||
non-finite input early (returns `None` without state mutation). Tests
|
||||
cover this with `ignores_non_finite_input`.
|
||||
|
||||
## Cross-crate flow
|
||||
|
||||
A typical full-stack call sequence for a Python live-trading example:
|
||||
|
||||
```
|
||||
[ Python: live_trading.py ]
|
||||
│
|
||||
▼
|
||||
[ binance.AsyncClient WebSocket ] ──── wickra_data live feed ───┐
|
||||
│
|
||||
┌──────────────────┘
|
||||
▼
|
||||
[ Candle struct conversion ]
|
||||
│
|
||||
▼
|
||||
[ PyRsi.update(close) ]
|
||||
│
|
||||
wraps │
|
||||
▼
|
||||
[ wickra_core::Rsi::update(f64) ] <-- the only place math runs
|
||||
│
|
||||
▼
|
||||
[ Option<f64> -> Py<PyFloat> ]
|
||||
│
|
||||
▼
|
||||
[ Python user code ]
|
||||
```
|
||||
|
||||
The same call sequence happens identically for Node (via NAPI),
|
||||
WASM (via wasm-bindgen → JS), and Rust (no FFI overhead, just direct
|
||||
calls).
|
||||
|
||||
## What lives where — the navigation cheat sheet
|
||||
|
||||
| You want to … | Look in |
|
||||
|---|---|
|
||||
| add a new indicator | `crates/wickra-core/src/indicators/<name>.rs` + add to `mod.rs` + add to `FAMILIES` + re-export in `lib.rs` |
|
||||
| change the `Indicator` trait surface | `crates/wickra-core/src/traits.rs` — this affects every indicator, treat as breaking |
|
||||
| add a new Candle field | `crates/wickra-core/src/ohlcv.rs` — also propagates to every binding's `Candle` mapping |
|
||||
| add a new exchange / data source | `crates/wickra-data/src/live/<exchange>.rs`, feature-gated under `live-<exchange>` |
|
||||
| expose a new binding | new crate under `bindings/` + macro-driven boilerplate in `bindings/<lang>/src/lib.rs` |
|
||||
| change benchmark coverage | `crates/wickra/benches/indicators.rs` |
|
||||
| add a new fuzz target | `fuzz/fuzz_targets/<name>.rs` + register in `fuzz/Cargo.toml` |
|
||||
| change CI matrix | `.github/workflows/ci.yml` |
|
||||
| change release pipeline | `.github/workflows/release.yml` (irreversible on `v*` tag — test on a throwaway tag first) |
|
||||
|
||||
## What is **deliberately** not in this repo
|
||||
|
||||
- **Backtest framework.** Wickra is an indicator library, not a backtester.
|
||||
Strategy + PnL + fills logic is for the user (see `examples/` for
|
||||
illustrative scripts).
|
||||
- **Multi-exchange aggregation.** Binance is the demo feed; full
|
||||
exchange-agnostic aggregation is `ccxt`'s job. Wickra's
|
||||
`wickra-data::live` is intentionally minimal.
|
||||
- **Order-book / L2 data.** Wickra works on OHLCV bars and ticks, not
|
||||
full depth. Tick-data variants (cumulative delta, single print) are on
|
||||
the roadmap but require new input types.
|
||||
- **Charting / visualization.** Out of scope for the Rust core. The
|
||||
WASM examples include a `lightweight-charts` integration as a
|
||||
starting point, but no charting code lives in the published packages.
|
||||
- **GPU / SIMD optimisation.** Indicators are O(1) per update — the
|
||||
bottleneck is not vector throughput. SIMD would only help large-batch
|
||||
workloads, which already saturate memory bandwidth via the cache-
|
||||
friendly `VecDeque` window.
|
||||
|
||||
## Performance characteristics
|
||||
|
||||
Every indicator is amortised O(1) per `update`. The constant factor
|
||||
varies:
|
||||
|
||||
| Class | Indicators | Per-`update` cost (approx) |
|
||||
|---|---|---|
|
||||
| Simple rolling | SMA, EMA, WMA, Mom | 1-2 floating-point ops |
|
||||
| Recursive smoothers | KAMA, FRAMA, VIDYA, JMA | 5-15 ops |
|
||||
| Window-sort | OmegaRatio, percentile-based VaR | O(period · log period) per update |
|
||||
| Multi-buffer DSP | MAMA, HilbertDominantCycle, EmpiricalModeDecomposition | 30-80 ops |
|
||||
| Multi-component | MacdIndicator, TtmSqueeze, Alligator | sum of components |
|
||||
|
||||
Benchmarks against real BTCUSDT 1-minute data live in
|
||||
`crates/wickra/benches/indicators.rs`. Cross-library comparison vs
|
||||
TA-Lib / pandas-ta / talipp / finta lives in
|
||||
`bindings/python/benchmarks/compare_libraries.py`.
|
||||
|
||||
## Stability commitments
|
||||
|
||||
- **MSRV.** Workspace: Rust 1.86. Node binding: 1.88 (NAPI-RS pins it).
|
||||
- **`Indicator` trait surface.** Breaking changes here are major-version
|
||||
events. Adding a new method with a default impl is minor.
|
||||
- **Indicator removal.** Once an indicator ships in a release, it stays
|
||||
callable. Renames go through a deprecation period of at least one
|
||||
minor version.
|
||||
- **Output structs.** Adding a field to a `FooOutput` is non-breaking
|
||||
because the binding contracts go through serde and accept extra keys.
|
||||
|
||||
## Open questions / known sharp edges
|
||||
|
||||
These are documented for contributors so you don't waste time
|
||||
re-discovering them.
|
||||
|
||||
- **`Rvi`** (Relative Vigor Index) and `RviVolatility` (Relative
|
||||
Volatility Index) are different indicators with the same short
|
||||
acronym — make sure you import the right one.
|
||||
- **Fuzz coverage of pair indicators** uses `indicator_update_pair.rs`,
|
||||
which is small because pair indicators are simpler — but coverage
|
||||
should grow as more pair indicators land.
|
||||
- **`FAMILIES` (from PR #60) is hand-maintained.** Adding a new
|
||||
indicator requires a separate entry in `FAMILIES`. The
|
||||
`total_count_matches_expected` test will fail if you forget.
|
||||
- **WASM does not have automated tests yet.** Smoke-validated only
|
||||
through the manual examples. Adding `wasm-bindgen-test` coverage is
|
||||
on the roadmap.
|
||||
|
||||
For the high-level project goals see [`ROADMAP.md`](ROADMAP.md); for
|
||||
day-to-day contribution mechanics see [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
+493
-9
@@ -7,6 +7,486 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.1] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- **Release pipeline — CycloneDX SBOM generation.** `cargo-cyclonedx` has no
|
||||
`-p`/`--package` selector; it walks the whole workspace in a single pass.
|
||||
The `release.yml` SBOM step invoked it as `cargo cyclonedx … -p <crate>` and
|
||||
aborted with `error: unexpected argument '-p' found`, which failed the
|
||||
crates.io publish job *after* the crates were already published and skipped
|
||||
the GitHub Release attach-assets job (no release page, no SBOM artefacts).
|
||||
The step now runs a single workspace pass and collects the three crates.io
|
||||
crate SBOMs. No library changes relative to 0.3.0 — this patch republishes
|
||||
the same code with a working release pipeline.
|
||||
|
||||
## [0.3.0] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
|
||||
pragmatically as standard `Indicator`s rather than a separate
|
||||
`wickra-metrics` crate; the input is a scalar `f64` per bar (period return,
|
||||
equity sample, or trade P&L depending on the metric).
|
||||
- **Scalar `Indicator<f64>` — 14 metrics:** Sharpe Ratio, Sortino Ratio,
|
||||
Calmar Ratio, Omega Ratio, Max Drawdown (rolling), Average Drawdown,
|
||||
Drawdown Duration (time-under-water), Pain Index, Value at Risk
|
||||
(historical, linear-interpolated percentile), Conditional Value at Risk
|
||||
(Expected Shortfall), Profit Factor, Gain/Loss Ratio, Recovery Factor,
|
||||
Kelly Criterion.
|
||||
- **Two-series `Indicator<(f64, f64)>` — 3 metrics on `(asset_return,
|
||||
benchmark_return)` pairs:** Treynor Ratio, Information Ratio,
|
||||
Jensen's Alpha (CAPM).
|
||||
- **Candlestick patterns family (15 indicators).** A new "Candlestick
|
||||
Patterns" family covers the standard 1- to 3-bar reversal and
|
||||
continuation shapes: `Doji`, `Hammer`, `InvertedHammer`, `HangingMan`,
|
||||
`ShootingStar`, `Engulfing`, `Harami`, `MorningEveningStar`,
|
||||
`ThreeSoldiersOrCrows`, `PiercingDarkCloud`, `Marubozu`, `Tweezer`,
|
||||
`SpinningTop`, `ThreeInside` and `ThreeOutside`. Every detector takes a
|
||||
`Candle` and emits a signed `f64` (`+1.0` bullish, `-1.0` bearish, `0.0`
|
||||
no pattern; `Doji` is direction-less and emits `+1.0`/`0.0`). The MVP is
|
||||
a pattern-shape check only — no trend filter is applied. Available
|
||||
across Rust, Python, Node and WASM bindings. Harmonic and chart
|
||||
patterns remain out of scope and will follow once the pattern-detection
|
||||
framework (pivot detector + multi-bar state machines) lands.
|
||||
- **Market Profile family** (3 new indicators, opens family #9 across the
|
||||
catalogue):
|
||||
- `ValueArea(period, bin_count, value_area_pct)` — rolling
|
||||
bin-approximation volume profile over the last `period` candles.
|
||||
Outputs `{poc, vah, val}`: Point of Control is the bin with the highest
|
||||
cumulative volume; the Value Area expands symmetrically from POC and
|
||||
always absorbs the higher-volume neighbour next, until the configured
|
||||
percentage of total volume (default 70%) is enclosed. Each candle's
|
||||
volume is spread uniformly across its `[low, high]` range; single-print
|
||||
bars (`low == high`) drop their entire volume into one bin.
|
||||
- `InitialBalance(period)` — first-N-bar session high / low, frozen
|
||||
once `period` bars have been ingested. Outputs `{high, low}`. Default
|
||||
`period = 12` (one-hour IB on 5-minute bars for US equities). Callers
|
||||
MUST invoke `reset()` at every session boundary, otherwise the IB
|
||||
locks and stays fixed for the lifetime of the instance.
|
||||
- `OpeningRange(period)` — same lock-after-N-bars semantics as IB but
|
||||
with a smaller default window (`period = 6`, 30 min on 5-minute
|
||||
bars) and a third output `breakout_distance` = `close - or_mid`,
|
||||
signed (positive above the range, negative below).
|
||||
- Histogram-output Market Profile variants (Volume Profile / VPVR /
|
||||
Composite Profile) and tick-data-only variants (TPO / Single Print /
|
||||
Cumulative Delta / Order Flow Delta / Volume-Weighted Open) are
|
||||
deliberately out of scope of this PR: the former need a new
|
||||
histogram-output API layer, the latter need tick / L2 data which
|
||||
`wickra-data` does not yet expose.
|
||||
- **Family 12 — Statistik / Regression (13 indicators).** A complete
|
||||
statistical toolkit for analysing rolling price distributions and
|
||||
cross-series relationships. Every indicator ships in the Rust core
|
||||
plus all three bindings (Python, Node, WASM), with full streaming +
|
||||
batch parity, fuzz coverage, and benches against the BTCUSDT
|
||||
dataset:
|
||||
- **Variance** — rolling population variance (`StdDev` squared).
|
||||
- **CoefficientOfVariation** — `StdDev / Mean`, dimensionless dispersion.
|
||||
- **Skewness** — rolling third standardised moment (Pearson skewness).
|
||||
- **Kurtosis** — rolling excess kurtosis (fourth moment minus `3`).
|
||||
- **StandardError** — standard error of estimate for the rolling OLS
|
||||
fit, with `n − 2` residual degrees of freedom.
|
||||
- **DetrendedStdDev** — population standard deviation of OLS
|
||||
residuals (the StdDev that remains after subtracting the linear
|
||||
trend).
|
||||
- **RSquared** — coefficient of determination of the rolling OLS
|
||||
fit; the trend-quality filter.
|
||||
- **MedianAbsoluteDeviation** — robust dispersion measure that
|
||||
survives outliers (median of absolute deviations from the median).
|
||||
- **Autocorrelation** — rolling lag-`k` Pearson autocorrelation;
|
||||
detects periodicity and tests for white-noise behaviour.
|
||||
- **HurstExponent** — R/S-analysis estimator of trend-persistence
|
||||
vs. mean-reversion regime (`0.5` is random walk).
|
||||
- **PearsonCorrelation** — rolling correlation between two
|
||||
synchronised series; takes `(x, y)` pairs.
|
||||
- **Beta** — rolling OLS slope of an asset on a benchmark; the CAPM
|
||||
sensitivity coefficient.
|
||||
- **SpearmanCorrelation** — rolling rank correlation (monotone,
|
||||
outlier-robust analogue of Pearson).
|
||||
|
||||
Indicator count: 71 → 84.
|
||||
- **Family 13 — Ichimoku & alternative charts.** Two new indicators:
|
||||
- `Ichimoku` (Ichimoku Kinko Hyo) — the full five-line cloud system
|
||||
(Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span) with the
|
||||
classic `(9, 26, 52, 26)` defaults and configurable periods. Forward
|
||||
displacement is handled in a streaming ring buffer so the
|
||||
currently-visible Senkou A/B at bar *n* are the values computed
|
||||
from bar *n − displacement*.
|
||||
- `HeikinAshi` — the candle smoothing transform that recursively
|
||||
averages OHLC into a four-component output (`ha_open`, `ha_high`,
|
||||
`ha_low`, `ha_close`). Seeds `ha_open` from the first bar's
|
||||
`(open + close) / 2`.
|
||||
|
||||
Exposed in all four bindings (Rust, Python, Node, WASM). Renko,
|
||||
Kagi, and Point & Figure from the family ideas list are deferred:
|
||||
they are custom bar generators rather than indicators and belong in
|
||||
`wickra-data`.
|
||||
- **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new
|
||||
streaming-first indicators implementing John Ehlers'
|
||||
digital-signal-processing school of cycle analytics — a strong
|
||||
differentiation feature versus TA-Lib and pandas-ta, which only
|
||||
ship fragments of this catalogue:
|
||||
- **MAMA / FAMA** (MESA Adaptive Moving Average + Following
|
||||
Adaptive Moving Average) — phase-rate-adaptive smoothing pair
|
||||
from the 2001 MESA paper, exposed both jointly via `Mama` (multi-
|
||||
output) and as a scalar `Fama` wrapper.
|
||||
- **Fisher Transform** and **Inverse Fisher Transform** — Gaussian
|
||||
normalisation of price (Ehlers 2002) and its tanh-based bounded
|
||||
counterpart for oscillators.
|
||||
- **SuperSmoother**, **Roofing Filter**, **Decycler** and **Decycler
|
||||
Oscillator** — 2-pole Butterworth lowpass, bandpass and
|
||||
high-pass complement building blocks from *Cycle Analytics for
|
||||
Traders* (2013).
|
||||
- **Hilbert Dominant Cycle**, **Sine Wave** and **Adaptive Cycle**
|
||||
— Hilbert-transform-based period estimation from *Rocket Science
|
||||
for Traders* (2001).
|
||||
- **Center of Gravity**, **Cybernetic Cycle Component**,
|
||||
**Instantaneous Trendline**, **Ehlers Stochastic** and
|
||||
**Empirical Mode Decomposition** — EasyLanguage classics from
|
||||
Ehlers' published catalogue.
|
||||
- All sixteen are exposed across Rust, Python, Node.js and WASM
|
||||
bindings, fuzz-tested, benchmarked against real BTCUSDT
|
||||
1-minute data, and pass `batch == streaming` equivalence.
|
||||
- Indicator count rises from 71 to **87** across **nine** families.
|
||||
- **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar
|
||||
buy/sell setup counter with parameterised lookback and target), TD
|
||||
Sequential (Setup + Countdown phase machine emitting setup count,
|
||||
countdown count and active countdown direction), TD DeMarker
|
||||
(bounded [0, 1] range oscillator built from high/low expansions),
|
||||
TD REI (Range Expansion Index — bounded ±100 oscillator with the
|
||||
classic 5-bar default), TD Pressure (volume-weighted buying /
|
||||
selling pressure normalised to ±100), TD Combo (aggressive
|
||||
countdown variant with extra monotone-low / monotone-close
|
||||
strictness conditions on top of the classic countdown rule), TD
|
||||
Countdown (standalone 13-bar countdown phase machine emitting
|
||||
only the signed countdown count and direction — smaller streaming
|
||||
payload than the full TD Sequential), TD Lines (TDST horizontal
|
||||
support / resistance levels derived from the highs and lows of
|
||||
the most-recently-completed setup), TD Range Projection (next-bar
|
||||
high / low projection from the current bar's OHLC via DeMark's
|
||||
open-vs-close-weighted pivot), TD Differential (2-bar
|
||||
buying-pressure-vs-selling-pressure reversal pattern emitting
|
||||
+1 / -1 / 0), TD Open (gap-and-fade reversal pattern emitting
|
||||
+1 / -1 / 0 when the open prints outside the prior bar's range
|
||||
but the subsequent action recovers back into it), and TD Risk
|
||||
Level (protective stop levels derived from the lowest-low / highest-
|
||||
high setup bar's true range). All twelve are exposed through the
|
||||
Rust, Python, Node, and WASM bindings with `batch == streaming`
|
||||
equivalence tests, candle-stream fuzz coverage, and benchmark
|
||||
entries on the BTCUSDT 1-minute dataset.
|
||||
- **Family 08 — Pivots & Support/Resistance.** Seven new indicators land
|
||||
the previously empty pivot family: Classic (Floor-Trader) Pivot Points
|
||||
with three resistance and support tiers, Fibonacci Pivots spaced by
|
||||
0.382 / 0.618 / 1.000 of the prior range, Camarilla Pivots
|
||||
(Nick Stott's four-tier `(H − L) · 1.1 / {12, 6, 4, 2}` levels),
|
||||
Woodie Pivots with the close-weighted `PP = (H + L + 2·C) / 4`,
|
||||
DeMark Pivots whose conditional `X` depends on whether the bar closed
|
||||
up, down or flat, Williams Fractals as a five-bar swing detector and
|
||||
ZigZag as a percent-threshold swing tracker. Every level/swing is
|
||||
exposed across Rust, Python, Node and WASM with the standard
|
||||
`update` / `batch` / `reset` / `is_ready` / `warmup_period` surface
|
||||
and matching streaming-vs-batch and reference-value tests. The fuzz
|
||||
candle target now covers all seven.
|
||||
- **Family 09 — Trailing Stops, seven new indicators.** Rounds out the
|
||||
trailing-stop family from 5 to 12: `HiLoActivator` (Crabel's
|
||||
SMA-of-high / SMA-of-low trail), `VoltyStop` (Cynthia Kase's
|
||||
extreme-anchor ATR stop), `YoyoExit` (long-only ATR trail with a
|
||||
re-entry trigger), `DonchianStop` (the original Turtle exit, lowest
|
||||
low / highest high), `PercentageTrailingStop` (fixed-percent trail),
|
||||
`StepTrailingStop` (round-number grid trail) and `RenkoTrailingStop`
|
||||
(block-anchored Renko-style trail). All wired into the four bindings
|
||||
(Rust, Python, Node, WASM), the streaming + batch fuzz targets, and
|
||||
the bench harness.
|
||||
- **Klinger Volume Oscillator (KVO).** Stephen J. Klinger's trend-aware
|
||||
volume-force oscillator: `EMA(vf, fast) − EMA(vf, slow)` over a daily
|
||||
volume force scaled by cumulative-measurement ratio. Classic
|
||||
`(fast, slow) = (34, 55)` exposed via `Kvo::classic()`.
|
||||
- **Volume Oscillator (VO).** Percent difference between a fast and a
|
||||
slow SMA of bar volume: `100 · (SMA(vol, fast) − SMA(vol, slow)) /
|
||||
SMA(vol, slow)`. Default `(14, 28)`.
|
||||
- **Negative Volume Index (NVI).** Paul Dysart's cumulative index that
|
||||
only updates on volume-contraction bars (`volume_t < volume_{t−1}`),
|
||||
absorbing the percent close change on those quiet days. Fosback
|
||||
baseline `1000.0`, configurable via `Nvi::with_baseline`.
|
||||
- **Positive Volume Index (PVI).** The complementary index that
|
||||
updates on volume-expansion bars (`volume_t > volume_{t−1}`).
|
||||
- **Williams Accumulation/Distribution.** Larry Williams' volume-less
|
||||
cumulative flow that anchors to the previous close (true high/low) and
|
||||
classifies each bar as accumulation, distribution, or neutral by the
|
||||
sign of the close-to-close change.
|
||||
- **Anchored VWAP.** A cumulative VWAP whose accumulation begins at a
|
||||
user-chosen anchor bar rather than the session open. Re-anchor at
|
||||
runtime via `AnchoredVwap::set_anchor` for click-to-anchor trader
|
||||
workflows.
|
||||
- **Demand Index (Sibbet).** James Sibbet's smoothed buying-vs-selling
|
||||
pressure ratio in the streaming-friendly textbook form
|
||||
`EMA(volume · close-return · (1 + range/close), period)`.
|
||||
- **Time Segmented Volume (TSV).** Don Worden's rolling sum of signed
|
||||
volume weighted by the close-to-close move: a window-sum measure of
|
||||
net accumulation/distribution.
|
||||
- **Volume Zone Oscillator (VZO).** Walid Khalil's normalised
|
||||
volume-flow oscillator bounded in `[−100, 100]`, defined as
|
||||
`100 · EMA(signed_volume) / EMA(volume)`.
|
||||
- **Market Facilitation Index (Bill Williams).** Per-bar
|
||||
`(high − low) / volume` — how much price movement the market produces
|
||||
per unit of volume.
|
||||
- **ADXR (Average Directional Movement Index Rating)** in the Trend &
|
||||
Directional family. Wilder's directional-strength smoother: the
|
||||
average of the current `ADX` and the `ADX` from `period - 1` bars
|
||||
ago. Warmup is `3 * period - 1` (e.g. 41 for the default `period =
|
||||
14`). Shipped across all four bindings (Rust core, Python, Node,
|
||||
WASM) plus fuzz/test/bench coverage.
|
||||
- **Random Walk Index (RWI)** in the Trend & Directional family. Mike
|
||||
Poulos' trend-vs.-random-walk gauge: for each lookback `i ∈ [2,
|
||||
period]` the ratio of actual displacement to the random-walk
|
||||
expectation `ATR_i * sqrt(i)` is taken; the per-bar output is the
|
||||
maximum across lookbacks for both the high (`RWI_High`) and low
|
||||
(`RWI_Low`) directions. Multi-output `(high, low)` across all four
|
||||
bindings; warmup `= period`.
|
||||
- **Trend Intensity Index (TII)** in the Trend & Directional family.
|
||||
M.H. Pee's `[0, 100]` oscillator: the share of the most recent
|
||||
`dev_period` SMA-deviations that are positive, scaled to
|
||||
`[0, 100]`. Saturates at 100 on a pure uptrend, at 0 on a pure
|
||||
downtrend, and returns the neutral 50 on a perfectly flat market.
|
||||
Canonical Python defaults `(sma_period=60, dev_period=30)`; warmup
|
||||
`= sma_period + dev_period − 1`.
|
||||
- **Wave Trend Oscillator (LazyBear)** in the Trend & Directional
|
||||
family. Two-line mean-reverting momentum gauge built from the
|
||||
typical price and three cascaded EMAs:
|
||||
`esa = EMA(ap, channel)`, `d = EMA(|ap − esa|, channel)`,
|
||||
`ci = (ap − esa) / (0.015 · d)`, `wt1 = EMA(ci, average)`,
|
||||
`wt2 = SMA(wt1, signal)`. `WaveTrend::classic()` exposes the
|
||||
LazyBear defaults `(channel = 10, average = 21, signal = 4)`;
|
||||
warmup `= 2 · channel + average + signal − 3` (42 for the classic
|
||||
defaults). Includes a sub-ULP flat-tolerance guard on `ci` so a
|
||||
perfectly flat market reports `(0, 0)` instead of the
|
||||
mathematically indeterminate `−1 / 0.015 = −66.67`. Multi-output
|
||||
`(wt1, wt2)` across all four bindings.
|
||||
- **Family 05 — Bands & Channels (11 new indicators).** Eleven additional
|
||||
price-envelope overlays organised into the new "Bands & Channels"
|
||||
family, exposed across all four bindings (Rust, Python, Node, WASM):
|
||||
- `MaEnvelope` — SMA centerline with fixed-percent envelope (the oldest
|
||||
band overlay still in use).
|
||||
- `AccelerationBands` — Price Headley's momentum-biased bands that widen
|
||||
with the bar's relative range `(H − L) / (H + L)`.
|
||||
- `StarcBands` — Stoller Average Range Channel: SMA(close) ± k·ATR
|
||||
(Keltner's SMA-centerline sibling).
|
||||
- `AtrBands` — Close-anchored envelope of width `k · ATR`, the standard
|
||||
volatility-targeting stop/target band.
|
||||
- `HurstChannel` — SMA centerline wrapped by the rolling high-low range
|
||||
(Brian Millard / Hurst-cycle channel).
|
||||
- `LinRegChannel` — Linear-regression endpoint ± k·σ of the residuals,
|
||||
measuring dispersion about the *trend* rather than the mean.
|
||||
- `StandardErrorBands` — Linear regression with the OLS standard error
|
||||
(denominator `n − 2`) for prediction-interval bands.
|
||||
- `DoubleBollinger` — Kathy Lien's `±1σ` plus `±2σ` zone-partition setup.
|
||||
- `TtmSqueeze` — John Carter's BB-inside-KC squeeze flag paired with a
|
||||
detrended-close momentum reading.
|
||||
- `FractalChaosBands` — Bill Williams 5-bar fractal high/low envelope.
|
||||
- `VwapStdDevBands` — Cumulative VWAP with volume-weighted standard
|
||||
deviation bands.
|
||||
Indicator count rises from 71 to 82 across nine families; the README
|
||||
family table and the wiki overview/sidebar/warmup pages were updated to
|
||||
match.
|
||||
- **Yang-Zhang Volatility.** Yang & Zhang (2000) gold-standard OHLC
|
||||
estimator: a convex blend of overnight (close-to-open), open-to-close
|
||||
and Rogers-Satchell variances. The blending factor
|
||||
`k = 0.34 / (1.34 + (n+1)/(n-1))` is the one that minimises
|
||||
estimator variance under driftless GBM with overnight gaps. The
|
||||
overnight and open-to-close pieces use sample variance (Bessel's
|
||||
correction, divisor `n−1`), so the indicator needs `period + 1` bars
|
||||
to emit. Output annualised to a percent. Defaults: `period = 20`,
|
||||
`trading_periods = 252`. The recommended OHLC estimator for equities,
|
||||
futures, and any asset with material close-to-open gaps.
|
||||
- **Rogers-Satchell Volatility.** Drift-free OHLC realised-volatility
|
||||
estimator from Rogers, Satchell & Yoon (1994). Per-bar sample is
|
||||
`ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O)`; every term is non-negative by
|
||||
construction (high >= open, close; low <= open, close), so the
|
||||
rolling mean is exact, not biased, under arbitrary drift. The
|
||||
algebraic drift-cancellation is what differentiates it from
|
||||
Garman-Klass. Output annualised to a percent. Defaults:
|
||||
`period = 20`, `trading_periods = 252`.
|
||||
- **Garman-Klass Volatility.** Garman & Klass (1980) OHLC realised
|
||||
volatility estimator: per-bar sample is
|
||||
`0.5·(ln H/L)² − (2·ln2 − 1)·(ln C/O)²`, then take the annualised
|
||||
square root of the rolling mean. Roughly 7.4× more statistically
|
||||
efficient than close-to-close stddev under driftless GBM. Output
|
||||
annualised to a percent. Defaults: `period = 20`,
|
||||
`trading_periods = 252`.
|
||||
- **Parkinson Volatility.** Michael Parkinson's (1980) high-low realised
|
||||
volatility estimator: `sigma² = (1 / (4n·ln2)) · Σ (ln(H/L))²`. Output
|
||||
annualised to a percent in the same style as `HistoricalVolatility`
|
||||
(pass `trading_periods = 1` for the raw per-bar `sigma·100` figure).
|
||||
Roughly 5× more statistically efficient than close-to-close stddev
|
||||
under a driftless-GBM assumption. Defaults: `period = 20`,
|
||||
`trading_periods = 252`.
|
||||
- **RVIVolatility (Relative Volatility Index).** Donald Dorsey's
|
||||
RSI-shaped volatility gauge: partition the rolling standard
|
||||
deviation of close into "up" (close rose) and "down" (close fell)
|
||||
samples, Wilder-smooth each side, and compute
|
||||
`100 · AvgUp / (AvgUp + AvgDown)`. Bounded on `[0, 100]`; saturates
|
||||
at `100` in pure uptrends, `0` in pure downtrends, and falls back to
|
||||
`50` on a completely flat series (same undefined-RS convention as
|
||||
`RSI`). Single `period` parameter (default `10`) drives both the
|
||||
stddev window and the Wilder smoothing. Named `RVIVolatility` rather
|
||||
than plain `RVI` to disambiguate from Relative Vigor Index, which
|
||||
ships in Family 02 under the shorter `RVI` name.
|
||||
- **Family 03 — MACD & Price Oscillators.** `Stc` (Schaff Trend Cycle,
|
||||
Doug Schaff): doubly-`Stochastic`-smoothed MACD producing a bounded
|
||||
`[0, 100]` reading that reacts faster than `MACD` itself. Four
|
||||
parameters `(fast = 23, slow = 50, schaff_period = 10, factor = 0.5)`.
|
||||
Output is clamped to `[0, 100]` to absorb floating-point rounding.
|
||||
Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `ElderImpulse` (Alexander
|
||||
Elder's Impulse System): tri-state momentum gauge combining `EMA`
|
||||
trend slope with `MACD` histogram slope. Returns `+1` (green/buy)
|
||||
when both rise, `−1` (red/sell) when both fall, `0` (blue/neutral)
|
||||
on disagreement. Four parameters
|
||||
`(ema_period, macd_fast, macd_slow, macd_signal)`; defaults
|
||||
`(13, 12, 26, 9)` track *Come Into My Trading Room*. Exposed in all
|
||||
four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `ZeroLagMacd`: classic
|
||||
MACD topology with `ZLEMA` substituted for `EMA` everywhere — faster
|
||||
reaction to trend changes at the cost of slightly noisier readings.
|
||||
Multi-output `ZeroLagMacdOutput { macd, signal, histogram }`. Three
|
||||
parameters `(fast = 12, slow = 26, signal = 9)`; `fast` must be
|
||||
strictly less than `slow`. Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `CFO` (Chande Forecast
|
||||
Oscillator): `100 · (close − LinReg(close, period)) / close`. Positive
|
||||
when the close overshoots the linear forecast, negative when it
|
||||
undershoots. Holds the previous value if the close is zero. Default
|
||||
period 14. Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `AwesomeOscillatorHistogram`:
|
||||
`AO − SMA(AO, sma_period)`. A configurable variant of the existing
|
||||
`AcceleratorOscillator` (which fixes `(fast, slow, sma) = (5, 34, 5)`).
|
||||
Three parameters; defaults match Bill Williams' Accelerator. Exposed
|
||||
in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `APO` (Absolute Price
|
||||
Oscillator): `EMA(close, fast) − EMA(close, slow)`. Like MACD's line
|
||||
without the signal EMA. Default `(fast = 12, slow = 26)`. `fast` must
|
||||
be strictly less than `slow`. Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a
|
||||
`LinearRegression` smoothing of the `RVI` series — preserves trend
|
||||
direction while damping the underlying ratio. Candle input, two
|
||||
parameters `(rvi_period, linreg_period)` (defaults 14 / 20). Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `ConnorsRsi`: Larry Connors'
|
||||
3-component aggregate — `RSI(close)`, `RSI(streak)`, and the
|
||||
percentile rank of the 1-bar return over the recent `period_rank`
|
||||
returns. Bounded in `[0, 100]`. Three parameters
|
||||
`(period_rsi, period_streak, period_rank)` (defaults 3 / 2 / 100).
|
||||
Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `LaguerreRsi` (Ehlers):
|
||||
four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
|
||||
accumulator. Single parameter `gamma` in `[0, 1]` (default 0.5) trades
|
||||
lag for smoothness. State is seeded to the first input so a constant
|
||||
series stays at the neutral 50. Output clamped to `[0, 100]`. Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `SMI` (Stochastic Momentum
|
||||
Index, Blau): doubly-`EMA`-smoothed bounded oscillator measuring the
|
||||
close's displacement from the centre of the recent high-low range,
|
||||
scaled by the smoothed range. Candle input, three parameters
|
||||
`(period, d_period, d2_period)` (defaults 5 / 3 / 3). Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `KST` (Know Sure Thing, Pring):
|
||||
weighted sum of four `SMA`-smoothed `ROC` series with Pring's fixed
|
||||
weights `1, 2, 3, 4`, plus an `SMA` signal line. Nine parameters
|
||||
(four ROC periods, four SMA periods, signal period); `Kst::classic()`
|
||||
uses Pring's recommended defaults. Multi-output indicator emitting
|
||||
`KstOutput { kst, signal }`. Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `PGO` (Pretty Good Oscillator,
|
||||
Mark Johnson): `(close − SMA(close, period)) / EMA(TR, period)`.
|
||||
Candle input, single parameter `period` (default 14). Roughly counts
|
||||
how many ATR-equivalents the close is from its mean. Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `RVI` (Relative Vigor Index,
|
||||
Dorsey): per-bar ratio `SMA(close - open, period) / SMA(high - low,
|
||||
period)`. Candle input, single parameter `period` (default 10).
|
||||
Positive on average-bullish windows, negative on average-bearish.
|
||||
Holds previous value if the entire window has zero range. Exposed in
|
||||
all four bindings.
|
||||
- **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average):
|
||||
Gaussian-weighted moving average with configurable centre (`offset` in
|
||||
`[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults
|
||||
`(period = 9, offset = 0.85, sigma = 6.0)` available via `Alma::classic()`.
|
||||
Exposed in all four bindings (Rust, Python, Node, WASM).
|
||||
- **Family 01 — Moving Averages.** `EVWMA` (Elastic Volume-Weighted
|
||||
Moving Average, Fries 2001): an "elastic" recurrence whose smoothing
|
||||
weight is the bar's volume relative to the running window-volume.
|
||||
Candle input (uses close + volume), single parameter `period`
|
||||
(default 20). Holds its previous value if the entire window has zero
|
||||
volume. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `Alligator` (Bill Williams): three
|
||||
SMMA lines (Jaw / Teeth / Lips) of the median price `(high + low) / 2`
|
||||
with default periods 13 / 8 / 5. Multi-output indicator emitting
|
||||
`AlligatorOutput { jaw, teeth, lips }`. Visual chart shift is left to
|
||||
the consumer. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `JMA` (Jurik Moving Average):
|
||||
three-stage filter reconstruction of Mark Jurik's adaptive MA.
|
||||
Three parameters: `period` (14), `phase` in `[-100, 100]` (0), `power`
|
||||
in `1..=4` (2). State is seeded to the first input so a constant series
|
||||
is reproduced exactly. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `VIDYA` (Variable Index Dynamic
|
||||
Average, Chande 1992): EMA whose smoothing factor is scaled by the
|
||||
absolute Chande Momentum Oscillator. Two parameters `period` and
|
||||
`cmo_period` (defaults 14 / 9). Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `FRAMA` (Fractal Adaptive Moving
|
||||
Average, Ehlers 2005): adapts its smoothing constant to the fractal
|
||||
dimension of the recent window — fast in trends, slow in chop. Single
|
||||
parameter `period` (must be even, default 16). Exposed in all four
|
||||
bindings.
|
||||
- **Family 01 — Moving Averages.** `McGinleyDynamic`: John McGinley's
|
||||
self-adjusting MA. Single parameter `period`; the recurrence
|
||||
`MD + (price - MD) / (0.6 * period * (price / MD)^4)` speeds up when price
|
||||
falls below the indicator and damps when price runs above. Seeded with the
|
||||
simple average of the first `period` inputs. Exposed in all four bindings.
|
||||
|
||||
## [0.2.7] - 2026-05-24
|
||||
|
||||
### Added
|
||||
- **Windows ARM64 is back.** npm Support unblocked the
|
||||
`wickra-win32-arm64-msvc` sub-package name (same path
|
||||
`wickra-win32-x64-msvc` took through 0.1.4) and transferred write
|
||||
access to @kingchenc. 0.2.7 ships the binding for
|
||||
`aarch64-pc-windows-msvc` alongside the existing five platforms:
|
||||
the `napi.triples.additional` entry, the `optionalDependencies`
|
||||
pin, the `bindings/node/npm/win32-arm64-msvc/` sub-package and the
|
||||
`windows-11-arm` row of the release.yml node-build matrix are all
|
||||
restored from 8aa74cb. `npm install wickra` on Windows ARM64 now
|
||||
resolves to a native build instead of failing the loader's
|
||||
optional-dep lookup. PyPI's `win_arm64` wheel was unaffected and
|
||||
carries through as before.
|
||||
|
||||
### Changed
|
||||
- **Benchmark CPU renamed.** The "Reproduced on" line in every
|
||||
README listed an AMD Ryzen 9 7950X3D; the canonical machine is
|
||||
actually a Ryzen 9 9950X. Speedup ratios in the tables are
|
||||
unchanged (they're relative across libraries on the same machine),
|
||||
only the labelling is corrected. The performance-regression issue
|
||||
template's CPU example was updated for consistency.
|
||||
|
||||
## [0.2.6] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
- **docs.rs build.** Rust 1.92 removed the `doc_auto_cfg` feature gate
|
||||
and folded it back into `doc_cfg` (rust-lang/rust#138907). docs.rs
|
||||
builds against the latest nightly and sets `--cfg docsrs`, so every
|
||||
published 0.2.x failed with E0557 on the
|
||||
`#![cfg_attr(docsrs, feature(doc_auto_cfg))]` line at the top of
|
||||
`wickra`, `wickra-core`, and `wickra-data`. GitHub CI didn't see
|
||||
this — stable rustc never enables the `docsrs` cfg. The three
|
||||
library crates now gate on `doc_cfg` (same intent, same rendered
|
||||
output on docs.rs, builds again on nightly).
|
||||
|
||||
### Changed
|
||||
- **README — Wickra is now the top row of every comparison table.**
|
||||
The "Why Wickra exists" library matrix and the per-indicator
|
||||
benchmark tables previously placed Wickra at the bottom; a reader
|
||||
landing on the README is here to compare *against* Wickra, so the
|
||||
pivot row belongs at the top with a ★ marker. Same column data,
|
||||
same winner annotations — only row order changed. Mirrored across
|
||||
the umbrella README and every binding README so crates.io / PyPI /
|
||||
npm landing pages stay in sync.
|
||||
|
||||
## [0.2.5] - 2026-05-24
|
||||
|
||||
### Added
|
||||
@@ -352,12 +832,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.5...HEAD
|
||||
[0.2.5]: https://github.com/kingchenc/wickra/compare/v0.2.1...v0.2.5
|
||||
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/kingchenc/wickra/compare/v0.1.4...v0.2.0
|
||||
[0.1.4]: https://github.com/kingchenc/wickra/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/kingchenc/wickra/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/kingchenc/wickra/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/kingchenc/wickra/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/kingchenc/wickra/releases/tag/v0.1.0
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.3.1...HEAD
|
||||
[0.3.1]: https://github.com/wickra-lib/wickra/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/wickra-lib/wickra/compare/v0.2.7...v0.3.0
|
||||
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
|
||||
[0.2.1]: https://github.com/wickra-lib/wickra/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/wickra-lib/wickra/compare/v0.1.4...v0.2.0
|
||||
[0.1.4]: https://github.com/wickra-lib/wickra/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/wickra-lib/wickra/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/wickra-lib/wickra/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/wickra-lib/wickra/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/wickra-lib/wickra/releases/tag/v0.1.0
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
cff-version: 1.2.0
|
||||
title: Wickra
|
||||
message: >-
|
||||
If you use Wickra in academic work, please cite it using the metadata
|
||||
below.
|
||||
type: software
|
||||
authors:
|
||||
- alias: kingchenc
|
||||
email: wickra.lib@gmail.com
|
||||
repository-code: "https://github.com/wickra-lib/wickra"
|
||||
url: "https://wickra.org"
|
||||
abstract: >-
|
||||
Wickra is a streaming-first technical-analysis library implemented in
|
||||
Rust with bindings for Python, Node.js and WebAssembly. Each indicator
|
||||
is a state machine that updates in constant time per new input, so
|
||||
identical code paths serve live-trading workloads and historical
|
||||
back-testing. The library covers 214 indicators across 16 families
|
||||
(moving averages, momentum, volatility, volume, statistics, Ehlers
|
||||
digital-signal-processing cycles, pivots, DeMark, Ichimoku, candlestick
|
||||
patterns, market profile, and risk/performance metrics).
|
||||
keywords:
|
||||
- technical-analysis
|
||||
- technical-indicators
|
||||
- streaming
|
||||
- algorithmic-trading
|
||||
- quantitative-finance
|
||||
- rust
|
||||
- time-series
|
||||
license: PolyForm-Noncommercial-1.0.0
|
||||
+1
-1
@@ -33,7 +33,7 @@ project in public spaces.
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||
at **kingchencp@gmail.com**. All reports will be reviewed and investigated
|
||||
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
|
||||
promptly and fairly, and the maintainer will respect the privacy and security
|
||||
of the reporter.
|
||||
|
||||
|
||||
+2
-2
@@ -76,9 +76,9 @@ wasm-pack test --node bindings/wasm
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
- **Docs.** Update the relevant page on the
|
||||
[project Wiki](https://github.com/kingchenc/wickra/wiki) and the
|
||||
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
|
||||
`README.md` when behaviour or the public API changes. The Wiki lives in
|
||||
a separate git repository: `https://github.com/kingchenc/wickra.wiki.git`.
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+5
-5
@@ -12,19 +12,19 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.5"
|
||||
authors = ["kingchenc <kingchencp@gmail.com>"]
|
||||
version = "0.3.1"
|
||||
authors = ["kingchenc <wickra.lib@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
repository = "https://github.com/kingchenc/wickra"
|
||||
homepage = "https://github.com/kingchenc/wickra"
|
||||
repository = "https://github.com/wickra-lib/wickra"
|
||||
homepage = "https://github.com/wickra-lib/wickra"
|
||||
readme = "README.md"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.2.5" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.3.1" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -35,7 +35,7 @@ URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
@@ -133,4 +133,4 @@ of your licenses.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/kingchenc/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -36,16 +36,16 @@ for price in live_feed:
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
@@ -58,7 +58,7 @@ depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
@@ -76,7 +76,7 @@ to recompute on every tick.
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -195,7 +203,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra-core/ core engine + all 214 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -300,14 +308,14 @@ The library is provided **as is**, without warranty of any kind; see
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+2
-2
@@ -16,9 +16,9 @@ version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/kingchenc/wickra/security/advisories/new)
|
||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||
("Report a vulnerability" under the repository's *Security* tab), or
|
||||
- email to **kingchencp@gmail.com** with a subject line starting with
|
||||
- email to **wickra.lib@gmail.com** with a subject line starting with
|
||||
`[wickra security]`.
|
||||
|
||||
Please include:
|
||||
|
||||
+39
-31
@@ -1,7 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/kingchenc/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -36,16 +36,16 @@ for price in live_feed:
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
@@ -58,7 +58,7 @@ depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
@@ -76,7 +76,7 @@ to recompute on every tick.
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const open = close.map((c) => c - 0.5);
|
||||
|
||||
function eq(a, b) {
|
||||
if (Number.isNaN(a)) return Number.isNaN(b);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,11 @@ const scalarFactories = {
|
||||
ROC: () => new wickra.ROC(12),
|
||||
TRIX: () => new wickra.TRIX(9),
|
||||
KAMA: () => new wickra.KAMA(10, 2, 30),
|
||||
ALMA: () => new wickra.ALMA(9, 0.85, 6.0),
|
||||
McGinleyDynamic: () => new wickra.McGinleyDynamic(10),
|
||||
FRAMA: () => new wickra.FRAMA(16),
|
||||
VIDYA: () => new wickra.VIDYA(14, 9),
|
||||
JMA: () => new wickra.JMA(14, 0, 2),
|
||||
SMMA: () => new wickra.SMMA(14),
|
||||
TRIMA: () => new wickra.TRIMA(20),
|
||||
ZLEMA: () => new wickra.ZLEMA(14),
|
||||
@@ -45,8 +51,13 @@ const scalarFactories = {
|
||||
CMO: () => new wickra.CMO(14),
|
||||
TSI: () => new wickra.TSI(25, 13),
|
||||
PMO: () => new wickra.PMO(35, 20),
|
||||
TII: () => new wickra.TII(20, 10),
|
||||
StochRSI: () => new wickra.StochRSI(14, 14),
|
||||
PPO: () => new wickra.PPO(12, 26),
|
||||
APO: () => new wickra.APO(12, 26),
|
||||
CFO: () => new wickra.CFO(14),
|
||||
ElderImpulse: () => new wickra.ElderImpulse(13, 12, 26, 9),
|
||||
STC: () => new wickra.STC(23, 50, 10, 0.5),
|
||||
DPO: () => new wickra.DPO(20),
|
||||
Coppock: () => new wickra.Coppock(14, 11, 10),
|
||||
StdDev: () => new wickra.StdDev(20),
|
||||
@@ -59,8 +70,79 @@ const scalarFactories = {
|
||||
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
|
||||
ZScore: () => new wickra.ZScore(20),
|
||||
LinRegAngle: () => new wickra.LinRegAngle(14),
|
||||
PercentageTrailingStop: () => new wickra.PercentageTrailingStop(5),
|
||||
StepTrailingStop: () => new wickra.StepTrailingStop(1),
|
||||
RenkoTrailingStop: () => new wickra.RenkoTrailingStop(1),
|
||||
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
|
||||
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
|
||||
RVIVolatility: () => new wickra.RVIVolatility(10),
|
||||
// Family 10 — Ehlers / Cycle
|
||||
SuperSmoother: () => new wickra.SuperSmoother(10),
|
||||
FisherTransform: () => new wickra.FisherTransform(10),
|
||||
InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0),
|
||||
Decycler: () => new wickra.Decycler(20),
|
||||
DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30),
|
||||
RoofingFilter: () => new wickra.RoofingFilter(10, 48),
|
||||
CenterOfGravity: () => new wickra.CenterOfGravity(10),
|
||||
CyberneticCycle: () => new wickra.CyberneticCycle(10),
|
||||
InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20),
|
||||
EhlersStochastic: () => new wickra.EhlersStochastic(20),
|
||||
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
|
||||
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
|
||||
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
|
||||
SineWave: () => new wickra.SineWave(),
|
||||
FAMA: () => new wickra.FAMA(0.5, 0.05),
|
||||
// Family 12 — Statistik / Regression
|
||||
Variance: () => new wickra.Variance(20),
|
||||
CoefficientOfVariation: () => new wickra.CoefficientOfVariation(20),
|
||||
Skewness: () => new wickra.Skewness(20),
|
||||
Kurtosis: () => new wickra.Kurtosis(20),
|
||||
StandardError: () => new wickra.StandardError(14),
|
||||
DetrendedStdDev: () => new wickra.DetrendedStdDev(14),
|
||||
RSquared: () => new wickra.RSquared(14),
|
||||
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
|
||||
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
|
||||
HurstExponent: () => new wickra.HurstExponent(40, 4),
|
||||
// Family 15 — Risk / Performance metrics (scalar f64 input).
|
||||
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
|
||||
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
|
||||
CalmarRatio: () => new wickra.CalmarRatio(20),
|
||||
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
|
||||
MaxDrawdown: () => new wickra.MaxDrawdown(20),
|
||||
AverageDrawdown: () => new wickra.AverageDrawdown(20),
|
||||
DrawdownDuration: () => new wickra.DrawdownDuration(),
|
||||
PainIndex: () => new wickra.PainIndex(20),
|
||||
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
|
||||
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
|
||||
ProfitFactor: () => new wickra.ProfitFactor(20),
|
||||
GainLossRatio: () => new wickra.GainLossRatio(20),
|
||||
RecoveryFactor: () => new wickra.RecoveryFactor(),
|
||||
KellyCriterion: () => new wickra.KellyCriterion(20),
|
||||
};
|
||||
|
||||
// --- Two-series (asset, benchmark) ratio indicators ---
|
||||
|
||||
const ratioPairFactories = {
|
||||
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
|
||||
InformationRatio: () => new wickra.InformationRatio(20),
|
||||
Alpha: () => new wickra.Alpha(20, 0),
|
||||
};
|
||||
|
||||
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
|
||||
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
|
||||
|
||||
for (const [name, make] of Object.entries(ratioPairFactories)) {
|
||||
test(`${name}: streaming update matches batch (pair)`, () => {
|
||||
const batch = make().batch(asset, bench);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(asset[i], bench[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, make] of Object.entries(scalarFactories)) {
|
||||
test(`${name}: streaming update matches batch`, () => {
|
||||
const batch = make().batch(close);
|
||||
@@ -86,6 +168,11 @@ const candleScalar = {
|
||||
AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
EVWMA: { make: () => new wickra.EVWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
NATR: { make: () => new wickra.NATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
@@ -96,15 +183,58 @@ const candleScalar = {
|
||||
ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
KVO: { make: () => new wickra.KVO(34, 55), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) },
|
||||
NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HiLoActivator: { make: () => new wickra.HiLoActivator(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VoltyStop: { make: () => new wickra.VoltyStop(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
YoyoExit: { make: () => new wickra.YoyoExit(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AwesomeOscillatorHistogram: { make: () => new wickra.AwesomeOscillatorHistogram(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ADXR: { make: () => new wickra.ADXR(7), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDSetup: { make: () => new wickra.TDSetup(4, 9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDDeMarker: { make: () => new wickra.TDDeMarker(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
TDREI: { make: () => new wickra.TDREI(5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
TDPressure: { make: () => new wickra.TDPressure(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(open, high, low, close, volume) },
|
||||
TDCombo: { make: () => new wickra.TDCombo(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCountdown: { make: () => new wickra.TDCountdown(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDDifferential: { make: () => new wickra.TDDifferential(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDOpen: { make: () => new wickra.TDOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
// Family 14 — Candlestick patterns
|
||||
Doji: { make: () => new wickra.Doji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Hammer: { make: () => new wickra.Hammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
InvertedHammer: { make: () => new wickra.InvertedHammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HangingMan: { make: () => new wickra.HangingMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ShootingStar: { make: () => new wickra.ShootingStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Engulfing: { make: () => new wickra.Engulfing(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Harami: { make: () => new wickra.Harami(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
MorningEveningStar: { make: () => new wickra.MorningEveningStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeSoldiersOrCrows: { make: () => new wickra.ThreeSoldiersOrCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
PiercingDarkCloud: { make: () => new wickra.PiercingDarkCloud(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Marubozu: { make: () => new wickra.Marubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Tweezer: { make: () => new wickra.Tweezer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
SpinningTop: { make: () => new wickra.SpinningTop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeInside: { make: () => new wickra.ThreeInside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeOutside: { make: () => new wickra.ThreeOutside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -122,7 +252,11 @@ for (const [name, d] of Object.entries(candleScalar)) {
|
||||
// --- Multi-output indicators: object update vs interleaved batch ---
|
||||
|
||||
const multi = {
|
||||
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
@@ -130,9 +264,46 @@ const multi = {
|
||||
Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
RWI: { make: () => new wickra.RWI(14), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WaveTrend: { make: () => wickra.WaveTrend.classic(), fields: ['wt1', 'wt2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
// Family 16: Market Profile
|
||||
ValueArea: { make: () => new wickra.ValueArea(20, 50, 0.70), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
InitialBalance: { make: () => new wickra.InitialBalance(12), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
OpeningRange: { make: () => new wickra.OpeningRange(6), fields: ['high', 'low', 'breakoutDistance'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
// Family 05: bands & channels
|
||||
MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
StarcBands: { make: () => new wickra.StarcBands(6, 15, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AtrBands: { make: () => new wickra.AtrBands(14, 3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HurstChannel: { make: () => new wickra.HurstChannel(10, 0.5), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
LinRegChannel: { make: () => new wickra.LinRegChannel(20, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
StandardErrorBands: { make: () => new wickra.StandardErrorBands(21, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
DoubleBollinger: { make: () => new wickra.DoubleBollinger(20, 1, 2), fields: ['upperOuter', 'upperInner', 'middle', 'lowerInner', 'lowerOuter'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
TtmSqueeze: { make: () => new wickra.TtmSqueeze(20, 2, 1.5), fields: ['squeeze', 'momentum'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FractalChaosBands: { make: () => new wickra.FractalChaosBands(2), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VwapStdDevBands: { make: () => new wickra.VwapStdDevBands(2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
// Family 08: Pivots & Support/Resistance
|
||||
ClassicPivots: { make: () => new wickra.ClassicPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FibonacciPivots: { make: () => new wickra.FibonacciPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Camarilla: { make: () => new wickra.Camarilla(), fields: ['pp', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WoodiePivots: { make: () => new wickra.WoodiePivots(), fields: ['pp', 'r1', 'r2', 's1', 's2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
// Family 11: DeMark
|
||||
TDSequential: { make: () => new wickra.TDSequential(4, 9, 2, 13), fields: ['setup', 'countdown', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
// Family 10: Ehlers / Cycle (multi-output)
|
||||
MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
// Family 13: Ichimoku & alternative charts
|
||||
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -258,3 +429,389 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => {
|
||||
const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]);
|
||||
assert.ok(Math.abs(out[4] - 45) < 1e-9);
|
||||
});
|
||||
|
||||
test('InitialBalance(2) locks after period and ignores subsequent bars', () => {
|
||||
const ib = new wickra.InitialBalance(2);
|
||||
let v = ib.update(102, 100);
|
||||
assert.equal(v.high, 102);
|
||||
assert.equal(v.low, 100);
|
||||
v = ib.update(103, 99);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 99);
|
||||
assert.equal(ib.isLocked(), true);
|
||||
// Extreme bar after lock must not modify the IB.
|
||||
v = ib.update(200, 50);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 99);
|
||||
});
|
||||
|
||||
test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
|
||||
const or = new wickra.OpeningRange(2);
|
||||
or.update(102, 100, 101);
|
||||
or.update(103, 101, 102);
|
||||
// OR locked at high 103 / low 100 / mid 101.5. Close 105 -> +3.5.
|
||||
const v = or.update(110, 102, 105);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 100);
|
||||
assert.ok(Math.abs(v.breakoutDistance - 3.5) < 1e-9);
|
||||
});
|
||||
|
||||
// --- Family 12: two-series indicators (Pearson / Beta / Spearman) ---
|
||||
|
||||
const pairFactories = {
|
||||
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
||||
Beta: () => new wickra.Beta(14),
|
||||
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
test(`${name}: streaming update matches batch over a pair of series`, () => {
|
||||
const xs = Array.from({ length: N }, (_, i) => Math.sin(i * 0.2) + 0.05 * i);
|
||||
const ys = Array.from({ length: N }, (_, i) => Math.cos(i * 0.3) + 0.02 * i);
|
||||
const batch = make().batch(xs, ys);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(xs[i], ys[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('PearsonCorrelation perfect positive is 1', () => {
|
||||
const x = Array.from({ length: 10 }, (_, i) => i);
|
||||
const y = x.map((v) => 2 * v + 3);
|
||||
const out = new wickra.PearsonCorrelation(5).batch(x, y);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('Beta perfect two-to-one', () => {
|
||||
const bench = Array.from({ length: 10 }, (_, i) => i);
|
||||
const asset = bench.map((v) => 2 * v);
|
||||
const out = new wickra.Beta(5).batch(asset, bench);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('SpearmanCorrelation monotone non-linear is 1', () => {
|
||||
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
||||
const y = x.map((v) => v ** 3);
|
||||
const out = new wickra.SpearmanCorrelation(5).batch(x, y);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('Variance(3) of [2, 4, 6] equals 8/3', () => {
|
||||
const out = new wickra.Variance(3).batch([2, 4, 6]);
|
||||
assert.ok(Math.abs(out[2] - 8 / 3) < 1e-12);
|
||||
});
|
||||
|
||||
test('RSquared on a perfect line is 1', () => {
|
||||
const xs = Array.from({ length: 20 }, (_, i) => 2 * i + 5);
|
||||
const out = new wickra.RSquared(5).batch(xs);
|
||||
for (let i = 5; i < out.length; i++) {
|
||||
assert.ok(Math.abs(out[i] - 1) < 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('MedianAbsoluteDeviation ignores a single huge outlier', () => {
|
||||
const xs = Array(9).fill(5).concat([1000]);
|
||||
const out = new wickra.MedianAbsoluteDeviation(10).batch(xs);
|
||||
assert.ok(Math.abs(out[9]) < 1e-12);
|
||||
});
|
||||
|
||||
test('Autocorrelation of an alternating series is strongly negative at lag 1', () => {
|
||||
const xs = Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? -1 : 1));
|
||||
const out = new wickra.Autocorrelation(10, 1).batch(xs);
|
||||
assert.ok(out[out.length - 1] < -0.5);
|
||||
});
|
||||
|
||||
test('HurstExponent of a monotone ramp is above 0.5', () => {
|
||||
const xs = Array.from({ length: 200 }, (_, i) => i);
|
||||
const out = new wickra.HurstExponent(100, 4).batch(xs);
|
||||
assert.ok(out[out.length - 1] > 0.5);
|
||||
});
|
||||
|
||||
test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => {
|
||||
const ichi = new wickra.Ichimoku(9, 26, 52, 26);
|
||||
assert.equal(ichi.warmupPeriod(), 77);
|
||||
const n = 30;
|
||||
const h = Array.from({ length: n }, (_, i) => 100 + i + 2);
|
||||
const l = Array.from({ length: n }, (_, i) => 100 + i - 2);
|
||||
const c = Array.from({ length: n }, (_, i) => 100 + i + 1);
|
||||
const out = ichi.batch(h, l, c);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
assert.ok(Number.isNaN(out[i * 5]), `tenkan should be NaN at bar ${i}`);
|
||||
}
|
||||
assert.ok(!Number.isNaN(out[8 * 5]), 'tenkan should be defined at bar 9');
|
||||
});
|
||||
|
||||
test('HeikinAshi first bar seeds from real open and close', () => {
|
||||
const ha = new wickra.HeikinAshi();
|
||||
const out = ha.update(10, 12, 9, 11);
|
||||
assert.ok(Math.abs(out.open - (10 + 11) / 2) < 1e-12);
|
||||
assert.ok(Math.abs(out.close - (10 + 12 + 9 + 11) / 4) < 1e-12);
|
||||
});
|
||||
|
||||
test('PercentageTrailingStop seeds and ratchets', () => {
|
||||
const s = new wickra.PercentageTrailingStop(10);
|
||||
assert.ok(Math.abs(s.update(100) - 90) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(110) - 99) < 1e-9);
|
||||
});
|
||||
|
||||
test('RenkoTrailingStop only advances after a full block', () => {
|
||||
const s = new wickra.RenkoTrailingStop(1);
|
||||
assert.ok(Math.abs(s.update(100) - 99) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(100.5) - 99) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(101) - 100) < 1e-9);
|
||||
});
|
||||
|
||||
test('DonchianStop window extremes', () => {
|
||||
const out = new wickra.DonchianStop(5).batch([1, 2, 3, 4, 5], [0, 1, 2, 3, 4]);
|
||||
// [long0, short0, long1, short1, ...]: idx 8 (=4*2) = long_5th, idx 9 = short_5th.
|
||||
assert.ok(Math.abs(out[8] - 0) < 1e-9);
|
||||
assert.ok(Math.abs(out[9] - 5) < 1e-9);
|
||||
});
|
||||
|
||||
test('MaEnvelope reference values', () => {
|
||||
// SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18.
|
||||
const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[3]));
|
||||
assert.ok(Math.abs(out[2 * 3 + 0] - 22) < 1e-9); // upper
|
||||
assert.ok(Math.abs(out[2 * 3 + 1] - 20) < 1e-9); // middle
|
||||
assert.ok(Math.abs(out[2 * 3 + 2] - 18) < 1e-9); // lower
|
||||
});
|
||||
|
||||
test('AccelerationBands single-bar reference', () => {
|
||||
// high=12, low=8, close=10, factor=0.5, period=1.
|
||||
// ratio=0.2, raw_up=13.2, raw_lo=7.2.
|
||||
const v = new wickra.AccelerationBands(1, 0.5).update(12, 8, 10);
|
||||
assert.ok(Math.abs(v.upper - 13.2) < 1e-9);
|
||||
assert.ok(Math.abs(v.middle - 10) < 1e-9);
|
||||
assert.ok(Math.abs(v.lower - 7.2) < 1e-9);
|
||||
});
|
||||
|
||||
test('LinRegChannel reference values for [1, 2, 9]', () => {
|
||||
// Line y=4x, endpoint=8, residuals=[1,-2,1], sigma=sqrt(2).
|
||||
const out = new wickra.LinRegChannel(3, 2).batch([1, 2, 9]);
|
||||
const s = Math.sqrt(2);
|
||||
const i = 2;
|
||||
assert.ok(Math.abs(out[i * 3 + 0] - (8 + 2 * s)) < 1e-9);
|
||||
assert.ok(Math.abs(out[i * 3 + 1] - 8) < 1e-9);
|
||||
assert.ok(Math.abs(out[i * 3 + 2] - (8 - 2 * s)) < 1e-9);
|
||||
});
|
||||
|
||||
test('VwapStdDevBands two-bar reference', () => {
|
||||
const v = new wickra.VwapStdDevBands(1.5);
|
||||
v.update(8, 8, 8, 1);
|
||||
const o = v.update(12, 12, 12, 1);
|
||||
assert.ok(Math.abs(o.upper - 13) < 1e-9);
|
||||
assert.ok(Math.abs(o.middle - 10) < 1e-9);
|
||||
assert.ok(Math.abs(o.lower - 7) < 1e-9);
|
||||
assert.ok(Math.abs(o.stddev - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('RVIVolatility pure uptrend saturates at 100', () => {
|
||||
const prices = Array.from({ length: 40 }, (_, i) => i + 1);
|
||||
const out = new wickra.RVIVolatility(5).batch(prices);
|
||||
for (let i = 9; i < out.length; i++) {
|
||||
assert.ok(Math.abs(out[i] - 100) < 1e-9, `RVIVolatility[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ParkinsonVolatility zero-range bars yield zero', () => {
|
||||
const n = 30;
|
||||
const h = Array(n).fill(10);
|
||||
const l = Array(n).fill(10);
|
||||
const out = new wickra.ParkinsonVolatility(14, 252).batch(h, l);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `Parkinson[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('GarmanKlassVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.GarmanKlassVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `GK[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('RogersSatchellVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.RogersSatchellVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `RS[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('YangZhangVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.YangZhangVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 14; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `YZ[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ZeroLagMACD on a flat series converges to zero', () => {
|
||||
const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42));
|
||||
// Last interleaved row: macd, signal, histogram all 0.
|
||||
const n = 60;
|
||||
assert.ok(Math.abs(out[(n - 1) * 3]) < 1e-12);
|
||||
assert.ok(Math.abs(out[(n - 1) * 3 + 1]) < 1e-12);
|
||||
assert.ok(Math.abs(out[(n - 1) * 3 + 2]) < 1e-12);
|
||||
});
|
||||
|
||||
test('AwesomeOscillatorHistogram on a flat median converges to zero', () => {
|
||||
const n = 50;
|
||||
const out = new wickra.AwesomeOscillatorHistogram(3, 5, 3).batch(
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
);
|
||||
// warmup = 5 + 3 - 1 = 7.
|
||||
for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('STC on a flat series stays at zero', () => {
|
||||
const out = new wickra.STC(3, 5, 4, 0.5).batch(Array(60).fill(42));
|
||||
// Latest values must be exactly zero.
|
||||
for (let i = out.length - 5; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.equal(out[i], 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('ElderImpulse on a flat series stays neutral (0)', () => {
|
||||
const out = new wickra.ElderImpulse(13, 12, 26, 9).batch(Array(120).fill(42));
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.equal(out[i], 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('CFO(5) on a perfectly linear series yields zero', () => {
|
||||
const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2);
|
||||
const out = new wickra.CFO(5).batch(prices);
|
||||
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9);
|
||||
});
|
||||
|
||||
test('APO(3, 5) on a flat series converges to zero', () => {
|
||||
const out = new wickra.APO(3, 5).batch(Array(30).fill(42));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
|
||||
const n = 60;
|
||||
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
|
||||
const out = new wickra.Inertia(3, 4).batch(
|
||||
Array(n).fill(10),
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
Array(n).fill(10.5),
|
||||
);
|
||||
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
|
||||
});
|
||||
|
||||
test('ConnorsRSI stays bounded in [0, 100]', () => {
|
||||
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
|
||||
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
|
||||
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
|
||||
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
|
||||
});
|
||||
|
||||
test('SMI with close at range centre emits zero after warmup', () => {
|
||||
const n = 60;
|
||||
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
// warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('KST on a flat series emits zero after warmup', () => {
|
||||
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
|
||||
const n = 80;
|
||||
const out = kst.batch(Array(n).fill(42));
|
||||
const warmup = kst.warmupPeriod();
|
||||
for (let i = warmup - 1; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
|
||||
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PGO(5) on a flat close emits zero after warmup', () => {
|
||||
const n = 20;
|
||||
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
|
||||
});
|
||||
|
||||
test('RVI(2) reference value on two bars', () => {
|
||||
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
|
||||
});
|
||||
|
||||
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
|
||||
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
assert.ok(Math.abs(out[1] - 20) < 1e-12);
|
||||
assert.ok(Math.abs(out[2] - 22.5) < 1e-12);
|
||||
});
|
||||
|
||||
test('Alligator on a flat median price seeds to that median', () => {
|
||||
const n = 30;
|
||||
const out = new wickra.Alligator(13, 8, 5).batch(Array(n).fill(11), Array(n).fill(9));
|
||||
// All three SMMAs see median (11 + 9) / 2 = 10 every bar.
|
||||
for (let i = 12; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i * 3] - 10) < 1e-12, `jaw at ${i}: ${out[i * 3]}`);
|
||||
assert.ok(Math.abs(out[i * 3 + 1] - 10) < 1e-12);
|
||||
assert.ok(Math.abs(out[i * 3 + 2] - 10) < 1e-12);
|
||||
}
|
||||
});
|
||||
|
||||
test('JMA on a flat series reproduces the constant', () => {
|
||||
const out = new wickra.JMA(14, 0, 2).batch(Array(30).fill(42));
|
||||
for (let i = 0; i < 30; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
||||
});
|
||||
|
||||
test('VIDYA on a flat series holds the seed', () => {
|
||||
const out = new wickra.VIDYA(14, 4).batch(Array(20).fill(42));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
||||
});
|
||||
|
||||
test('FRAMA pure uptrend hugs the latest close', () => {
|
||||
const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05);
|
||||
});
|
||||
|
||||
test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => {
|
||||
// Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8.
|
||||
const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.ok(Math.abs(out[2] - 20) < 1e-12);
|
||||
const expected = 20 + 20 / (0.6 * 3 * 16);
|
||||
assert.ok(Math.abs(out[3] - expected) < 1e-12);
|
||||
});
|
||||
|
||||
test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
|
||||
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5));
|
||||
const s = w[0] + w[1] + w[2];
|
||||
const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s;
|
||||
assert.ok(Math.abs(out[2] - expected) < 1e-12);
|
||||
// The heavy offset toward the newest sample lifts the average above the
|
||||
// simple mean of 20.
|
||||
assert.ok(out[2] > 20);
|
||||
});
|
||||
|
||||
+144
-1
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
|
||||
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
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -338,6 +338,7 @@ module.exports.ATR = ATR
|
||||
module.exports.Stochastic = Stochastic
|
||||
module.exports.OBV = OBV
|
||||
module.exports.ADX = ADX
|
||||
module.exports.ADXR = ADXR
|
||||
module.exports.CCI = CCI
|
||||
module.exports.WilliamsR = WilliamsR
|
||||
module.exports.MFI = MFI
|
||||
@@ -349,19 +350,57 @@ module.exports.RollingVWAP = RollingVWAP
|
||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||
module.exports.Aroon = Aroon
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.RVI = RVI
|
||||
module.exports.PGO = PGO
|
||||
module.exports.KST = KST
|
||||
module.exports.SMI = SMI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.Inertia = Inertia
|
||||
module.exports.ALMA = ALMA
|
||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
module.exports.VIDYA = VIDYA
|
||||
module.exports.JMA = JMA
|
||||
module.exports.Alligator = Alligator
|
||||
module.exports.EVWMA = EVWMA
|
||||
module.exports.APO = APO
|
||||
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
||||
module.exports.CFO = CFO
|
||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
||||
module.exports.ElderImpulse = ElderImpulse
|
||||
module.exports.STC = STC
|
||||
module.exports.T3 = T3
|
||||
module.exports.TSI = TSI
|
||||
module.exports.PMO = PMO
|
||||
module.exports.TII = TII
|
||||
module.exports.ADL = ADL
|
||||
module.exports.VolumePriceTrend = VolumePriceTrend
|
||||
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
|
||||
module.exports.ChaikinOscillator = ChaikinOscillator
|
||||
module.exports.ForceIndex = ForceIndex
|
||||
module.exports.EaseOfMovement = EaseOfMovement
|
||||
module.exports.KVO = KVO
|
||||
module.exports.VolumeOscillator = VolumeOscillator
|
||||
module.exports.NVI = NVI
|
||||
module.exports.PVI = PVI
|
||||
module.exports.WilliamsAD = WilliamsAD
|
||||
module.exports.AnchoredVWAP = AnchoredVWAP
|
||||
module.exports.DemandIndex = DemandIndex
|
||||
module.exports.TSV = TSV
|
||||
module.exports.VZO = VZO
|
||||
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
||||
module.exports.SuperTrend = SuperTrend
|
||||
module.exports.ChandelierExit = ChandelierExit
|
||||
module.exports.ChandeKrollStop = ChandeKrollStop
|
||||
module.exports.AtrTrailingStop = AtrTrailingStop
|
||||
module.exports.HiLoActivator = HiLoActivator
|
||||
module.exports.VoltyStop = VoltyStop
|
||||
module.exports.YoyoExit = YoyoExit
|
||||
module.exports.DonchianStop = DonchianStop
|
||||
module.exports.PercentageTrailingStop = PercentageTrailingStop
|
||||
module.exports.StepTrailingStop = StepTrailingStop
|
||||
module.exports.RenkoTrailingStop = RenkoTrailingStop
|
||||
module.exports.TypicalPrice = TypicalPrice
|
||||
module.exports.MedianPrice = MedianPrice
|
||||
module.exports.WeightedClose = WeightedClose
|
||||
@@ -379,9 +418,113 @@ module.exports.NATR = NATR
|
||||
module.exports.HistoricalVolatility = HistoricalVolatility
|
||||
module.exports.AroonOscillator = AroonOscillator
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.RWI = RWI
|
||||
module.exports.WaveTrend = WaveTrend
|
||||
module.exports.MassIndex = MassIndex
|
||||
module.exports.StochRSI = StochRSI
|
||||
module.exports.UltimateOscillator = UltimateOscillator
|
||||
module.exports.PPO = PPO
|
||||
module.exports.Coppock = Coppock
|
||||
module.exports.VWMA = VWMA
|
||||
module.exports.RVIVolatility = RVIVolatility
|
||||
module.exports.ParkinsonVolatility = ParkinsonVolatility
|
||||
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
|
||||
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
|
||||
module.exports.YangZhangVolatility = YangZhangVolatility
|
||||
module.exports.MaEnvelope = MaEnvelope
|
||||
module.exports.AccelerationBands = AccelerationBands
|
||||
module.exports.StarcBands = StarcBands
|
||||
module.exports.AtrBands = AtrBands
|
||||
module.exports.HurstChannel = HurstChannel
|
||||
module.exports.LinRegChannel = LinRegChannel
|
||||
module.exports.StandardErrorBands = StandardErrorBands
|
||||
module.exports.DoubleBollinger = DoubleBollinger
|
||||
module.exports.TtmSqueeze = TtmSqueeze
|
||||
module.exports.FractalChaosBands = FractalChaosBands
|
||||
module.exports.VwapStdDevBands = VwapStdDevBands
|
||||
module.exports.ClassicPivots = ClassicPivots
|
||||
module.exports.FibonacciPivots = FibonacciPivots
|
||||
module.exports.Camarilla = Camarilla
|
||||
module.exports.WoodiePivots = WoodiePivots
|
||||
module.exports.DemarkPivots = DemarkPivots
|
||||
module.exports.WilliamsFractals = WilliamsFractals
|
||||
module.exports.ZigZag = ZigZag
|
||||
module.exports.TDSetup = TDSetup
|
||||
module.exports.TDSequential = TDSequential
|
||||
module.exports.TDDeMarker = TDDeMarker
|
||||
module.exports.TDREI = TDREI
|
||||
module.exports.TDPressure = TDPressure
|
||||
module.exports.TDCombo = TDCombo
|
||||
module.exports.TDCountdown = TDCountdown
|
||||
module.exports.TDLines = TDLines
|
||||
module.exports.TDRangeProjection = TDRangeProjection
|
||||
module.exports.TDDifferential = TDDifferential
|
||||
module.exports.TDOpen = TDOpen
|
||||
module.exports.TDRiskLevel = TDRiskLevel
|
||||
module.exports.SuperSmoother = SuperSmoother
|
||||
module.exports.FisherTransform = FisherTransform
|
||||
module.exports.InverseFisherTransform = InverseFisherTransform
|
||||
module.exports.Decycler = Decycler
|
||||
module.exports.DecyclerOscillator = DecyclerOscillator
|
||||
module.exports.RoofingFilter = RoofingFilter
|
||||
module.exports.CenterOfGravity = CenterOfGravity
|
||||
module.exports.CyberneticCycle = CyberneticCycle
|
||||
module.exports.InstantaneousTrendline = InstantaneousTrendline
|
||||
module.exports.EhlersStochastic = EhlersStochastic
|
||||
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
|
||||
module.exports.HilbertDominantCycle = HilbertDominantCycle
|
||||
module.exports.AdaptiveCycle = AdaptiveCycle
|
||||
module.exports.SineWave = SineWave
|
||||
module.exports.MAMA = MAMA
|
||||
module.exports.FAMA = FAMA
|
||||
module.exports.Ichimoku = Ichimoku
|
||||
module.exports.HeikinAshi = HeikinAshi
|
||||
module.exports.Variance = Variance
|
||||
module.exports.CoefficientOfVariation = CoefficientOfVariation
|
||||
module.exports.Skewness = Skewness
|
||||
module.exports.Kurtosis = Kurtosis
|
||||
module.exports.StandardError = StandardError
|
||||
module.exports.DetrendedStdDev = DetrendedStdDev
|
||||
module.exports.RSquared = RSquared
|
||||
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
|
||||
module.exports.Autocorrelation = Autocorrelation
|
||||
module.exports.HurstExponent = HurstExponent
|
||||
module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.ValueArea = ValueArea
|
||||
module.exports.InitialBalance = InitialBalance
|
||||
module.exports.OpeningRange = OpeningRange
|
||||
module.exports.Doji = Doji
|
||||
module.exports.Hammer = Hammer
|
||||
module.exports.InvertedHammer = InvertedHammer
|
||||
module.exports.HangingMan = HangingMan
|
||||
module.exports.ShootingStar = ShootingStar
|
||||
module.exports.Engulfing = Engulfing
|
||||
module.exports.Harami = Harami
|
||||
module.exports.MorningEveningStar = MorningEveningStar
|
||||
module.exports.ThreeSoldiersOrCrows = ThreeSoldiersOrCrows
|
||||
module.exports.PiercingDarkCloud = PiercingDarkCloud
|
||||
module.exports.Marubozu = Marubozu
|
||||
module.exports.Tweezer = Tweezer
|
||||
module.exports.SpinningTop = SpinningTop
|
||||
module.exports.ThreeInside = ThreeInside
|
||||
module.exports.ThreeOutside = ThreeOutside
|
||||
// Family 15: Risk / Performance metrics
|
||||
module.exports.SharpeRatio = SharpeRatio
|
||||
module.exports.SortinoRatio = SortinoRatio
|
||||
module.exports.CalmarRatio = CalmarRatio
|
||||
module.exports.OmegaRatio = OmegaRatio
|
||||
module.exports.MaxDrawdown = MaxDrawdown
|
||||
module.exports.AverageDrawdown = AverageDrawdown
|
||||
module.exports.DrawdownDuration = DrawdownDuration
|
||||
module.exports.PainIndex = PainIndex
|
||||
module.exports.ValueAtRisk = ValueAtRisk
|
||||
module.exports.ConditionalValueAtRisk = ConditionalValueAtRisk
|
||||
module.exports.ProfitFactor = ProfitFactor
|
||||
module.exports.GainLossRatio = GainLossRatio
|
||||
module.exports.RecoveryFactor = RecoveryFactor
|
||||
module.exports.KellyCriterion = KellyCriterion
|
||||
module.exports.TreynorRatio = TreynorRatio
|
||||
module.exports.InformationRatio = InformationRatio
|
||||
module.exports.Alpha = Alpha
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-arm64-msvc.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.3.1",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.3.1",
|
||||
"wickra-darwin-x64": "0.3.1",
|
||||
"wickra-linux-arm64-gnu": "0.3.1",
|
||||
"wickra-linux-x64-gnu": "0.3.1",
|
||||
"wickra-win32-arm64-msvc": "0.3.1",
|
||||
"wickra-win32-x64-msvc": "0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
"version": "2.18.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
|
||||
"integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"napi": "scripts/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.1.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.1.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.1.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.1.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.1.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.1.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.1",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <kingchencp@gmail.com>",
|
||||
"author": "kingchenc <wickra.lib@gmail.com>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -17,12 +17,12 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kingchenc/wickra/issues"
|
||||
"url": "https://github.com/wickra-lib/wickra/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra",
|
||||
"homepage": "https://github.com/wickra-lib/wickra",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
@@ -38,7 +38,8 @@
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc"
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -46,11 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.2.5",
|
||||
"wickra-linux-arm64-gnu": "0.2.5",
|
||||
"wickra-darwin-x64": "0.2.5",
|
||||
"wickra-darwin-arm64": "0.2.5",
|
||||
"wickra-win32-x64-msvc": "0.2.5"
|
||||
"wickra-linux-x64-gnu": "0.3.1",
|
||||
"wickra-linux-arm64-gnu": "0.3.1",
|
||||
"wickra-darwin-x64": "0.3.1",
|
||||
"wickra-darwin-arm64": "0.3.1",
|
||||
"wickra-win32-x64-msvc": "0.3.1",
|
||||
"wickra-win32-arm64-msvc": "0.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+39
-31
@@ -1,7 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/kingchenc/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -36,16 +36,16 @@ for price in live_feed:
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
@@ -58,7 +58,7 @@ depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
@@ -76,7 +76,7 @@ to recompute on every tick.
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.2.5"
|
||||
version = "0.3.1"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
authors = [{ name = "kingchenc", email = "kingchencp@gmail.com" }]
|
||||
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
@@ -47,9 +47,9 @@ bench = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kingchenc/wickra"
|
||||
Repository = "https://github.com/kingchenc/wickra"
|
||||
Issues = "https://github.com/kingchenc/wickra/issues"
|
||||
Homepage = "https://github.com/wickra-lib/wickra"
|
||||
Repository = "https://github.com/wickra-lib/wickra"
|
||||
Issues = "https://github.com/wickra-lib/wickra/issues"
|
||||
|
||||
[tool.maturin]
|
||||
manifest-path = "Cargo.toml"
|
||||
|
||||
@@ -38,6 +38,13 @@ from ._wickra import (
|
||||
ZLEMA,
|
||||
T3,
|
||||
VWMA,
|
||||
ALMA,
|
||||
McGinleyDynamic,
|
||||
FRAMA,
|
||||
VIDYA,
|
||||
JMA,
|
||||
Alligator,
|
||||
EVWMA,
|
||||
# Momentum
|
||||
RSI,
|
||||
MACD,
|
||||
@@ -46,6 +53,7 @@ from ._wickra import (
|
||||
ROC,
|
||||
WilliamsR,
|
||||
ADX,
|
||||
ADXR,
|
||||
MFI,
|
||||
TRIX,
|
||||
AwesomeOscillator,
|
||||
@@ -54,13 +62,30 @@ from ._wickra import (
|
||||
CMO,
|
||||
TSI,
|
||||
PMO,
|
||||
TII,
|
||||
KST,
|
||||
StochRSI,
|
||||
UltimateOscillator,
|
||||
RVI,
|
||||
PGO,
|
||||
KST,
|
||||
SMI,
|
||||
LaguerreRSI,
|
||||
ConnorsRSI,
|
||||
Inertia,
|
||||
APO,
|
||||
AwesomeOscillatorHistogram,
|
||||
CFO,
|
||||
ZeroLagMACD,
|
||||
ElderImpulse,
|
||||
STC,
|
||||
PPO,
|
||||
DPO,
|
||||
Coppock,
|
||||
AroonOscillator,
|
||||
Vortex,
|
||||
RWI,
|
||||
WaveTrend,
|
||||
MassIndex,
|
||||
AcceleratorOscillator,
|
||||
BalanceOfPower,
|
||||
@@ -82,8 +107,20 @@ from ._wickra import (
|
||||
ChandelierExit,
|
||||
ChandeKrollStop,
|
||||
AtrTrailingStop,
|
||||
HiLoActivator,
|
||||
VoltyStop,
|
||||
YoyoExit,
|
||||
DonchianStop,
|
||||
PercentageTrailingStop,
|
||||
StepTrailingStop,
|
||||
RenkoTrailingStop,
|
||||
TrueRange,
|
||||
ChaikinVolatility,
|
||||
RVIVolatility,
|
||||
ParkinsonVolatility,
|
||||
GarmanKlassVolatility,
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
OBV,
|
||||
VWAP,
|
||||
@@ -93,6 +130,16 @@ from ._wickra import (
|
||||
ChaikinMoneyFlow,
|
||||
ChaikinOscillator,
|
||||
ForceIndex,
|
||||
KVO,
|
||||
VolumeOscillator,
|
||||
NVI,
|
||||
PVI,
|
||||
WilliamsAD,
|
||||
AnchoredVWAP,
|
||||
DemandIndex,
|
||||
TSV,
|
||||
VZO,
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
TypicalPrice,
|
||||
@@ -102,6 +149,110 @@ from ._wickra import (
|
||||
LinRegSlope,
|
||||
ZScore,
|
||||
LinRegAngle,
|
||||
Variance,
|
||||
CoefficientOfVariation,
|
||||
Skewness,
|
||||
Kurtosis,
|
||||
StandardError,
|
||||
DetrendedStdDev,
|
||||
RSquared,
|
||||
Autocorrelation,
|
||||
MedianAbsoluteDeviation,
|
||||
HurstExponent,
|
||||
PearsonCorrelation,
|
||||
Beta,
|
||||
SpearmanCorrelation,
|
||||
# Ehlers / Cycle
|
||||
SuperSmoother,
|
||||
FisherTransform,
|
||||
InverseFisherTransform,
|
||||
Decycler,
|
||||
DecyclerOscillator,
|
||||
RoofingFilter,
|
||||
CenterOfGravity,
|
||||
CyberneticCycle,
|
||||
InstantaneousTrendline,
|
||||
EhlersStochastic,
|
||||
EmpiricalModeDecomposition,
|
||||
HilbertDominantCycle,
|
||||
AdaptiveCycle,
|
||||
SineWave,
|
||||
MAMA,
|
||||
FAMA,
|
||||
# Bands & Channels
|
||||
MaEnvelope,
|
||||
AccelerationBands,
|
||||
StarcBands,
|
||||
AtrBands,
|
||||
HurstChannel,
|
||||
LinRegChannel,
|
||||
StandardErrorBands,
|
||||
DoubleBollinger,
|
||||
TtmSqueeze,
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
WoodiePivots,
|
||||
DemarkPivots,
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
TDREI,
|
||||
TDPressure,
|
||||
TDCombo,
|
||||
TDCountdown,
|
||||
TDLines,
|
||||
TDRangeProjection,
|
||||
TDDifferential,
|
||||
TDOpen,
|
||||
TDRiskLevel,
|
||||
# Ichimoku & alternative charts
|
||||
Ichimoku,
|
||||
HeikinAshi,
|
||||
# Market Profile
|
||||
ValueArea,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Candlestick patterns
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
HangingMan,
|
||||
ShootingStar,
|
||||
Engulfing,
|
||||
Harami,
|
||||
MorningEveningStar,
|
||||
ThreeSoldiersOrCrows,
|
||||
PiercingDarkCloud,
|
||||
Marubozu,
|
||||
Tweezer,
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
CalmarRatio,
|
||||
OmegaRatio,
|
||||
MaxDrawdown,
|
||||
AverageDrawdown,
|
||||
DrawdownDuration,
|
||||
PainIndex,
|
||||
ValueAtRisk,
|
||||
ConditionalValueAtRisk,
|
||||
ProfitFactor,
|
||||
GainLossRatio,
|
||||
RecoveryFactor,
|
||||
KellyCriterion,
|
||||
TreynorRatio,
|
||||
InformationRatio,
|
||||
Alpha,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -119,6 +270,13 @@ __all__ = [
|
||||
"ZLEMA",
|
||||
"T3",
|
||||
"VWMA",
|
||||
"ALMA",
|
||||
"McGinleyDynamic",
|
||||
"FRAMA",
|
||||
"VIDYA",
|
||||
"JMA",
|
||||
"Alligator",
|
||||
"EVWMA",
|
||||
# Momentum
|
||||
"RSI",
|
||||
"MACD",
|
||||
@@ -127,6 +285,7 @@ __all__ = [
|
||||
"ROC",
|
||||
"WilliamsR",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"MFI",
|
||||
"TRIX",
|
||||
"AwesomeOscillator",
|
||||
@@ -135,13 +294,30 @@ __all__ = [
|
||||
"CMO",
|
||||
"TSI",
|
||||
"PMO",
|
||||
"TII",
|
||||
"KST",
|
||||
"StochRSI",
|
||||
"UltimateOscillator",
|
||||
"RVI",
|
||||
"PGO",
|
||||
"KST",
|
||||
"SMI",
|
||||
"LaguerreRSI",
|
||||
"ConnorsRSI",
|
||||
"Inertia",
|
||||
"APO",
|
||||
"AwesomeOscillatorHistogram",
|
||||
"CFO",
|
||||
"ZeroLagMACD",
|
||||
"ElderImpulse",
|
||||
"STC",
|
||||
"PPO",
|
||||
"DPO",
|
||||
"Coppock",
|
||||
"AroonOscillator",
|
||||
"Vortex",
|
||||
"RWI",
|
||||
"WaveTrend",
|
||||
"MassIndex",
|
||||
"AcceleratorOscillator",
|
||||
"BalanceOfPower",
|
||||
@@ -163,8 +339,20 @@ __all__ = [
|
||||
"ChandelierExit",
|
||||
"ChandeKrollStop",
|
||||
"AtrTrailingStop",
|
||||
"HiLoActivator",
|
||||
"VoltyStop",
|
||||
"YoyoExit",
|
||||
"DonchianStop",
|
||||
"PercentageTrailingStop",
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"TrueRange",
|
||||
"ChaikinVolatility",
|
||||
"RVIVolatility",
|
||||
"ParkinsonVolatility",
|
||||
"GarmanKlassVolatility",
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"OBV",
|
||||
"VWAP",
|
||||
@@ -174,6 +362,16 @@ __all__ = [
|
||||
"ChaikinMoneyFlow",
|
||||
"ChaikinOscillator",
|
||||
"ForceIndex",
|
||||
"KVO",
|
||||
"VolumeOscillator",
|
||||
"NVI",
|
||||
"PVI",
|
||||
"WilliamsAD",
|
||||
"AnchoredVWAP",
|
||||
"DemandIndex",
|
||||
"TSV",
|
||||
"VZO",
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"TypicalPrice",
|
||||
@@ -183,4 +381,108 @@ __all__ = [
|
||||
"LinRegSlope",
|
||||
"ZScore",
|
||||
"LinRegAngle",
|
||||
"Variance",
|
||||
"CoefficientOfVariation",
|
||||
"Skewness",
|
||||
"Kurtosis",
|
||||
"StandardError",
|
||||
"DetrendedStdDev",
|
||||
"RSquared",
|
||||
"Autocorrelation",
|
||||
"MedianAbsoluteDeviation",
|
||||
"HurstExponent",
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"SpearmanCorrelation",
|
||||
# Ehlers / Cycle
|
||||
"SuperSmoother",
|
||||
"FisherTransform",
|
||||
"InverseFisherTransform",
|
||||
"Decycler",
|
||||
"DecyclerOscillator",
|
||||
"RoofingFilter",
|
||||
"CenterOfGravity",
|
||||
"CyberneticCycle",
|
||||
"InstantaneousTrendline",
|
||||
"EhlersStochastic",
|
||||
"EmpiricalModeDecomposition",
|
||||
"HilbertDominantCycle",
|
||||
"AdaptiveCycle",
|
||||
"SineWave",
|
||||
"MAMA",
|
||||
"FAMA",
|
||||
# Bands & Channels
|
||||
"MaEnvelope",
|
||||
"AccelerationBands",
|
||||
"StarcBands",
|
||||
"AtrBands",
|
||||
"HurstChannel",
|
||||
"LinRegChannel",
|
||||
"StandardErrorBands",
|
||||
"DoubleBollinger",
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
"WoodiePivots",
|
||||
"DemarkPivots",
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
"TDREI",
|
||||
"TDPressure",
|
||||
"TDCombo",
|
||||
"TDCountdown",
|
||||
"TDLines",
|
||||
"TDRangeProjection",
|
||||
"TDDifferential",
|
||||
"TDOpen",
|
||||
"TDRiskLevel",
|
||||
# Ichimoku & alternative charts
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
# Market Profile
|
||||
"ValueArea",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Candlestick patterns
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
"HangingMan",
|
||||
"ShootingStar",
|
||||
"Engulfing",
|
||||
"Harami",
|
||||
"MorningEveningStar",
|
||||
"ThreeSoldiersOrCrows",
|
||||
"PiercingDarkCloud",
|
||||
"Marubozu",
|
||||
"Tweezer",
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
"CalmarRatio",
|
||||
"OmegaRatio",
|
||||
"MaxDrawdown",
|
||||
"AverageDrawdown",
|
||||
"DrawdownDuration",
|
||||
"PainIndex",
|
||||
"ValueAtRisk",
|
||||
"ConditionalValueAtRisk",
|
||||
"ProfitFactor",
|
||||
"GainLossRatio",
|
||||
"RecoveryFactor",
|
||||
"KellyCriterion",
|
||||
"TreynorRatio",
|
||||
"InformationRatio",
|
||||
"Alpha",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,3 +39,64 @@ def test_roc_and_trix_have_default_periods():
|
||||
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
|
||||
assert ta.ROC().period == 10
|
||||
assert ta.TRIX() is not None
|
||||
|
||||
|
||||
def test_value_area_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(0, 50, 0.7)
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 0, 0.7)
|
||||
|
||||
|
||||
def test_value_area_rejects_invalid_pct():
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 50, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 50, 1.5)
|
||||
|
||||
|
||||
def test_initial_balance_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.InitialBalance(0)
|
||||
|
||||
|
||||
def test_opening_range_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OpeningRange(0)
|
||||
|
||||
|
||||
def test_value_area_unequal_length_raises():
|
||||
high = np.array([1.0, 2.0, 3.0])
|
||||
low = np.array([0.5, 1.5])
|
||||
volume = np.array([10.0, 10.0, 10.0])
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(2, 10, 0.7).batch(high, low, volume)
|
||||
|
||||
|
||||
def test_ichimoku_rejects_zero_and_non_increasing_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(0, 26, 52, 26)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(9, 26, 52, 0)
|
||||
# Periods must satisfy tenkan < kijun < senkou_b.
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(26, 9, 52, 26)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(9, 52, 52, 26)
|
||||
|
||||
|
||||
def test_family_10_ehlers_rejects_invalid_parameters():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SuperSmoother(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.FisherTransform(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.InverseFisherTransform(0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.DecyclerOscillator(30, 10)
|
||||
with pytest.raises(ValueError):
|
||||
ta.RoofingFilter(48, 10)
|
||||
with pytest.raises(ValueError):
|
||||
ta.MAMA(0.05, 0.5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.EmpiricalModeDecomposition(20, 0.0)
|
||||
|
||||
@@ -66,6 +66,224 @@ def test_rsi_wilder_textbook_first_value():
|
||||
assert math.isclose(out[14], 70.464, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_inertia_constant_rvi_passes_through_linreg():
|
||||
# Every bar identical (open, high, low, close) = (10, 11, 9, 10.5):
|
||||
# RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant
|
||||
# series equals that constant after warmup.
|
||||
n = 60
|
||||
out = ta.Inertia(3, 4).batch(
|
||||
np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5)
|
||||
)
|
||||
# warmup_period = 3 + 4 - 1 = 6.
|
||||
np.testing.assert_allclose(out[5:], 0.25, atol=1e-12)
|
||||
|
||||
|
||||
def test_connors_rsi_output_is_bounded():
|
||||
# CRSI is the average of three [0, 100] components, so the aggregate must
|
||||
# also sit in [0, 100] after warmup.
|
||||
prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250))
|
||||
out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
assert ready.min() >= 0.0
|
||||
assert ready.max() <= 100.0
|
||||
|
||||
|
||||
def test_laguerre_rsi_constant_series_stays_at_mid_band():
|
||||
# All four Laguerre stages seed to the first input, so subsequent flat
|
||||
# inputs keep them equal and the up/down accumulator is 0 — Wickra maps
|
||||
# that to the neutral 50.
|
||||
out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64))
|
||||
np.testing.assert_allclose(out, 50.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_smi_close_at_centre_yields_zero():
|
||||
# Close at the midpoint of a flat high/low range -> displacement is
|
||||
# always zero -> SMI converges to 0.
|
||||
n = 60
|
||||
out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0))
|
||||
# warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
np.testing.assert_allclose(out[8:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_kst_constant_series_yields_zero():
|
||||
# ROC is zero on a flat input, so every RCMA is zero, so KST and its
|
||||
# signal SMA are both zero after warmup.
|
||||
kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9)
|
||||
out = kst.batch(np.full(80, 42.0, dtype=np.float64))
|
||||
warmup = kst.warmup_period()
|
||||
# Use NaN-safe comparison on the post-warmup tail.
|
||||
tail = out[warmup - 1 :]
|
||||
assert np.all(np.isfinite(tail))
|
||||
np.testing.assert_allclose(tail, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_pgo_flat_close_yields_zero():
|
||||
# On a constant close the numerator (close − SMA) is zero, so PGO emits 0
|
||||
# regardless of the TR-EMA in the denominator.
|
||||
n = 20
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 10.0)
|
||||
out = ta.PGO(5).batch(high, low, close)
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_rvi_reference_value_period_2():
|
||||
# Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
# num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5.
|
||||
out = ta.RVI(2).batch(
|
||||
np.array([10.0, 10.5]),
|
||||
np.array([11.0, 11.5]),
|
||||
np.array([9.0, 10.0]),
|
||||
np.array([10.5, 11.0]),
|
||||
)
|
||||
assert math.isnan(out[0])
|
||||
assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alma_constant_series_yields_the_constant():
|
||||
# ALMA's Gaussian weights are normalised, so any constant series is
|
||||
# reproduced exactly after warmup.
|
||||
out = ta.ALMA(9, 0.85, 6.0).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:8]))
|
||||
np.testing.assert_allclose(out[8:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_alma_reference_value_period_3():
|
||||
# ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
|
||||
# m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
out = ta.ALMA(3, 0.85, 6.0).batch(np.array([10.0, 20.0, 30.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
# Independently compute the expected Gaussian-weighted sum.
|
||||
w = np.exp(-((np.arange(3, dtype=np.float64) - 1.7) ** 2) / 0.5)
|
||||
expected = float(np.dot([10.0, 20.0, 30.0], w) / w.sum())
|
||||
assert math.isclose(out[2], expected, abs_tol=1e-12)
|
||||
# Sanity: heavy offset toward the newest sample lifts the average above
|
||||
# the simple mean of 20.
|
||||
assert out[2] > 20.0
|
||||
|
||||
|
||||
def test_mcginley_dynamic_constant_series_yields_the_constant():
|
||||
# ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
|
||||
out = ta.McGinleyDynamic(5).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_mcginley_dynamic_reference_value():
|
||||
# Period 3, seed = SMA([10, 20, 30]) = 20.0. Next price 40.0:
|
||||
# ratio = 2; divisor = 0.6 * 3 * 16 = 28.8; next = 20 + 20/28.8.
|
||||
out = ta.McGinleyDynamic(3).batch(np.array([10.0, 20.0, 30.0, 40.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
assert math.isclose(out[2], 20.0, abs_tol=1e-12)
|
||||
expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0)
|
||||
assert math.isclose(out[3], expected, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_frama_constant_series_yields_the_constant():
|
||||
# Flat input -> degenerate ranges -> alpha clamps to 0.01 and the EMA
|
||||
# recurrence holds the seed value.
|
||||
out = ta.FRAMA(4).batch(np.full(20, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:3]))
|
||||
np.testing.assert_allclose(out[3:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_frama_pure_uptrend_hugs_latest():
|
||||
# Monotonic uptrend -> alpha pushed toward 1.0, FRAMA tracks close.
|
||||
out = ta.FRAMA(4).batch(np.arange(1.0, 9.0, dtype=np.float64))
|
||||
assert math.isclose(out[-1], 8.0, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_jma_constant_series_yields_the_constant():
|
||||
# JMA seeds e0 and the output to the first input, so a constant series
|
||||
# is reproduced exactly from the first sample.
|
||||
out = ta.JMA(14, 0.0, 2).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
np.testing.assert_allclose(out, 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_evwma_reference_value_period_2():
|
||||
# EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1).
|
||||
# Bar 2: sum_v = 4, seeded prev = 20, EVWMA = (1*20 + 3*20)/4 = 20.
|
||||
# Bar 3: sum_v = 4 (drops 1, gains 1), EVWMA = (3*20 + 1*30)/4 = 22.5.
|
||||
out = ta.EVWMA(2).batch(np.array([10.0, 20.0, 30.0]), np.array([1.0, 3.0, 1.0]))
|
||||
assert math.isnan(out[0])
|
||||
assert math.isclose(out[1], 20.0, abs_tol=1e-12)
|
||||
assert math.isclose(out[2], 22.5, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alligator_constant_series_holds_at_median_price():
|
||||
# Median price = (11 + 9) / 2 = 10 on every candle, so all three SMMAs
|
||||
# seed at 10 and stay there.
|
||||
n = 30
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
out = ta.Alligator(13, 8, 5).batch(high, low)
|
||||
assert out.shape == (n, 3)
|
||||
for row in out[12:]:
|
||||
assert math.isclose(row[0], 10.0, abs_tol=1e-12)
|
||||
assert math.isclose(row[1], 10.0, abs_tol=1e-12)
|
||||
assert math.isclose(row[2], 10.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_vidya_constant_series_holds_seed():
|
||||
# CMO = 0 on a flat series -> alpha = 0 -> VIDYA holds its seed value.
|
||||
out = ta.VIDYA(14, 4).batch(np.full(20, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_zero_lag_macd_constant_series_converges_to_zero():
|
||||
# Each inner ZLEMA reproduces a constant, so macd, signal and histogram
|
||||
# are all 0 once the slowest branch warms up.
|
||||
out = ta.ZeroLagMACD(3, 5, 3).batch(np.full(60, 42.0, dtype=np.float64))
|
||||
# Take the last row and verify all three columns are 0.
|
||||
last = out[-1]
|
||||
assert math.isclose(last[0], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(last[1], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(last[2], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_awesome_oscillator_histogram_flat_series_converges_to_zero():
|
||||
# Flat median price -> AO = 0 -> SMA(AO) = 0 -> AOHist = 0.
|
||||
n = 50
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low)
|
||||
# warmup = slow + sma - 1 = 5 + 3 - 1 = 7.
|
||||
np.testing.assert_allclose(out[6:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_stc_constant_series_yields_zero():
|
||||
# Flat input collapses both stochastic stages to zero -> STC stays at 0.
|
||||
out = ta.STC(3, 5, 4, 0.5).batch(np.full(60, 42.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_array_equal(ready[-5:], np.zeros(5))
|
||||
|
||||
|
||||
def test_elder_impulse_constant_series_is_neutral():
|
||||
# Flat input -> neither EMA nor MACD histogram moves -> Impulse stays at 0.
|
||||
out = ta.ElderImpulse(13, 12, 26, 9).batch(np.full(120, 42.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_array_equal(ready, np.zeros_like(ready))
|
||||
|
||||
|
||||
def test_cfo_perfect_linear_series_yields_zero():
|
||||
# LinReg of a perfectly linear series fits exactly, so CFO = 0 after warmup.
|
||||
out = ta.CFO(5).batch(np.arange(1.0, 21.0, dtype=np.float64) * 2.0)
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_apo_constant_series_converges_to_zero():
|
||||
# Both EMAs reproduce a constant exactly, so APO = 0 after warmup.
|
||||
out = ta.APO(3, 5).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_macd_constant_series_converges_to_zero():
|
||||
out = ta.MACD().batch(np.full(200, 100.0))
|
||||
# Last row's MACD and signal must be ~0.
|
||||
@@ -112,3 +330,435 @@ def test_obv_cumulative_known_sequence():
|
||||
volume = np.array([100.0, 20.0, 30.0, 40.0, 10.0])
|
||||
out = ta.OBV().batch(close, volume)
|
||||
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
|
||||
|
||||
|
||||
# --- Family 15: Risk / Performance ---------------------------------------
|
||||
|
||||
|
||||
def test_sharpe_ratio_known_window():
|
||||
# returns [0.01, 0.02, 0.03, 0.04], rf = 0; mean = 0.025;
|
||||
# sample-var = 0.000166...; Sharpe = 0.025 / sqrt(var).
|
||||
out = ta.SharpeRatio(4, 0.0).batch(np.array([0.01, 0.02, 0.03, 0.04]))
|
||||
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
|
||||
assert math.isclose(out[3], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_sortino_ratio_known_window():
|
||||
# returns [-0.02, 0.01, -0.01, 0.03], mar = 0; mean = 0.0025;
|
||||
# downside_sq = 0.0005; dd = sqrt(0.0005/4); Sortino = 0.0025/dd.
|
||||
out = ta.SortinoRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
|
||||
expected = 0.0025 / math.sqrt(0.000_125)
|
||||
assert math.isclose(out[3], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_max_drawdown_known_window():
|
||||
# window [100, 120, 90] -> peak 120, trough 90 -> 25% drawdown.
|
||||
out = ta.MaxDrawdown(3).batch(np.array([100.0, 120.0, 90.0]))
|
||||
assert math.isclose(out[2], 0.25, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_pain_index_known_window():
|
||||
# dd[0..2] = 0, 0, 0.25; mean = 0.25/3.
|
||||
out = ta.PainIndex(3).batch(np.array([100.0, 120.0, 90.0]))
|
||||
assert math.isclose(out[2], 0.25 / 3.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_profit_factor_known_window():
|
||||
# gains 0.05, losses 0.03 -> PF = 5/3.
|
||||
out = ta.ProfitFactor(4).batch(np.array([0.02, -0.01, 0.03, -0.02]))
|
||||
assert math.isclose(out[3], 5.0 / 3.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_gain_loss_ratio_known_window():
|
||||
# avg_win 0.03, avg_loss 0.02 -> GLR = 1.5.
|
||||
out = ta.GainLossRatio(4).batch(np.array([0.02, -0.01, 0.04, -0.03]))
|
||||
assert math.isclose(out[3], 1.5, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_omega_ratio_known_window():
|
||||
# gains 0.04, losses 0.03 -> Omega = 4/3.
|
||||
out = ta.OmegaRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
|
||||
assert math.isclose(out[3], 4.0 / 3.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_kelly_criterion_known_window():
|
||||
# n_win=n_loss=2, payoff=2 -> Kelly = 0.5 - 0.5/2 = 0.25.
|
||||
out = ta.KellyCriterion(4).batch(np.array([0.02, 0.04, -0.01, -0.02]))
|
||||
assert math.isclose(out[3], 0.25, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_drawdown_duration_under_water_counter():
|
||||
out = ta.DrawdownDuration().batch(np.array([100.0, 95.0, 90.0, 85.0]))
|
||||
np.testing.assert_allclose(out, [0.0, 1.0, 2.0, 3.0])
|
||||
|
||||
|
||||
def test_recovery_factor_known_path():
|
||||
# Start 100, peak 110, trough 88 -> max_dd = 0.20; end 130 ->
|
||||
# net_return = 0.30 -> Recovery = 1.5.
|
||||
prices = np.array([100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0])
|
||||
out = ta.RecoveryFactor().batch(prices)
|
||||
assert math.isclose(out[-1], 1.5, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_alpha_perfect_capm_fit_yields_zero():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = 2.0 * bench
|
||||
out = ta.Alpha(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alpha_additive_offset_recovered():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = bench + 0.005
|
||||
out = ta.Alpha(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], 0.005, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_treynor_ratio_known_window():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = 2.0 * bench
|
||||
out = ta.TreynorRatio(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], bench.mean(), rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_information_ratio_known_window():
|
||||
asset = np.array([0.02, 0.04, 0.06, 0.08])
|
||||
bench = np.array([0.01, 0.02, 0.03, 0.04])
|
||||
out = ta.InformationRatio(4).batch(asset, bench)
|
||||
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_value_at_risk_known_window():
|
||||
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
|
||||
returns = np.array([i * 0.01 for i in range(-5, 5)])
|
||||
out = ta.ValueAtRisk(10, 0.95).batch(returns)
|
||||
assert math.isclose(out[-1], 0.0455, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_conditional_value_at_risk_known_window():
|
||||
# tail = {-0.10}; CVaR = 0.10.
|
||||
returns = np.array([i * 0.01 for i in range(-10, 10)])
|
||||
out = ta.ConditionalValueAtRisk(20, 0.95).batch(returns)
|
||||
assert math.isclose(out[-1], 0.10, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_calmar_ratio_known_path():
|
||||
# returns [0.10, -0.20, 0.05]; equity 1.0->1.10->0.88->0.924;
|
||||
# mdd = 0.20; mean = -0.01666...; Calmar = mean / 0.20.
|
||||
out = ta.CalmarRatio(3).batch(np.array([0.10, -0.20, 0.05]))
|
||||
expected = ((0.10 - 0.20 + 0.05) / 3.0) / 0.20
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_average_drawdown_known_window():
|
||||
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
|
||||
# mean = (0.25 + 10/120) / 4.
|
||||
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
|
||||
expected = (0.25 + 10.0 / 120.0) / 4.0
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_value_area_concentrated_volume_locates_poc():
|
||||
# Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume
|
||||
# at price 110. POC must fall inside the high-volume bar's [low, high]
|
||||
# range; ties resolve to the lowest-index bin, so the POC may sit on the
|
||||
# left edge of bar 4's range rather than at its midpoint.
|
||||
high = np.array([100.5, 100.5, 100.5, 100.5, 110.5])
|
||||
low = np.array([99.5, 99.5, 99.5, 99.5, 109.5])
|
||||
volume = np.array([1.0, 1.0, 1.0, 1.0, 1000.0])
|
||||
out = ta.ValueArea(5, 50, 0.70).batch(high, low, volume)
|
||||
poc = out[-1, 0]
|
||||
assert 109.5 <= poc <= 110.5
|
||||
# VAH >= POC >= VAL.
|
||||
assert out[-1, 1] >= poc >= out[-1, 2]
|
||||
|
||||
|
||||
def test_initial_balance_locks_after_period():
|
||||
# First two bars set IB = [99, 103]. Third bar (extreme) must be ignored.
|
||||
high = np.array([102.0, 103.0, 200.0])
|
||||
low = np.array([100.0, 99.0, 50.0])
|
||||
out = ta.InitialBalance(2).batch(high, low)
|
||||
# Bar 0: IB = [100, 102]; Bar 1: IB locked at [99, 103]; Bar 2: unchanged.
|
||||
np.testing.assert_allclose(out[0], [102.0, 100.0])
|
||||
np.testing.assert_allclose(out[1], [103.0, 99.0])
|
||||
np.testing.assert_allclose(out[2], [103.0, 99.0])
|
||||
|
||||
|
||||
def test_opening_range_breakout_distance_signed():
|
||||
# OR locks after 2 bars at high 103 / low 100; mid 101.5. Third bar
|
||||
# closes at 105 -> breakout +3.5; fourth bar closes at 95 -> -6.5.
|
||||
high = np.array([102.0, 103.0, 110.0, 110.0])
|
||||
low = np.array([100.0, 101.0, 102.0, 90.0])
|
||||
close = np.array([101.0, 102.0, 105.0, 95.0])
|
||||
out = ta.OpeningRange(2).batch(high, low, close)
|
||||
assert math.isclose(out[2, 0], 103.0)
|
||||
assert math.isclose(out[2, 1], 100.0)
|
||||
assert math.isclose(out[2, 2], 105.0 - 101.5)
|
||||
assert math.isclose(out[3, 2], 95.0 - 101.5)
|
||||
|
||||
|
||||
# --- Family 10 — Ehlers / Cycle reference values ---
|
||||
|
||||
|
||||
def test_inverse_fisher_saturates_for_large_input():
|
||||
# tanh(10) ~ 0.99999996; very close to +1 without exceeding.
|
||||
v = ta.InverseFisherTransform(1.0).batch(np.array([10.0]))[0]
|
||||
assert v < 1.0
|
||||
assert v > 0.999
|
||||
|
||||
|
||||
def test_super_smoother_constant_input_is_constant():
|
||||
out = ta.SuperSmoother(20).batch(np.full(200, 50.0))
|
||||
# Steady-state gain is 1, so a flat input stays flat.
|
||||
np.testing.assert_allclose(out[-50:], 50.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_decycler_oscillator_flat_series_is_zero():
|
||||
out = ta.DecyclerOscillator(10, 30).batch(np.full(80, 42.0))
|
||||
ready = out[~np.isnan(out)]
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_mama_constant_series_both_lines_converge_to_price():
|
||||
out = ta.MAMA().batch(np.full(200, 100.0))
|
||||
last = out[-1]
|
||||
# MAMA and FAMA both track price closely on a flat series.
|
||||
assert abs(last[0] - 100.0) < 1.0
|
||||
assert abs(last[1] - 100.0) < 1.0
|
||||
|
||||
|
||||
# --- DeMark family ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_td_setup_buy_setup_completes_at_minus_9_uptrend():
|
||||
# Strictly rising closes -> every bar has close > close[-4] (sell setup);
|
||||
# the streak hits -9 at index 12 and caps there.
|
||||
h = np.arange(2.0, 22.0)
|
||||
l = h - 1.0
|
||||
c = h - 0.5
|
||||
out = ta.TDSetup(4, 9).batch(h, l, c)
|
||||
assert out[12] == pytest.approx(-9.0)
|
||||
assert out[-1] == pytest.approx(-9.0)
|
||||
|
||||
|
||||
def test_td_demarker_downtrend_pegs_at_zero():
|
||||
n = 20
|
||||
h = np.arange(30.0, 30.0 - n, -1.0)
|
||||
l = h - 2.0
|
||||
out = ta.TDDeMarker(5).batch(h, l)
|
||||
assert out[-1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_td_pressure_pure_bearish_yields_minus_100():
|
||||
n = 20
|
||||
open_ = np.full(n, 11.0)
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 9.0)
|
||||
volume = np.full(n, 100.0)
|
||||
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
|
||||
assert out[-1] == pytest.approx(-100.0)
|
||||
|
||||
|
||||
def test_td_combo_uptrend_completes_to_minus_13():
|
||||
# Pure uptrend -> setup completes, then combo conditions (close>=high[-2],
|
||||
# high>=prev.high, close>prev.close) all hold for every subsequent bar
|
||||
# -> sell combo saturates at -13.
|
||||
n = 40
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDCombo().batch(high, low, close)
|
||||
assert out[-1] == pytest.approx(-13.0)
|
||||
|
||||
|
||||
def test_td_countdown_uptrend_completes_to_minus_13():
|
||||
n = 40
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDCountdown().batch(high, low, close)
|
||||
assert out[-1] == pytest.approx(-13.0)
|
||||
|
||||
|
||||
def test_td_range_projection_doji_reference():
|
||||
# open=close=10, high=12, low=9 -> doji branch.
|
||||
# pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
|
||||
# projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
|
||||
out = ta.TDRangeProjection().batch(
|
||||
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([10.0])
|
||||
)
|
||||
assert out[0, 0] == pytest.approx(11.5)
|
||||
assert out[0, 1] == pytest.approx(8.5)
|
||||
|
||||
|
||||
def test_td_open_sell_signal_reference():
|
||||
# Prev high=12. Curr open=13 > 12, curr low=11 < 12 -> -1.
|
||||
td = ta.TDOpen()
|
||||
assert td.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) is None
|
||||
assert td.update((13.0, 13.5, 11.0, 11.5, 1.0, 1)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_td_differential_sell_signal_reference():
|
||||
# Prev high=10, low=8, close=9: buying=1, selling=1.
|
||||
# Curr high=12, low=9.8, close=10.5: close>prev.close, selling=1.5>1,
|
||||
# buying=0.7<1 -> sell signal -1.
|
||||
td = ta.TDDifferential()
|
||||
assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
|
||||
assert td.update((10.5, 12.0, 9.8, 10.5, 1.0, 1)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_td_lines_uptrend_support_reference():
|
||||
# Strictly rising series -> sell setup completes at idx 12, the
|
||||
# lowest low across bars 4..=12 is the low at idx 4 = 4.5.
|
||||
n = 20
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDLines().batch(high, low, close)
|
||||
assert math.isnan(out[-1, 0])
|
||||
assert out[-1, 1] == pytest.approx(4.5)
|
||||
|
||||
|
||||
def test_td_risk_level_uptrend_sell_risk_reference():
|
||||
# Strictly rising series -> sell setup completes at idx 12 with high
|
||||
# 13.5 and true range 1.5 -> sell_risk = 13.5 + 1.5 = 15.0.
|
||||
# Subsequent setups re-ratchet the level, so we check the first emission
|
||||
# at idx 12 rather than the latest value.
|
||||
n = 20
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDRiskLevel().batch(high, low, close)
|
||||
assert math.isnan(out[12, 0])
|
||||
assert out[12, 1] == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_percentage_trailing_stop_seed_and_ratchet():
|
||||
# 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99.
|
||||
s = ta.PercentageTrailingStop(10.0)
|
||||
assert math.isclose(s.update(100.0), 90.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(110.0), 99.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_step_trailing_stop_snaps_below_close():
|
||||
# step 1: floor((100.4 - 1) / 1) = 99.
|
||||
s = ta.StepTrailingStop(1.0)
|
||||
assert math.isclose(s.update(100.4), 99.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_renko_trailing_stop_holds_until_full_block():
|
||||
# block 1: seed 100 -> stop 99; 100.5 still 99; 101 -> stop 100.
|
||||
s = ta.RenkoTrailingStop(1.0)
|
||||
assert math.isclose(s.update(100.0), 99.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(100.5), 99.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(101.0), 100.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_donchian_stop_window_extremes():
|
||||
# 5-bar window of highs 1..5 and lows 0..4.
|
||||
high = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
low = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
|
||||
out = ta.DonchianStop(5).batch(high, low)
|
||||
# First 4 rows NaN, fifth row: stop_long = 0, stop_short = 5.
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i, 0])
|
||||
assert math.isnan(out[i, 1])
|
||||
assert math.isclose(out[4, 0], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(out[4, 1], 5.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_hilo_activator_flat_market_holds_low_sma():
|
||||
# Flat candles H=11, L=9, C=10 -> close (10) sits between bands, so the
|
||||
# initial long seed is preserved: emitted stop = lo_sma = 9.
|
||||
h = np.full(15, 11.0)
|
||||
l = np.full(15, 9.0)
|
||||
c = np.full(15, 10.0)
|
||||
out = ta.HiLoActivator(3).batch(h, l, c)
|
||||
# warmup_period == period + 1 == 4, so indices 0..2 are NaN; index 3 onwards is 9.
|
||||
for i in range(3):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(3, 15):
|
||||
assert math.isclose(out[i], 9.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_volty_stop_flat_market_constant_level():
|
||||
# ATR=2, mult=2 -> band 4; anchor stays at close 10 -> stop = 10 - 4 = 6.
|
||||
h = np.full(20, 11.0)
|
||||
l = np.full(20, 9.0)
|
||||
c = np.full(20, 10.0)
|
||||
out = ta.VoltyStop(5, 2.0).batch(h, l, c)
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(4, 20):
|
||||
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_yoyo_exit_flat_market_constant_level():
|
||||
# ATR=2, mult=2 -> band 4; trail = close - band = 10 - 4 = 6 and holds.
|
||||
h = np.full(20, 11.0)
|
||||
l = np.full(20, 9.0)
|
||||
c = np.full(20, 10.0)
|
||||
out = ta.YoyoExit(5, 2.0).batch(h, l, c)
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(4, 20):
|
||||
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_rvi_volatility_pure_uptrend_saturates_at_one_hundred():
|
||||
# Strictly rising closes -> every stddev sample classified as "up" ->
|
||||
# RVIVolatility saturates at 100. Renamed from the original ta.RVI in
|
||||
# PR 42 to disambiguate from Family 02's Relative Vigor Index, which
|
||||
# now owns the short ta.RVI name (candle input).
|
||||
out = ta.RVIVolatility(5).batch(np.arange(1.0, 41.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready[-10:], 100.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_parkinson_volatility_zero_range_yields_zero():
|
||||
# H == L every bar -> ln(H/L) = 0 -> Parkinson sigma is zero.
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
out = ta.ParkinsonVolatility(14, 252).batch(h, l)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_garman_klass_zero_movement_yields_zero():
|
||||
# O == H == L == C every bar -> both log terms are zero -> sigma is zero.
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.GarmanKlassVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_rogers_satchell_zero_movement_yields_zero():
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.RogersSatchellVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_yang_zhang_zero_movement_yields_zero():
|
||||
# O == H == L == C and constant across bars -> every sub-component is
|
||||
# zero -> Yang-Zhang sigma is zero.
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.YangZhangVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
@@ -86,3 +86,46 @@ def test_candle_tuple_input_supported():
|
||||
atr.update((10.0, 11.0, 9.0, 10.5, 1.0, 0))
|
||||
v = atr.update((10.5, 12.0, 10.0, 11.0, 1.0, 1))
|
||||
assert v is not None
|
||||
|
||||
|
||||
def test_initial_balance_reset_unlocks():
|
||||
ib = ta.InitialBalance(2)
|
||||
assert not ib.is_ready()
|
||||
ib.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
|
||||
ib.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
|
||||
assert ib.is_ready()
|
||||
assert ib.is_locked()
|
||||
ib.reset()
|
||||
assert not ib.is_ready()
|
||||
assert not ib.is_locked()
|
||||
|
||||
|
||||
def test_opening_range_reset_unlocks():
|
||||
or_ind = ta.OpeningRange(2)
|
||||
or_ind.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
|
||||
or_ind.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
|
||||
assert or_ind.is_locked()
|
||||
or_ind.reset()
|
||||
assert not or_ind.is_locked()
|
||||
|
||||
|
||||
def test_value_area_warmup_equals_period():
|
||||
assert ta.ValueArea(20, 50, 0.70).warmup_period() == 20
|
||||
assert ta.ValueArea(10, 30, 0.80).warmup_period() == 10
|
||||
|
||||
|
||||
def test_ehlers_indicators_lifecycle():
|
||||
# Spot-check a few Family-10 entries beyond what test_new_indicators covers.
|
||||
series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0
|
||||
for ind in [
|
||||
ta.SuperSmoother(10),
|
||||
ta.FisherTransform(10),
|
||||
ta.MAMA(),
|
||||
ta.HilbertDominantCycle(),
|
||||
ta.SineWave(),
|
||||
]:
|
||||
assert not ind.is_ready()
|
||||
ind.batch(series)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,3 +55,45 @@ def test_obv_batch_shape(ohlc_series):
|
||||
volume = np.ones_like(close)
|
||||
out = ta.OBV().batch(close, volume)
|
||||
assert out.shape == close.shape
|
||||
|
||||
|
||||
def test_value_area_batch_shape(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
volume = np.ones_like(close)
|
||||
out = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
|
||||
assert out.shape == (close.size, 3)
|
||||
|
||||
|
||||
def test_initial_balance_batch_shape(ohlc_series):
|
||||
high, low, _close = ohlc_series
|
||||
out = ta.InitialBalance(12).batch(high, low)
|
||||
assert out.shape == (high.size, 2)
|
||||
|
||||
|
||||
def test_opening_range_batch_shape(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
out = ta.OpeningRange(6).batch(high, low, close)
|
||||
assert out.shape == (close.size, 3)
|
||||
|
||||
|
||||
def test_ichimoku_batch_returns_n_by_5(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
out = ta.Ichimoku().batch(high, low, close)
|
||||
assert out.shape == (close.size, 5)
|
||||
|
||||
|
||||
def test_heikin_ashi_batch_returns_n_by_4(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
open_ = (high + low) / 2.0
|
||||
out = ta.HeikinAshi().batch(open_, high, low, close)
|
||||
assert out.shape == (close.size, 4)
|
||||
|
||||
|
||||
def test_ehlers_super_smoother_batch_shape(sine_prices):
|
||||
out = ta.SuperSmoother(10).batch(sine_prices)
|
||||
assert out.shape == sine_prices.shape
|
||||
|
||||
|
||||
def test_mama_batch_shape(sine_prices):
|
||||
out = ta.MAMA().batch(sine_prices)
|
||||
assert out.shape == (sine_prices.size, 2)
|
||||
|
||||
@@ -117,6 +117,30 @@ def test_obv_streaming_matches_batch(ohlc_series):
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_mama_streaming_matches_batch(sine_prices):
|
||||
batch = ta.MAMA().batch(sine_prices)
|
||||
streamer = ta.MAMA()
|
||||
rows = []
|
||||
for p in sine_prices:
|
||||
v = streamer.update(float(p))
|
||||
if v is None:
|
||||
rows.append([math.nan, math.nan])
|
||||
else:
|
||||
rows.append(list(v))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_super_smoother_streaming_matches_batch(sine_prices):
|
||||
batch = ta.SuperSmoother(10).batch(sine_prices)
|
||||
streamer = ta.SuperSmoother(10)
|
||||
streamed = np.array(
|
||||
[math.nan if (v := streamer.update(float(p))) is None else float(v) for p in sine_prices],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_rolling_vwap_streaming_matches_batch(ohlc_series):
|
||||
# RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP
|
||||
# parity coverage now that the indicator is exposed across all bindings.
|
||||
@@ -135,3 +159,45 @@ def test_rolling_vwap_streaming_matches_batch(ohlc_series):
|
||||
assert streamer.is_ready()
|
||||
streamer.reset()
|
||||
assert not streamer.is_ready()
|
||||
|
||||
|
||||
def test_value_area_streaming_matches_batch(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
|
||||
batch = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
|
||||
|
||||
streamer = ta.ValueArea(20, 50, 0.70)
|
||||
rows = []
|
||||
for h, l, v in zip(high, low, volume):
|
||||
mid = float((h + l) / 2.0)
|
||||
out = streamer.update((mid, float(h), float(l), mid, float(v), 0))
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_initial_balance_streaming_matches_batch(ohlc_series):
|
||||
high, low, _close = ohlc_series
|
||||
batch = ta.InitialBalance(12).batch(high, low)
|
||||
|
||||
streamer = ta.InitialBalance(12)
|
||||
rows = []
|
||||
for h, l in zip(high, low):
|
||||
mid = float((h + l) / 2.0)
|
||||
out = streamer.update((mid, float(h), float(l), mid, 0.0, 0))
|
||||
rows.append([math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_opening_range_streaming_matches_batch(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
batch = ta.OpeningRange(6).batch(high, low, close)
|
||||
|
||||
streamer = ta.OpeningRange(6)
|
||||
rows = []
|
||||
for h, l, c in zip(high, low, close):
|
||||
out = streamer.update((float(c), float(h), float(l), float(c), 0.0, 0))
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
+39
-31
@@ -1,7 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/kingchenc/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -36,16 +36,16 @@ for price in live_feed:
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
@@ -58,7 +58,7 @@ depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
@@ -76,7 +76,7 @@ to recompute on every tick.
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
@@ -109,20 +109,28 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -256,7 +264,7 @@ Every layer is covered; run the suites with the commands in
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -290,14 +298,14 @@ use Wickra commercially, get in touch about a license.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
//! Acceleration Bands (Price Headley).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Acceleration Bands output: SMA of close with momentum-biased envelopes
|
||||
/// driven by the bar's high/low geometry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AccelerationBandsOutput {
|
||||
/// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`.
|
||||
pub upper: f64,
|
||||
/// Middle band: SMA of close.
|
||||
pub middle: f64,
|
||||
/// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
|
||||
/// bar's relative range `(high − low) / (high + low)`.
|
||||
///
|
||||
/// ```text
|
||||
/// ratio = (high − low) / (high + low)
|
||||
/// raw_up = high · (1 + factor · ratio)
|
||||
/// raw_lo = low · (1 − factor · ratio)
|
||||
/// upper = SMA(raw_up, period)
|
||||
/// middle = SMA(close, period)
|
||||
/// lower = SMA(raw_lo, period)
|
||||
/// ```
|
||||
///
|
||||
/// Headley's reference parameters are `period = 20`, `factor = 0.001` for
|
||||
/// intraday equity markets — the geometric `ratio` term tends to scale on
|
||||
/// fractional moves, so the literal `factor` is small. The bands compress in
|
||||
/// quiet markets and flare on impulsive bars, making them a momentum-biased
|
||||
/// alternative to the volatility-driven Bollinger or Keltner envelopes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AccelerationBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccelerationBands {
|
||||
upper_sma: Sma,
|
||||
middle_sma: Sma,
|
||||
lower_sma: Sma,
|
||||
factor: f64,
|
||||
period: usize,
|
||||
}
|
||||
|
||||
impl AccelerationBands {
|
||||
/// Construct a new Acceleration Bands indicator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
|
||||
/// and finite.
|
||||
pub fn new(period: usize, factor: f64) -> Result<Self> {
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
upper_sma: Sma::new(period)?,
|
||||
middle_sma: Sma::new(period)?,
|
||||
lower_sma: Sma::new(period)?,
|
||||
factor,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Headley's classic configuration: `period = 20`, `factor = 0.001`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, factor)`.
|
||||
pub const fn parameters(&self) -> (usize, f64) {
|
||||
(self.period, self.factor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AccelerationBands {
|
||||
type Input = Candle;
|
||||
type Output = AccelerationBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
|
||||
// (high + low) == 0 is geometrically impossible for valid OHLC
|
||||
// (high >= low and a zero-sum requires both equal to 0, which would
|
||||
// make the bar degenerate). Guard anyway so a hypothetical zero-price
|
||||
// bar collapses the ratio to zero rather than emitting NaN.
|
||||
let sum_hl = candle.high + candle.low;
|
||||
let ratio = if sum_hl == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(candle.high - candle.low) / sum_hl
|
||||
};
|
||||
let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
|
||||
let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
|
||||
|
||||
// Feed all three SMAs unconditionally so they warm up in lock-step.
|
||||
let upper = self.upper_sma.update(raw_up);
|
||||
let middle = self.middle_sma.update(candle.close);
|
||||
let lower = self.lower_sma.update(raw_lo);
|
||||
let (upper, middle, lower) = (upper?, middle?, lower?);
|
||||
Some(AccelerationBandsOutput {
|
||||
upper,
|
||||
middle,
|
||||
lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.upper_sma.reset();
|
||||
self.middle_sma.reset();
|
||||
self.lower_sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.middle_sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AccelerationBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(0, 0.001),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_factor() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AccelerationBands::classic();
|
||||
let (p, f) = ab.parameters();
|
||||
assert_eq!(p, 20);
|
||||
assert_relative_eq!(f, 0.001, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 20);
|
||||
assert_eq!(ab.name(), "AccelerationBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_to_constant() {
|
||||
// high == low so the ratio term is zero; all three SMAs converge to
|
||||
// the same constant.
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut ab = AccelerationBands::new(5, 0.001).unwrap();
|
||||
for i in 0..4 {
|
||||
let base = 100.0 + f64::from(i);
|
||||
assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
|
||||
}
|
||||
assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(20, 0.5).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
|
||||
assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AccelerationBands::new(10, 0.5).unwrap();
|
||||
let mut b = AccelerationBands::new(10, 0.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_price_candle_collapses_ratio_to_zero() {
|
||||
// `high + low == 0` is geometrically only reachable with a fully-zero
|
||||
// bar (high >= low and both non-negative for a real market, but
|
||||
// `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
|
||||
// ratio guard must fire and the bands all collapse to zero.
|
||||
let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(zero).unwrap();
|
||||
assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
/// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
|
||||
/// `close = 10`, `factor = 0.5`, `period = 1`.
|
||||
/// `ratio = (12 − 8) / (12 + 8) = 0.2`
|
||||
/// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
|
||||
/// `raw_lo = 8 · (1 − 0.5 · 0.2) = 8 · 0.9 = 7.2`
|
||||
/// `middle = SMA(close, 1) = 10`
|
||||
#[test]
|
||||
fn reference_value_single_bar() {
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
|
||||
assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Williams Accumulation/Distribution.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
|
||||
/// flow that classifies each bar as accumulation or distribution based on its
|
||||
/// close relative to the previous close, then sums the directional component.
|
||||
///
|
||||
/// Williams' definition (1972) uses a *true* high/low that includes the prior
|
||||
/// close as an anchor — the same idea that motivates true range:
|
||||
///
|
||||
/// ```text
|
||||
/// TR_h_t = max(close_{t−1}, high_t)
|
||||
/// TR_l_t = min(close_{t−1}, low_t)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_l_t) if close_t > close_{t−1} (accumulation)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_h_t) if close_t < close_{t−1} (distribution)
|
||||
/// AD_t = AD_{t−1} if close_t == close_{t−1} (no change)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
|
||||
/// volume entirely — Williams argued that the relative position of the close
|
||||
/// already encodes the day's "true" buying or selling pressure. The series is
|
||||
/// unbounded and used primarily for divergence analysis. The first candle only
|
||||
/// seeds the previous close; the first emission lands at bar 2.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdOscillator};
|
||||
///
|
||||
/// let mut indicator = AdOscillator::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdOscillator {
|
||||
prev_close: Option<f64>,
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdOscillator {
|
||||
/// Construct a new Williams A/D starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev_close: None,
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one emission has happened.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev_close else {
|
||||
// The first bar only establishes the previous close anchor.
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let delta = if candle.close > prev {
|
||||
// Accumulation: distance from the true low.
|
||||
let tr_l = prev.min(candle.low);
|
||||
candle.close - tr_l
|
||||
} else if candle.close < prev {
|
||||
// Distribution: distance from the true high (negative).
|
||||
let tr_h = prev.max(candle.high);
|
||||
candle.close - tr_h
|
||||
} else {
|
||||
// Unchanged close contributes nothing.
|
||||
0.0
|
||||
};
|
||||
self.total += delta;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed bar; the second bar is the first emission.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WilliamsAD"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 100.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ad = AdOscillator::new();
|
||||
assert_eq!(ad.name(), "WilliamsAD");
|
||||
assert_eq!(ad.warmup_period(), 2);
|
||||
assert_eq!(ad.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_returns_total_after_first_emission() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_only_seeds() {
|
||||
let mut ad = AdOscillator::new();
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
|
||||
assert!(!ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulation_adds_distance_from_true_low() {
|
||||
// prev close = 10, today low = 8, today close = 12 (up day).
|
||||
// TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_adds_distance_from_true_high() {
|
||||
// prev close = 10, today high = 11, today close = 7 (down day).
|
||||
// TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, -4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_keeps_total() {
|
||||
// close equals prev close -> no contribution.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Every close equals the previous -> AD stays at zero forever.
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ad = AdOscillator::new();
|
||||
for v in ad.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80i64)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
let mid = 100.0 + (f * 0.3).sin() * 5.0;
|
||||
c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AdOscillator::new();
|
||||
let mut b = AdOscillator::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.batch(&[
|
||||
c(10.0, 11.0, 9.0, 10.0, 0),
|
||||
c(10.0, 12.0, 9.0, 11.0, 1),
|
||||
c(11.0, 13.0, 10.0, 12.0, 2),
|
||||
]);
|
||||
assert!(ad.is_ready());
|
||||
ad.reset();
|
||||
assert!(!ad.is_ready());
|
||||
assert_eq!(ad.value(), None);
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
|
||||
|
||||
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Adaptive Cycle Indicator.
|
||||
///
|
||||
/// Returns half the current dominant cycle period — the "best" lookback for
|
||||
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
|
||||
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
|
||||
/// the fact that an oscillator over a half-cycle captures the full peak-to-
|
||||
/// trough swing without aliasing.
|
||||
///
|
||||
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
|
||||
/// matching the typical operating range of period-adaptive oscillators.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveCycle};
|
||||
///
|
||||
/// let mut ac = AdaptiveCycle::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdaptiveCycle {
|
||||
cycle: HilbertDominantCycle,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCycle {
|
||||
/// Construct a new adaptive cycle estimator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current adaptive period if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCycle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let period = self.cycle.update(input)?;
|
||||
let half = (period * 0.5).round().clamp(3.0, 25.0);
|
||||
self.last_value = Some(half);
|
||||
Some(half)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cycle.reset();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.cycle.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCycle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
assert_eq!(ac.warmup_period(), 50);
|
||||
assert_eq!(ac.name(), "AdaptiveCycle");
|
||||
assert!(!ac.is_ready());
|
||||
assert!(ac.value().is_none());
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
assert!(ac.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_clamp_band() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
for v in ac.batch(&prices).into_iter().flatten() {
|
||||
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
|
||||
assert_eq!(v, v.round(), "expected integer-valued output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = AdaptiveCycle::new();
|
||||
let mut b = AdaptiveCycle::new();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
let before = ac.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(ac.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Average Directional Movement Index Rating (ADXR).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::adx::Adx;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wilder's Average Directional Movement Index Rating.
|
||||
///
|
||||
/// `ADXR` smooths the [`Adx`] line by averaging its current value with the value
|
||||
/// it had `period` bars ago:
|
||||
///
|
||||
/// ```text
|
||||
/// ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2
|
||||
/// ```
|
||||
///
|
||||
/// The lookback length is the same `period` that feeds the underlying ADX.
|
||||
/// Wilder introduced ADXR alongside ADX in *New Concepts in Technical Trading
|
||||
/// Systems* (1978) as a more stable directional-strength reading: because the
|
||||
/// older `ADX` is `period - 1` bars stale, ADXR responds more slowly than ADX
|
||||
/// and is used to compare trend-strength between different instruments.
|
||||
///
|
||||
/// The first complete `ADXR` is emitted after `3 * period - 1` candles
|
||||
/// (`2 * period` to seed the ADX plus another `period - 1` to fill the
|
||||
/// lookback ring).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Adxr, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = Adxr::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Adxr {
|
||||
period: usize,
|
||||
adx: Adx,
|
||||
/// Ring buffer of the most recent `period` `ADX` values; the front is the
|
||||
/// oldest, the back is the newest. ADXR is `(back + front) / 2` once the
|
||||
/// ring is full.
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Adxr {
|
||||
/// Construct a new ADXR with the given Wilder smoothing period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
adx: Adx::new(period)?,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adxr {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let adx_value = self.adx.update(candle)?.adx;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(adx_value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let oldest = *self.window.front().expect("ring is full");
|
||||
let adxr = f64::midpoint(adx_value, oldest);
|
||||
self.last = Some(adxr);
|
||||
Some(adxr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.adx.reset();
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ADX warmup is `2 * period` and emits one `ADX` per subsequent candle;
|
||||
// the ADXR ring then needs `period - 1` more candles to fill, so the
|
||||
// first ADXR lands at `2 * period + (period - 1) = 3 * period - 1`.
|
||||
3 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADXR"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
|
||||
Candle::new(c, h, l, c, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Adxr::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut a = Adxr::new(14).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.warmup_period(), 41);
|
||||
assert_eq!(a.name(), "ADXR");
|
||||
assert!(a.value().is_none());
|
||||
// Drive past warmup.
|
||||
for i in 0..50_i64 {
|
||||
let base = 100.0 + (i as f64) * 2.0;
|
||||
a.update(candle(base + 1.0, base - 0.5, base + 0.5, i));
|
||||
}
|
||||
assert!(a.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_finite_positive_adxr() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64) * 2.0;
|
||||
candle(base + 1.0, base - 0.5, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(14).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0 && last <= 100.0 + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero_adxr() {
|
||||
let candles: Vec<Candle> = (0..50_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
let out = a.batch(&candles);
|
||||
let warmup = 3 * 5 - 1; // 14
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_against_explicit_adx_average() {
|
||||
// The first ADXR(p) emits at index `3p - 2` (0-based), and equals
|
||||
// (ADX[index] + ADX[index - (p - 1)]) / 2. Verify against a separate
|
||||
// ADX run.
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.2).sin() * 6.0;
|
||||
candle(p + 1.5, p - 1.5, p, i)
|
||||
})
|
||||
.collect();
|
||||
let period = 5;
|
||||
let mut adx = Adx::new(period).unwrap();
|
||||
let adx_out: Vec<_> = adx
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.map(|o| o.map(|x| x.adx))
|
||||
.collect();
|
||||
let mut adxr = Adxr::new(period).unwrap();
|
||||
let adxr_out = adxr.batch(&candles);
|
||||
// First ADXR index (0-based) = 3 * period - 2 = 13.
|
||||
let first = 3 * period - 2;
|
||||
let prev = first - (period - 1);
|
||||
let expected = f64::midpoint(adx_out[first].unwrap(), adx_out[prev].unwrap());
|
||||
assert_relative_eq!(adxr_out[first].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.25).sin() * 5.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(7).unwrap();
|
||||
let mut b = Adxr::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..60_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Bill Williams' Alligator indicator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::smma::Smma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Alligator output: three smoothed moving averages of the median price
|
||||
/// `(high + low) / 2`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AlligatorOutput {
|
||||
/// `Jaw` — the slowest line (default period 13).
|
||||
pub jaw: f64,
|
||||
/// `Teeth` — the middle line (default period 8).
|
||||
pub teeth: f64,
|
||||
/// `Lips` — the fastest line (default period 5).
|
||||
pub lips: f64,
|
||||
}
|
||||
|
||||
/// Bill Williams' Alligator: three `SMMA`s of the median price `(high + low) / 2`
|
||||
/// with different periods. Classic parameters are `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
///
|
||||
/// The original chart variant additionally shifts each line forward by a fixed
|
||||
/// number of bars for display (Jaw +8, Teeth +5, Lips +3). Wickra publishes the
|
||||
/// *unshifted* `SMMA` values — the consumer can apply the visual shift on the
|
||||
/// chart side. The indicator emits values once all three `SMMA`s have warmed
|
||||
/// up, i.e. after `max(jaw, teeth, lips) = jaw` candles.
|
||||
///
|
||||
/// Reference: Bill Williams, *Trading Chaos*, 1995.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Alligator, Candle, Indicator};
|
||||
///
|
||||
/// let mut alligator = Alligator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = alligator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alligator {
|
||||
jaw_period: usize,
|
||||
teeth_period: usize,
|
||||
lips_period: usize,
|
||||
jaw: Smma,
|
||||
teeth: Smma,
|
||||
lips: Smma,
|
||||
}
|
||||
|
||||
impl Alligator {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero.
|
||||
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
|
||||
if jaw_period == 0 || teeth_period == 0 || lips_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
jaw_period,
|
||||
teeth_period,
|
||||
lips_period,
|
||||
jaw: Smma::new(jaw_period)?,
|
||||
teeth: Smma::new(teeth_period)?,
|
||||
lips: Smma::new(lips_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(13, 8, 5).expect("classic Alligator parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(jaw_period, teeth_period, lips_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.jaw_period, self.teeth_period, self.lips_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alligator {
|
||||
type Input = Candle;
|
||||
type Output = AlligatorOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AlligatorOutput> {
|
||||
let median = f64::midpoint(candle.high, candle.low);
|
||||
// Feed every `SMMA` on every bar so they warm up in parallel; gating
|
||||
// the longer lines behind the shorter ones would starve them during
|
||||
// their own warmup.
|
||||
let lips = self.lips.update(median);
|
||||
let teeth = self.teeth.update(median);
|
||||
let jaw = self.jaw.update(median);
|
||||
Some(AlligatorOutput {
|
||||
jaw: jaw?,
|
||||
teeth: teeth?,
|
||||
lips: lips?,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.jaw.reset();
|
||||
self.teeth.reset();
|
||||
self.lips.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// All three SMMAs run on every bar, so readiness is gated by the
|
||||
// longest period — the Jaw with the default parameters.
|
||||
self.jaw_period.max(self.teeth_period).max(self.lips_period)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.jaw.is_ready() && self.teeth.is_ready() && self.lips.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Alligator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let close = f64::midpoint(high, low);
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Alligator::new(0, 8, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Alligator::new(13, 0, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Alligator::new(13, 8, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let alligator = Alligator::classic();
|
||||
assert_eq!(alligator.periods(), (13, 8, 5));
|
||||
assert_eq!(alligator.warmup_period(), 13);
|
||||
assert_eq!(alligator.name(), "Alligator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// Median price = 10 for every bar, so each SMMA seeds to 10 and stays.
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = alligator.batch(&candles);
|
||||
for v in out.iter().skip(12).flatten() {
|
||||
assert_relative_eq!(v.jaw, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.teeth, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lips, 10.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_longest_period() {
|
||||
let mut alligator = Alligator::new(5, 3, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = alligator.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_ordering() {
|
||||
// On a clean uptrend the fastest line (Lips, smallest SMMA) leads the
|
||||
// slowest line (Jaw) — lips > teeth > jaw at the latest bar.
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0_i64..80)
|
||||
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
|
||||
.collect();
|
||||
let out = alligator.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(
|
||||
last.lips > last.teeth,
|
||||
"lips {} > teeth {}",
|
||||
last.lips,
|
||||
last.teeth
|
||||
);
|
||||
assert!(
|
||||
last.teeth > last.jaw,
|
||||
"teeth {} > jaw {}",
|
||||
last.teeth,
|
||||
last.jaw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 1.0, base - 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Alligator::classic();
|
||||
let mut b = Alligator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
alligator.batch(&candles);
|
||||
assert!(alligator.is_ready());
|
||||
alligator.reset();
|
||||
assert!(!alligator.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Arnaud Legoux Moving Average (ALMA).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average.
|
||||
///
|
||||
/// Each output is a weighted sum of the last `period` inputs:
|
||||
///
|
||||
/// ```text
|
||||
/// w[i] = exp(-(i - m)^2 / (2 * s^2)) for i in 0..period
|
||||
/// m = offset * (period - 1)
|
||||
/// s = period / sigma
|
||||
/// ALMA = sum(price[i] * w[i]) / sum(w[i])
|
||||
/// ```
|
||||
///
|
||||
/// The Gaussian is centred on the relative index `offset * (period - 1)`, so
|
||||
/// `offset = 0.85` puts the peak near the newest sample (responsive), while
|
||||
/// `offset = 0.5` centres the peak in the middle of the window (smooth).
|
||||
/// `sigma` controls how concentrated the Gaussian is: larger `sigma` ->
|
||||
/// narrower kernel, smaller `sigma` -> broader (closer to SMA).
|
||||
///
|
||||
/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009.
|
||||
///
|
||||
/// # Defaults
|
||||
///
|
||||
/// The community-standard parameters are `period = 9`, `offset = 0.85`,
|
||||
/// `sigma = 6.0`. The first output lands after exactly `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Alma, Indicator};
|
||||
///
|
||||
/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = alma.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alma {
|
||||
period: usize,
|
||||
offset: f64,
|
||||
sigma: f64,
|
||||
/// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest
|
||||
/// sample in the window, `weights[period - 1]` the newest.
|
||||
weights: Vec<f64>,
|
||||
window: VecDeque<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Alma {
|
||||
/// Construct a new ALMA with the given period, offset and sigma.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::PeriodZero`] if `period == 0`.
|
||||
/// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or
|
||||
/// `sigma <= 0.0` or either of `offset` / `sigma` is non-finite.
|
||||
pub fn new(period: usize, offset: f64, sigma: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !offset.is_finite() || !(0.0..=1.0).contains(&offset) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "ALMA offset must be a finite value in [0, 1]",
|
||||
});
|
||||
}
|
||||
if !sigma.is_finite() || sigma <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "ALMA sigma must be a finite positive value",
|
||||
});
|
||||
}
|
||||
let m = offset * (period as f64 - 1.0);
|
||||
let s = period as f64 / sigma;
|
||||
let denom = 2.0 * s * s;
|
||||
// The raw Gaussian weights sum to a strictly positive value because
|
||||
// every term is `exp(_) > 0`, so the normalisation below cannot divide
|
||||
// by zero.
|
||||
let mut raw: Vec<f64> = (0..period)
|
||||
.map(|i| (-((i as f64 - m).powi(2)) / denom).exp())
|
||||
.collect();
|
||||
let sum: f64 = raw.iter().sum();
|
||||
for w in &mut raw {
|
||||
*w /= sum;
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
offset,
|
||||
sigma,
|
||||
weights: raw,
|
||||
window: VecDeque::with_capacity(period),
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct ALMA with the community-standard parameters
|
||||
/// `(period = 9, offset = 0.85, sigma = 6.0)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured offset.
|
||||
pub const fn offset(&self) -> f64 {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// Configured sigma.
|
||||
pub const fn sigma(&self) -> f64 {
|
||||
self.sigma
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alma {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut acc = 0.0;
|
||||
for (w, p) in self.weights.iter().zip(self.window.iter()) {
|
||||
acc += w * p;
|
||||
}
|
||||
self.current = Some(acc);
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ALMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_offset() {
|
||||
assert!(matches!(
|
||||
Alma::new(9, -0.1, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 1.1, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, f64::NAN, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_sigma() {
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, -1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, f64::INFINITY),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
assert_eq!(alma.period(), 9);
|
||||
assert_eq!(alma.warmup_period(), 9);
|
||||
assert_eq!(alma.name(), "ALMA");
|
||||
assert!((alma.offset() - 0.85).abs() < 1e-12);
|
||||
assert!((alma.sigma() - 6.0).abs() < 1e-12);
|
||||
// Weights are normalised by construction.
|
||||
let sum: f64 = alma.weights.iter().sum();
|
||||
assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
let a = Alma::classic();
|
||||
assert_eq!(a.period(), 9);
|
||||
assert!((a.offset() - 0.85).abs() < 1e-12);
|
||||
assert!((a.sigma() - 6.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// Normalised weights sum to 1, so any constant is reproduced exactly.
|
||||
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
let out = alma.batch(&[42.0_f64; 40]);
|
||||
for v in out.iter().skip(8).flatten() {
|
||||
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
|
||||
for i in 0..4 {
|
||||
assert_eq!(alma.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(alma.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_period_3() {
|
||||
// ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
|
||||
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
// Independently compute the normalised Gaussian weights and the
|
||||
// expected weighted sum, then check the indicator output matches.
|
||||
// Computing the expectation here (rather than pinning a printed
|
||||
// constant) keeps the test stable across libm `exp` implementations.
|
||||
let mut alma = Alma::new(3, 0.85, 6.0).unwrap();
|
||||
alma.update(10.0);
|
||||
alma.update(20.0);
|
||||
let v = alma.update(30.0).expect("ALMA emits after period");
|
||||
|
||||
let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let s = w0 + w1 + w2;
|
||||
let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s;
|
||||
|
||||
// The weighted sum is heavily skewed toward the newest sample so the
|
||||
// output must sit close to but below the latest input (30).
|
||||
assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}");
|
||||
assert_relative_eq!(v, expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_zero_centres_on_oldest_sample() {
|
||||
// With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward
|
||||
// the oldest sample in the window and away from the newest.
|
||||
let mut alma = Alma::new(5, 0.0, 6.0).unwrap();
|
||||
let series: Vec<f64> = (1..=5).map(f64::from).collect();
|
||||
let mut last = None;
|
||||
for p in &series {
|
||||
last = alma.update(*p);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
let mean = series.iter().sum::<f64>() / series.len() as f64;
|
||||
// Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit
|
||||
// strictly below the mean.
|
||||
assert!(v < mean, "{v} should be less than {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_one_centres_on_newest_sample() {
|
||||
// Symmetric to the above: offset = 1 leans toward the newest sample.
|
||||
let mut alma = Alma::new(5, 1.0, 6.0).unwrap();
|
||||
let series: Vec<f64> = (1..=5).map(f64::from).collect();
|
||||
let mut last = None;
|
||||
for p in &series {
|
||||
last = alma.update(*p);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
let mean = series.iter().sum::<f64>() / series.len() as f64;
|
||||
assert!(v > mean, "{v} should exceed {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut a = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
let mut b = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
alma.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(alma.is_ready());
|
||||
alma.reset();
|
||||
assert!(!alma.is_ready());
|
||||
assert_eq!(alma.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
|
||||
alma.batch(&(1..=5).map(f64::from).collect::<Vec<_>>());
|
||||
let before = alma.update(6.0).unwrap();
|
||||
// Non-finite inputs leave the window/current untouched.
|
||||
assert_eq!(alma.update(f64::NAN), Some(before));
|
||||
assert_eq!(alma.update(f64::INFINITY), Some(before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Rolling Jensen's Alpha (CAPM).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Jensen's Alpha.
|
||||
///
|
||||
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
|
||||
/// the trailing window of `period` pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// Beta = cov(asset, bench) / var(bench)
|
||||
/// Alpha = mean(asset) − ( risk_free + Beta · (mean(bench) − risk_free) )
|
||||
/// ```
|
||||
///
|
||||
/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
|
||||
/// performance that cannot be explained by simple exposure to the
|
||||
/// benchmark. A positive alpha indicates outperformance net of the market
|
||||
/// premium implied by the asset's beta; negative alpha is the opposite.
|
||||
///
|
||||
/// Population covariance and variance are used (matching common
|
||||
/// implementations in pandas-ta / quantstats); the rolling estimator stays
|
||||
/// unbiased in the steady state for fixed `period`.
|
||||
///
|
||||
/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
|
||||
/// `alpha = mean(asset) − risk_free` — the asset's mean excess return, with
|
||||
/// no market-risk adjustment, since the regression slope is undefined.
|
||||
///
|
||||
/// Each `update` is O(1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alpha {
|
||||
period: usize,
|
||||
risk_free: f64,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl Alpha {
|
||||
/// Construct a new rolling Alpha.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "alpha needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
risk_free,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured per-period risk-free rate.
|
||||
pub const fn risk_free(&self) -> f64 {
|
||||
self.risk_free
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alpha {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
if var_b <= 0.0 {
|
||||
// Undefined beta: report unadjusted excess.
|
||||
return Some(mean_a - self.risk_free);
|
||||
}
|
||||
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
|
||||
let beta = cov_ab / var_b;
|
||||
Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
|
||||
}
|
||||
|
||||
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 {
|
||||
"Alpha"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
Alpha::new(1, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Alpha::new(20, 0.001).unwrap();
|
||||
assert_eq!(a.period(), 20);
|
||||
assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
|
||||
assert_eq!(a.name(), "Alpha");
|
||||
assert_eq!(a.warmup_period(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capm_perfect_fit_yields_zero_alpha() {
|
||||
// asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
|
||||
// CAPM-implied return matches the asset's mean perfectly.
|
||||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||||
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
|
||||
.collect();
|
||||
let out = a.batch(&inputs);
|
||||
assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_alpha_offset_recovered() {
|
||||
// asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
|
||||
// Expected alpha = 0.005.
|
||||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||||
.map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
|
||||
.collect();
|
||||
let out = a.batch(&inputs);
|
||||
assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_benchmark_falls_back_to_excess_return() {
|
||||
// Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
|
||||
let mut a = Alpha::new(4, 0.001).unwrap();
|
||||
let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
|
||||
let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
|
||||
assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||||
assert_eq!(a.update((f64::NAN, 0.0)), None);
|
||||
assert_eq!(a.update((0.0, f64::INFINITY)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||||
a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update((0.01, 0.005)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<(f64, f64)> = (0..50)
|
||||
.map(|i| {
|
||||
let b = (f64::from(i) * 0.2).sin() * 0.01;
|
||||
(1.5 * b + 0.002, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
|
||||
let mut s = Alpha::new(10, 0.0).unwrap();
|
||||
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Anchored Volume-Weighted Average Price.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a
|
||||
/// user-chosen anchor bar rather than the session open.
|
||||
///
|
||||
/// ```text
|
||||
/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i
|
||||
/// ```
|
||||
///
|
||||
/// The indicator emits `None` until the first anchored bar has been ingested.
|
||||
/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that
|
||||
/// arrives, clearing the running sums; this is the conventional behaviour for
|
||||
/// "click to anchor" trader workflows where the anchor is set on the close of
|
||||
/// a swing point and the next bar starts the new accumulation. The cumulative
|
||||
/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`].
|
||||
///
|
||||
/// Bars where the running volume is still zero (only happens if every anchored
|
||||
/// bar so far carried zero volume) return `None` to avoid a zero-division.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AnchoredVwap, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AnchoredVwap::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// // Re-anchor at bar 40 (e.g. a major swing low).
|
||||
/// if i == 40 {
|
||||
/// indicator.set_anchor();
|
||||
/// }
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AnchoredVwap {
|
||||
sum_pv: f64,
|
||||
sum_v: f64,
|
||||
has_emitted: bool,
|
||||
pending_anchor: bool,
|
||||
}
|
||||
|
||||
impl AnchoredVwap {
|
||||
/// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
sum_pv: 0.0,
|
||||
sum_v: 0.0,
|
||||
has_emitted: false,
|
||||
pending_anchor: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
|
||||
/// running sums before adding its own contribution, effectively starting a
|
||||
/// fresh anchored window.
|
||||
pub fn set_anchor(&mut self) {
|
||||
self.pending_anchor = true;
|
||||
}
|
||||
|
||||
/// Current anchored value if at least one bar with non-zero volume has
|
||||
/// been observed in the current anchor window.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.sum_v == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(self.sum_pv / self.sum_v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AnchoredVwap {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.pending_anchor {
|
||||
// Drop the old window before folding in this bar.
|
||||
self.sum_pv = 0.0;
|
||||
self.sum_v = 0.0;
|
||||
self.has_emitted = false;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
let tp = candle.typical_price();
|
||||
self.sum_pv += tp * candle.volume;
|
||||
self.sum_v += candle.volume;
|
||||
if self.sum_v == 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.has_emitted = true;
|
||||
Some(self.sum_pv / self.sum_v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sum_pv = 0.0;
|
||||
self.sum_v = 0.0;
|
||||
self.has_emitted = false;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AnchoredVWAP"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(price: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price, price, price, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let v = AnchoredVwap::new();
|
||||
assert_eq!(v.name(), "AnchoredVWAP");
|
||||
assert_eq!(v.warmup_period(), 1);
|
||||
assert_eq!(v.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_with_zero_volume_returns_none() {
|
||||
let mut v = AnchoredVwap::new();
|
||||
assert_eq!(v.update(c(50.0, 0.0, 0)), None);
|
||||
assert!(!v.is_ready());
|
||||
// The next bar with volume still works.
|
||||
assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_volumes_yield_mean_typical_price() {
|
||||
// typical_price of a flat OHLC bar equals the price.
|
||||
let mut v = AnchoredVwap::new();
|
||||
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]);
|
||||
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_clears_old_window() {
|
||||
// Run a few bars at price 10, then re-anchor and pump in price 100.
|
||||
// After the re-anchor the running mean must be 100, not the mix.
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]);
|
||||
assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12);
|
||||
v.set_anchor();
|
||||
let after = v.update(c(100.0, 5.0, 3)).unwrap();
|
||||
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_before_first_bar_acts_as_normal_first_bar() {
|
||||
// Calling set_anchor on an empty indicator should be a no-op effect:
|
||||
// the first bar still anchors the window.
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.set_anchor();
|
||||
assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_average_reference() {
|
||||
// Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5.
|
||||
let mut v = AnchoredVwap::new();
|
||||
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]);
|
||||
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect();
|
||||
let mut a = AnchoredVwap::new();
|
||||
let mut b = AnchoredVwap::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]);
|
||||
assert!(v.is_ready());
|
||||
v.reset();
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
// After reset the first bar acts as the new anchor.
|
||||
assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Absolute Price Oscillator (APO).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Absolute Price Oscillator — the raw difference between a fast and a slow
|
||||
/// `EMA`. This is MACD's line without the signal-EMA — useful when only the
|
||||
/// momentum-direction reading is needed.
|
||||
///
|
||||
/// ```text
|
||||
/// APO_t = EMA(close, fast)_t − EMA(close, slow)_t
|
||||
/// ```
|
||||
///
|
||||
/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be
|
||||
/// strictly less than `slow`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Apo, Indicator};
|
||||
///
|
||||
/// let mut apo = Apo::new(12, 26).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = apo.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Apo {
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
fast: Ema,
|
||||
slow: Ema,
|
||||
}
|
||||
|
||||
impl Apo {
|
||||
/// # Errors
|
||||
/// - [`Error::PeriodZero`] if either period is zero.
|
||||
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "APO fast period must be strictly less than slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
fast: Ema::new(fast)?,
|
||||
slow: Ema::new(slow)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// MACD-style defaults: `(fast = 12, slow = 26)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26).expect("classic APO parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast, slow)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.fast_period, self.slow_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Apo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Feed both EMAs on every input so the slow one warms in parallel.
|
||||
let f = self.fast.update(input);
|
||||
let s = self.slow.update(input);
|
||||
Some(f? - s?)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Slow EMA dominates; both EMAs emit at their `period` th input.
|
||||
self.slow_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.slow.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"APO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fast_geq_slow() {
|
||||
assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let apo = Apo::classic();
|
||||
assert_eq!(apo.periods(), (12, 26));
|
||||
assert_eq!(apo.warmup_period(), 26);
|
||||
assert_eq!(apo.name(), "APO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(Apo::classic().periods(), (12, 26));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
// Both EMAs reproduce the constant exactly, so APO is 0.
|
||||
let mut apo = Apo::new(3, 5).unwrap();
|
||||
let out = apo.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_slow_period() {
|
||||
let mut apo = Apo::new(2, 4).unwrap();
|
||||
assert_eq!(apo.warmup_period(), 4);
|
||||
for i in 1..=3 {
|
||||
assert_eq!(apo.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(apo.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
// Fast EMA leads the slow EMA on an uptrend, so APO > 0.
|
||||
let mut apo = Apo::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
let out = apo.batch(&prices);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(*last > 0.0, "APO on uptrend should be positive: {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Apo::classic();
|
||||
let mut b = Apo::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut apo = Apo::classic();
|
||||
apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(apo.is_ready());
|
||||
apo.reset();
|
||||
assert!(!apo.is_ready());
|
||||
assert_eq!(apo.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! ATR Bands.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtrBandsOutput {
|
||||
/// Upper band: `close + multiplier · ATR`.
|
||||
pub upper: f64,
|
||||
/// Middle band: the current close.
|
||||
pub middle: f64,
|
||||
/// Lower band: `close − multiplier · ATR`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
|
||||
///
|
||||
/// ```text
|
||||
/// upper = close + multiplier · ATR(period)
|
||||
/// lower = close − multiplier · ATR(period)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
|
||||
/// centerline is the *raw close* rather than a smoothed average — the band
|
||||
/// rides the price tick-for-tick. This is the standard volatility-targeting
|
||||
/// envelope traders use to set initial stop-loss and profit targets: an entry
|
||||
/// at the close sets a `multiplier · ATR` stop and the symmetric target
|
||||
/// without ever needing to wait for a moving average to warm up.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AtrBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrBands {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
}
|
||||
|
||||
impl AtrBands {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
|
||||
/// invalid inputs.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(period)?,
|
||||
multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured ATR period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.atr.period()
|
||||
}
|
||||
|
||||
/// Configured ATR multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrBands {
|
||||
type Input = Candle;
|
||||
type Output = AtrBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
Some(AtrBandsOutput {
|
||||
upper: candle.close + self.multiplier * atr,
|
||||
middle: candle.close,
|
||||
lower: candle.close - self.multiplier * atr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.atr.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, f64::INFINITY),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AtrBands::new(14, 3.0).unwrap();
|
||||
assert_eq!(ab.period(), 14);
|
||||
assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 14);
|
||||
assert_eq!(ab.name(), "AtrBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_bands() {
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(14, 3.0).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle);
|
||||
assert!(o.middle >= o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AtrBands::new(10, 2.5).unwrap();
|
||||
let mut b = AtrBands::new(10, 2.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// Reference: with constant high-low spread of 2, ATR(period) converges to
|
||||
/// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
|
||||
#[test]
|
||||
fn reference_values_constant_spread() {
|
||||
// Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
|
||||
let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let out = ab.batch(&candles);
|
||||
assert!(out[0].is_none() && out[3].is_none());
|
||||
let v = out[4].unwrap();
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Rolling lag-`k` autocorrelation.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
|
||||
///
|
||||
/// Over the trailing window the Pearson correlation between the series and
|
||||
/// itself shifted by `lag` is computed:
|
||||
///
|
||||
/// ```text
|
||||
/// y_i for i = 0..period − 1
|
||||
/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )²
|
||||
/// ```
|
||||
///
|
||||
/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a
|
||||
/// perfect alternation. Values near `0` mean the series at `t` and `t −
|
||||
/// lag` carry no linear relationship — a clean white-noise proxy. The
|
||||
/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
|
||||
/// flags a cycle of that length) or testing whether returns are
|
||||
/// uncorrelated (a key efficient-markets diagnostic).
|
||||
///
|
||||
/// `period` must be strictly greater than `lag` so that at least two
|
||||
/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
|
||||
/// indicator returns `0` rather than dividing by zero.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Autocorrelation, Indicator};
|
||||
///
|
||||
/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Autocorrelation {
|
||||
period: usize,
|
||||
lag: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl Autocorrelation {
|
||||
/// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
|
||||
pub fn new(period: usize, lag: usize) -> Result<Self> {
|
||||
if lag == 0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation lag must be >= 1",
|
||||
});
|
||||
}
|
||||
if period <= lag {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation needs period > lag",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
lag,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured lag.
|
||||
pub const fn lag(&self) -> usize {
|
||||
self.lag
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Autocorrelation {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// ACF over the current window with a single inner pass. The window is
|
||||
// small relative to a typical input stream so the O(period) per-bar
|
||||
// cost is bounded by the user-chosen `period`; the constant factor
|
||||
// is dominated by two adds and one multiply per element.
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut denom = 0.0;
|
||||
let mut numer = 0.0;
|
||||
// The window is a deque; index via slices for cache-friendly access.
|
||||
let (front, back) = self.window.as_slices();
|
||||
let get = |i: usize| -> f64 {
|
||||
if i < front.len() {
|
||||
front[i]
|
||||
} else {
|
||||
back[i - front.len()]
|
||||
}
|
||||
};
|
||||
for i in 0..self.period {
|
||||
let d = get(i) - mean;
|
||||
denom += d * d;
|
||||
}
|
||||
let lag = self.lag;
|
||||
for i in 0..(self.period - lag) {
|
||||
numer += (get(i) - mean) * (get(i + lag) - mean);
|
||||
}
|
||||
if denom == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(numer / denom)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Autocorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_lag() {
|
||||
assert!(Autocorrelation::new(10, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_lag_geq_period() {
|
||||
assert!(Autocorrelation::new(5, 5).is_err());
|
||||
assert!(Autocorrelation::new(5, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Autocorrelation::new(14, 2).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.lag(), 2);
|
||||
assert_eq!(a.warmup_period(), 14);
|
||||
assert_eq!(a.name(), "Autocorrelation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
for v in a.batch(&[42.0; 30]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_series_lag_one_is_strongly_negative() {
|
||||
// [−1, 1, −1, 1, …] alternates each step.
|
||||
let prices: Vec<f64> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
|
||||
.collect();
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < -0.5,
|
||||
"alternating series should be strongly negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_series_is_strongly_positive_at_period() {
|
||||
// A series that repeats every 4 steps must have ACF(4) ≈ +1.
|
||||
let pattern = [1.0, 2.0, 3.0, 4.0];
|
||||
let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
|
||||
let mut a = Autocorrelation::new(16, 4).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.5,
|
||||
"period-4 repeat should ACF(4) > 0.5, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = Autocorrelation::new(5, 1).unwrap();
|
||||
a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
|
||||
let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
|
||||
let mut b = Autocorrelation::new(14, 2).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Rolling Average Drawdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Average Drawdown.
|
||||
///
|
||||
/// Input is treated as an equity-curve sample. The indicator scans the
|
||||
/// trailing window of `period` values, tracks the running peak inside the
|
||||
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
|
||||
/// "pain" of being under water):
|
||||
///
|
||||
/// ```text
|
||||
/// drawdown_t = (peak_t − equity_t) / peak_t (running peak inside window)
|
||||
/// AvgDD = mean(drawdown_t over window)
|
||||
/// ```
|
||||
///
|
||||
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
|
||||
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
|
||||
/// for the same metric exposed under its conventional label.
|
||||
///
|
||||
/// Each `update` is O(period).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AverageDrawdown {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl AverageDrawdown {
|
||||
/// Construct a new rolling Average Drawdown.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AverageDrawdown {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut peak = f64::NEG_INFINITY;
|
||||
let mut sum_dd = 0.0_f64;
|
||||
for &v in &self.window {
|
||||
if v > peak {
|
||||
peak = v;
|
||||
}
|
||||
if peak > 0.0 {
|
||||
sum_dd += (peak - v) / peak;
|
||||
}
|
||||
}
|
||||
Some(sum_dd / self.period as f64)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AverageDrawdown"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = AverageDrawdown::new(10).unwrap();
|
||||
assert_eq!(a.period(), 10);
|
||||
assert_eq!(a.name(), "AverageDrawdown");
|
||||
assert_eq!(a.warmup_period(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_zero() {
|
||||
let mut a = AverageDrawdown::new(5).unwrap();
|
||||
let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// window [100, 120, 90, 110]:
|
||||
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
|
||||
// avg = (.25 + .0833...) / 4 = .0833...
|
||||
let mut a = AverageDrawdown::new(4).unwrap();
|
||||
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
|
||||
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
|
||||
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
assert_eq!(a.update(f64::NAN), None);
|
||||
assert_eq!(a.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
a.batch(&[100.0, 90.0, 110.0]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(100.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
|
||||
let mut s = AverageDrawdown::new(10).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_positive_peak_yields_zero() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
let out = a.batch(&[0.0_f64; 6]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Awesome Oscillator Histogram.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::awesome_oscillator::AwesomeOscillator;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// "Awesome Oscillator Histogram" — the difference between the Awesome
|
||||
/// Oscillator and its `sma_period`-bar `SMA`. Positive bars mean `AO` is
|
||||
/// trending up (bullish acceleration); negative bars mean `AO` is trending
|
||||
/// down (bearish acceleration).
|
||||
///
|
||||
/// ```text
|
||||
/// AO = SMA(median, fast) − SMA(median, slow)
|
||||
/// AOHist = AO − SMA(AO, sma_period)
|
||||
/// ```
|
||||
///
|
||||
/// With Williams' default `sma_period = 5`, this collapses to the existing
|
||||
/// `AcceleratorOscillator` for `fast = 5, slow = 34, sma_period = 5`; for any
|
||||
/// other parameterisation this is a more flexible variant.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AwesomeOscillatorHistogram, Candle, Indicator};
|
||||
///
|
||||
/// let mut hist = AwesomeOscillatorHistogram::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 0.5, p - 0.5, p, 1.0, i64::from(i)).unwrap();
|
||||
/// last = hist.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AwesomeOscillatorHistogram {
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
sma_period: usize,
|
||||
ao: AwesomeOscillator,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl AwesomeOscillatorHistogram {
|
||||
/// # Errors
|
||||
/// - [`Error::PeriodZero`] if any period is zero.
|
||||
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 || sma_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "AwesomeOscillatorHistogram fast must be strictly less than slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
sma_period,
|
||||
ao: AwesomeOscillator::new(fast, slow)?,
|
||||
sma: Sma::new(sma_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' Accelerator-equivalent defaults `(5, 34, 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 34, 5).expect("classic Awesome Oscillator Histogram parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast_period, slow_period, sma_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.fast_period, self.slow_period, self.sma_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AwesomeOscillatorHistogram {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ao = self.ao.update(candle)?;
|
||||
let sma = self.sma.update(ao)?;
|
||||
Some(ao - sma)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ao.reset();
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// AO emits at `slow` candles; the SMA then needs `sma_period - 1`
|
||||
// more AO values to fill its window.
|
||||
self.slow_period + self.sma_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AwesomeOscillatorHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(price: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price + 0.5, price - 0.5, price, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(0, 34, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(5, 0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(5, 34, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fast_geq_slow() {
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(34, 5, 5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hist = AwesomeOscillatorHistogram::classic();
|
||||
assert_eq!(hist.periods(), (5, 34, 5));
|
||||
assert_eq!(hist.warmup_period(), 38);
|
||||
assert_eq!(hist.name(), "AwesomeOscillatorHistogram");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
// AO of a flat series is 0; SMA of 0 is 0; difference is 0.
|
||||
let mut hist = AwesomeOscillatorHistogram::new(3, 5, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, i)).collect();
|
||||
let out = hist.batch(&candles);
|
||||
for v in out.iter().skip(hist.warmup_period() - 1).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut hist = AwesomeOscillatorHistogram::new(2, 4, 3).unwrap();
|
||||
assert_eq!(hist.warmup_period(), 6);
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
|
||||
.collect();
|
||||
let out = hist.batch(&candles);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..100_i64)
|
||||
.map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i))
|
||||
.collect();
|
||||
let batch = AwesomeOscillatorHistogram::classic().batch(&candles);
|
||||
let mut b = AwesomeOscillatorHistogram::classic();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hist = AwesomeOscillatorHistogram::classic();
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
|
||||
.collect();
|
||||
hist.batch(&candles);
|
||||
assert!(hist.is_ready());
|
||||
hist.reset();
|
||||
assert!(!hist.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Rolling Beta — sensitivity of an asset to a benchmark.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Beta of an `asset` series relative to a `benchmark` series.
|
||||
///
|
||||
/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing
|
||||
/// window of `period` pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// cov_ab = (1/n) · Σ a·b − ā·b̄
|
||||
/// var_b = (1/n) · Σ b² − b̄²
|
||||
/// Beta = cov_ab / var_b
|
||||
/// ```
|
||||
///
|
||||
/// Beta measures how much the asset moves for a unit move in the
|
||||
/// benchmark. A reading of `1.0` means the two move together one-for-one;
|
||||
/// `2.0` means the asset typically doubles the benchmark's moves;
|
||||
/// `0.5` means it moves only half as much; `0.0` means moves are
|
||||
/// uncorrelated; negative Betas signal a hedge. It is the slope of the
|
||||
/// OLS regression of the asset on the benchmark and the foundation of the
|
||||
/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free —
|
||||
/// it carries the ratio of standard deviations.
|
||||
///
|
||||
/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`)
|
||||
/// are maintained as the window slides. A flat benchmark window has zero
|
||||
/// variance and Beta is undefined; the indicator returns `0` in that
|
||||
/// case rather than producing `NaN`.
|
||||
///
|
||||
/// Conventionally Beta is computed on **returns** (typically log-returns)
|
||||
/// rather than raw prices; feed the indicator pre-computed returns if
|
||||
/// that is your convention. The pure rolling OLS slope is the same
|
||||
/// either way.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Beta, Indicator};
|
||||
///
|
||||
/// let mut indicator = Beta::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // Asset doubles every benchmark move.
|
||||
/// last = indicator.update((2.0 * f64::from(i), f64::from(i)));
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Beta {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl Beta {
|
||||
/// Construct a new rolling Beta.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "beta needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Beta {
|
||||
/// `(asset, benchmark)` pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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 cov = self.sum_ab / n - mean_a * mean_b;
|
||||
if var_b == 0.0 {
|
||||
// A flat benchmark has no defined beta.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(cov / var_b)
|
||||
}
|
||||
|
||||
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 {
|
||||
"Beta"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(Beta::new(0).is_err());
|
||||
assert!(Beta::new(1).is_err());
|
||||
assert!(Beta::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let b = Beta::new(14).unwrap();
|
||||
assert_eq!(b.period(), 14);
|
||||
assert_eq!(b.warmup_period(), 14);
|
||||
assert_eq!(b.name(), "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_two_to_one_relationship() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10)
|
||||
.map(|i| (2.0 * f64::from(i), f64::from(i)))
|
||||
.collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_negative_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_benchmark_yields_zero() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut b = Beta::new(5).unwrap();
|
||||
b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
|
||||
assert!(b.is_ready());
|
||||
b.reset();
|
||||
assert!(!b.is_ready());
|
||||
assert_eq!(b.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 2.0 + 0.3 * t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
let batch = Beta::new(14).unwrap().batch(&pairs);
|
||||
let mut b = Beta::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Rolling Calmar Ratio — return over max drawdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Calmar Ratio.
|
||||
///
|
||||
/// Input is treated as a single period return. Over the trailing window of
|
||||
/// `period` returns the indicator reconstructs the implied equity curve
|
||||
/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
|
||||
/// divides the mean return by that drawdown:
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = ∏(1 + r_i) for i in window up to t
|
||||
/// mdd = max peak-to-trough decline of equity over window
|
||||
/// Calmar = mean(returns) / mdd
|
||||
/// ```
|
||||
///
|
||||
/// If the drawdown is zero (monotonically non-decreasing equity in the
|
||||
/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
|
||||
///
|
||||
/// The equity curve is recomputed inside the window each `update`, which
|
||||
/// keeps each call O(period) — acceptable for typical backtest windows
|
||||
/// (`period ≤ 252`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CalmarRatio, Indicator};
|
||||
///
|
||||
/// let mut cr = CalmarRatio::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalmarRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
}
|
||||
|
||||
impl CalmarRatio {
|
||||
/// Construct a new rolling Calmar Ratio.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "calmar ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CalmarRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
// Build equity curve and track the worst peak-to-trough drawdown.
|
||||
let mut equity = 1.0_f64;
|
||||
let mut peak = 1.0_f64;
|
||||
let mut mdd = 0.0_f64;
|
||||
for &r in &self.window {
|
||||
equity *= 1.0 + r;
|
||||
if equity > peak {
|
||||
peak = equity;
|
||||
}
|
||||
// peak starts at 1.0 and never decreases, so peak > 0 by construction.
|
||||
let dd = (peak - equity) / peak;
|
||||
if dd > mdd {
|
||||
mdd = dd;
|
||||
}
|
||||
}
|
||||
if mdd == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(mean / mdd)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CalmarRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
CalmarRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = CalmarRatio::new(10).unwrap();
|
||||
assert_eq!(c.period(), 10);
|
||||
assert_eq!(c.name(), "CalmarRatio");
|
||||
assert_eq!(c.warmup_period(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_zero() {
|
||||
// All positive returns -> no drawdown -> Calmar = 0 by convention.
|
||||
let mut c = CalmarRatio::new(5).unwrap();
|
||||
let out = c.batch(&[0.01; 10]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns = [0.10, -0.20, 0.05]
|
||||
// equity: 1.0 -> 1.10 -> 0.88 -> 0.924
|
||||
// peak 1.10, trough 0.88 -> mdd = 0.20.
|
||||
// mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
|
||||
// Calmar = -0.01666... / 0.20 ≈ -0.08333...
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
let out = c.batch(&[0.10, -0.20, 0.05]);
|
||||
let mean = (0.10 - 0.20 + 0.05) / 3.0;
|
||||
let expected = mean / 0.20;
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
assert_eq!(c.update(f64::NAN), None);
|
||||
assert_eq!(c.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
c.batch(&[0.10, -0.20, 0.05]);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let returns: Vec<f64> = (0..50)
|
||||
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = CalmarRatio::new(10).unwrap().batch(&returns);
|
||||
let mut s = CalmarRatio::new(10).unwrap();
|
||||
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Camarilla Pivot Points (Nick Stott).
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Camarilla Pivot Points output: four resistances, the pivot, four supports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CamarillaPivotsOutput {
|
||||
/// Pivot Point: `(H + L + C) / 3` (informational, not in the Camarilla R/S formulas).
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `C + (H − L)·1.1/12`.
|
||||
pub r1: f64,
|
||||
/// Resistance 2: `C + (H − L)·1.1/6`.
|
||||
pub r2: f64,
|
||||
/// Resistance 3: `C + (H − L)·1.1/4`.
|
||||
pub r3: f64,
|
||||
/// Resistance 4: `C + (H − L)·1.1/2`.
|
||||
pub r4: f64,
|
||||
/// Support 1: `C − (H − L)·1.1/12`.
|
||||
pub s1: f64,
|
||||
/// Support 2: `C − (H − L)·1.1/6`.
|
||||
pub s2: f64,
|
||||
/// Support 3: `C − (H − L)·1.1/4`.
|
||||
pub s3: f64,
|
||||
/// Support 4: `C − (H − L)·1.1/2`.
|
||||
pub s4: f64,
|
||||
}
|
||||
|
||||
/// Camarilla Pivot Points — Nick Stott's four-tier range-based level set.
|
||||
/// Anchored on the prior close rather than the typical price, with widths
|
||||
/// scaled by the constant `1.1` divided by `{12, 6, 4, 2}`.
|
||||
///
|
||||
/// ```text
|
||||
/// PP = (H + L + C) / 3
|
||||
/// R_n = C + (H − L) · 1.1 / d_n S_n = C − (H − L) · 1.1 / d_n
|
||||
/// where d_1 = 12, d_2 = 6, d_3 = 4, d_4 = 2
|
||||
/// ```
|
||||
///
|
||||
/// R3/S3 are typically used as reversal levels; R4/S4 as breakout levels. As
|
||||
/// with the other pivot variants there are no parameters and no warmup — the
|
||||
/// first candle produces the first set of levels.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Camarilla, Candle, Indicator};
|
||||
///
|
||||
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
|
||||
/// let levels = Camarilla::new().update(prev).unwrap();
|
||||
/// assert!(levels.r4 > levels.r3);
|
||||
/// assert!(levels.s4 < levels.s3);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Camarilla {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl Camarilla {
|
||||
/// Construct a new Camarilla Pivot Points indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
const CAM: f64 = 1.1;
|
||||
|
||||
impl Indicator for Camarilla {
|
||||
type Input = Candle;
|
||||
type Output = CamarillaPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CamarillaPivotsOutput> {
|
||||
let (h, l, c) = (candle.high, candle.low, candle.close);
|
||||
let range = h - l;
|
||||
let pp = (h + l + c) / 3.0;
|
||||
let w1 = range * CAM / 12.0;
|
||||
let w2 = range * CAM / 6.0;
|
||||
let w3 = range * CAM / 4.0;
|
||||
let w4 = range * CAM / 2.0;
|
||||
let out = CamarillaPivotsOutput {
|
||||
pp,
|
||||
r1: c + w1,
|
||||
r2: c + w2,
|
||||
r3: c + w3,
|
||||
r4: c + w4,
|
||||
s1: c - w1,
|
||||
s2: c - w2,
|
||||
s3: c - w3,
|
||||
s4: c - w4,
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Camarilla"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, h, l, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, C=105, range=20.
|
||||
let levels = Camarilla::new().update(c(110.0, 90.0, 105.0, 0)).unwrap();
|
||||
let range = 20.0;
|
||||
assert!((levels.r1 - (105.0 + range * 1.1 / 12.0)).abs() < 1e-12);
|
||||
assert!((levels.r2 - (105.0 + range * 1.1 / 6.0)).abs() < 1e-12);
|
||||
assert!((levels.r3 - (105.0 + range * 1.1 / 4.0)).abs() < 1e-12);
|
||||
assert!((levels.r4 - (105.0 + range * 1.1 / 2.0)).abs() < 1e-12);
|
||||
assert!((levels.s1 - (105.0 - range * 1.1 / 12.0)).abs() < 1e-12);
|
||||
assert!((levels.s4 - (105.0 - range * 1.1 / 2.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resistance_strictly_widens_with_index() {
|
||||
let levels = Camarilla::new().update(c(120.0, 80.0, 110.0, 0)).unwrap();
|
||||
assert!(levels.r4 > levels.r3);
|
||||
assert!(levels.r3 > levels.r2);
|
||||
assert!(levels.r2 > levels.r1);
|
||||
assert!(levels.r1 > 110.0);
|
||||
assert!(levels.s1 < 110.0);
|
||||
assert!(levels.s2 < levels.s1);
|
||||
assert!(levels.s3 < levels.s2);
|
||||
assert!(levels.s4 < levels.s3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
let levels = Camarilla::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
|
||||
assert_eq!(levels.r4, 50.0);
|
||||
assert_eq!(levels.s4, 50.0);
|
||||
assert_eq!(levels.pp, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_and_ready() {
|
||||
let mut p = Camarilla::new();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = Camarilla::new();
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0_i32..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
f64::from(i) + 2.0,
|
||||
f64::from(i),
|
||||
f64::from(i) + 1.0,
|
||||
i.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Camarilla::new();
|
||||
let mut b = Camarilla::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = Camarilla::new();
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
assert_eq!(p.name(), "Camarilla");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Ehlers Center of Gravity Oscillator.
|
||||
#![allow(clippy::manual_midpoint)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Center of Gravity (CG) oscillator.
|
||||
///
|
||||
/// Treats the most recent `period` prices as masses and reports the
|
||||
/// weighted "center" of that mass distribution, negated so positive readings
|
||||
/// correspond to recent strength:
|
||||
///
|
||||
/// ```text
|
||||
/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
|
||||
/// den = sum_{k=0..period-1} price[t - k]
|
||||
/// cg = - num / den + (period + 1) / 2
|
||||
/// ```
|
||||
///
|
||||
/// The constant offset centres the oscillator around zero. From Ehlers,
|
||||
/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CenterOfGravity};
|
||||
///
|
||||
/// let mut cg = CenterOfGravity::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CenterOfGravity {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl CenterOfGravity {
|
||||
/// Construct with the rolling window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CenterOfGravity {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// Most recent has weight 1; oldest has weight `period`.
|
||||
let mut num = 0.0;
|
||||
let mut den = 0.0;
|
||||
for (k, p) in self.window.iter().rev().enumerate() {
|
||||
let w = 1.0 + k as f64;
|
||||
num += w * p;
|
||||
den += p;
|
||||
}
|
||||
let v = if den.abs() > f64::EPSILON {
|
||||
-num / den + (self.period as f64 + 1.0) / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CenterOfGravity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut cg = CenterOfGravity::new(10).unwrap();
|
||||
assert_eq!(cg.period(), 10);
|
||||
assert_eq!(cg.warmup_period(), 10);
|
||||
assert_eq!(cg.name(), "CenterOfGravity");
|
||||
assert!(!cg.is_ready());
|
||||
for i in 1..=10 {
|
||||
cg.update(f64::from(i));
|
||||
}
|
||||
assert!(cg.is_ready());
|
||||
assert!(cg.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// num = sum k * p, den = period * p, ratio = (period + 1) / 2,
|
||||
// so cg = - (period+1)/2 + (period+1)/2 = 0.
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
let out = cg.batch(&[7.0_f64; 30]);
|
||||
for x in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=50).map(f64::from).collect();
|
||||
let mut a = CenterOfGravity::new(10).unwrap();
|
||||
let mut b = CenterOfGravity::new(10).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
let before = cg.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(cg.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(cg.is_ready());
|
||||
cg.reset();
|
||||
assert!(!cg.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none_until_seed() {
|
||||
let mut cg = CenterOfGravity::new(4).unwrap();
|
||||
assert_eq!(cg.update(1.0), None);
|
||||
assert_eq!(cg.update(2.0), None);
|
||||
assert_eq!(cg.update(3.0), None);
|
||||
assert!(cg.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_window_uses_zero_fallback() {
|
||||
// den == sum(prices) == 0 when the rolling window is all zeros, which
|
||||
// exercises the protective fallback in the divisor guard.
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
let out = cg.batch(&[0.0_f64; 10]);
|
||||
for x in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Chande Forecast Oscillator (CFO).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::linreg::LinearRegression;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Tushar Chande's Forecast Oscillator — the percentage difference between
|
||||
/// the close and the endpoint of an `n`-bar linear-regression forecast of the
|
||||
/// close.
|
||||
///
|
||||
/// ```text
|
||||
/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t
|
||||
/// ```
|
||||
///
|
||||
/// Positive readings mean the close is *above* the linear forecast (price has
|
||||
/// overshot trend); negative readings mean it sits below. Wraps the existing
|
||||
/// `LinearRegression` so the warmup matches.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Cfo, Indicator};
|
||||
///
|
||||
/// let mut cfo = Cfo::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = cfo.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cfo {
|
||||
period: usize,
|
||||
linreg: LinearRegression,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Cfo {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
linreg: LinearRegression::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Cfo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let forecast = self.linreg.update(input)?;
|
||||
// Hold the previous value if the close is zero — the percentage form
|
||||
// is undefined and a return of inf would propagate badly.
|
||||
if input == 0.0 {
|
||||
return self.current;
|
||||
}
|
||||
let value = 100.0 * (input - forecast) / input;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.linreg.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CFO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cfo = Cfo::new(14).unwrap();
|
||||
assert_eq!(cfo.period(), 14);
|
||||
assert_eq!(cfo.warmup_period(), 14);
|
||||
assert_eq!(cfo.name(), "CFO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// LinReg of a constant series equals the constant, so close − forecast
|
||||
// is 0 and CFO is 0.
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
let out = cfo.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_linear_series_yields_zero() {
|
||||
// LinReg of a perfectly linear input fits the line exactly, so the
|
||||
// close lands on the forecast and CFO = 0.
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
|
||||
let out = cfo.batch(&prices);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut cfo = Cfo::new(3).unwrap();
|
||||
for i in 1..=2 {
|
||||
assert_eq!(cfo.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(cfo.update(3.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Cfo::new(14).unwrap();
|
||||
let mut b = Cfo::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(cfo.is_ready());
|
||||
cfo.reset();
|
||||
assert!(!cfo.is_ready());
|
||||
assert_eq!(cfo.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_close_holds_value() {
|
||||
let mut cfo = Cfo::new(3).unwrap();
|
||||
cfo.batch(&[1.0_f64, 2.0, 3.0]);
|
||||
let before = cfo.current;
|
||||
assert_eq!(cfo.update(0.0), before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Classic (Floor-Trader) Pivot Points.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Classic Pivot Points output: pivot plus three resistances and three supports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ClassicPivotsOutput {
|
||||
/// Pivot Point: `(H + L + C) / 3`.
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `2·PP − L`.
|
||||
pub r1: f64,
|
||||
/// Resistance 2: `PP + (H − L)`.
|
||||
pub r2: f64,
|
||||
/// Resistance 3: `H + 2·(PP − L)`.
|
||||
pub r3: f64,
|
||||
/// Support 1: `2·PP − H`.
|
||||
pub s1: f64,
|
||||
/// Support 2: `PP − (H − L)`.
|
||||
pub s2: f64,
|
||||
/// Support 3: `L − 2·(H − PP)`.
|
||||
pub s3: f64,
|
||||
}
|
||||
|
||||
/// Classic (Floor-Trader) Pivot Points — the standard pivot/resistance/support
|
||||
/// levels computed from a completed candle's high, low and close.
|
||||
///
|
||||
/// ```text
|
||||
/// PP = (H + L + C) / 3
|
||||
/// R1 = 2·PP − L S1 = 2·PP − H
|
||||
/// R2 = PP + (H − L) S2 = PP − (H − L)
|
||||
/// R3 = H + 2·(PP − L) S3 = L − 2·(H − PP)
|
||||
/// ```
|
||||
///
|
||||
/// Pivots are typically computed once per session (day, week, month) from the
|
||||
/// **previous** session's bar and used as fixed reference levels for the next
|
||||
/// session. The streaming API here simply re-evaluates the formula on every
|
||||
/// candle it sees, which makes it a one-step transform you can wire to any
|
||||
/// pre-aggregated session bar. There are no parameters and no warmup — the
|
||||
/// first candle produces the first set of levels.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, ClassicPivots, Indicator};
|
||||
///
|
||||
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
|
||||
/// let mut pp = ClassicPivots::new();
|
||||
/// let levels = pp.update(prev).unwrap();
|
||||
/// assert!((levels.pp - 101.6666666666).abs() < 1e-9);
|
||||
/// assert!(levels.r1 > levels.pp);
|
||||
/// assert!(levels.s1 < levels.pp);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClassicPivots {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl ClassicPivots {
|
||||
/// Construct a new Classic Pivot Points indicator. The indicator has no
|
||||
/// parameters and no warmup.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ClassicPivots {
|
||||
type Input = Candle;
|
||||
type Output = ClassicPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ClassicPivotsOutput> {
|
||||
let (h, l, c) = (candle.high, candle.low, candle.close);
|
||||
let pp = (h + l + c) / 3.0;
|
||||
let range = h - l;
|
||||
let out = ClassicPivotsOutput {
|
||||
pp,
|
||||
r1: 2.0 * pp - l,
|
||||
r2: pp + range,
|
||||
r3: h + 2.0 * (pp - l),
|
||||
s1: 2.0 * pp - h,
|
||||
s2: pp - range,
|
||||
s3: l - 2.0 * (h - pp),
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ClassicPivots"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, h, l, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, C=105 -> PP = 305/3 ≈ 101.6667.
|
||||
let levels = ClassicPivots::new()
|
||||
.update(c(110.0, 90.0, 105.0, 0))
|
||||
.unwrap();
|
||||
let pp = 305.0 / 3.0;
|
||||
let range = 20.0;
|
||||
assert!((levels.pp - pp).abs() < 1e-12);
|
||||
assert!((levels.r1 - (2.0 * pp - 90.0)).abs() < 1e-12);
|
||||
assert!((levels.s1 - (2.0 * pp - 110.0)).abs() < 1e-12);
|
||||
assert!((levels.r2 - (pp + range)).abs() < 1e-12);
|
||||
assert!((levels.s2 - (pp - range)).abs() < 1e-12);
|
||||
assert!((levels.r3 - (110.0 + 2.0 * (pp - 90.0))).abs() < 1e-12);
|
||||
assert!((levels.s3 - (90.0 - 2.0 * (110.0 - pp))).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_resistance_above_pivot_above_support() {
|
||||
// For any non-degenerate bar with H > L, R-levels exceed PP and S-levels lie below.
|
||||
let levels = ClassicPivots::new()
|
||||
.update(c(200.0, 100.0, 150.0, 0))
|
||||
.unwrap();
|
||||
assert!(levels.r3 >= levels.r2);
|
||||
assert!(levels.r2 >= levels.r1);
|
||||
assert!(levels.r1 >= levels.pp);
|
||||
assert!(levels.pp >= levels.s1);
|
||||
assert!(levels.s1 >= levels.s2);
|
||||
assert!(levels.s2 >= levels.s3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
// H = L = C means range = 0 and every level equals the close.
|
||||
let levels = ClassicPivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
|
||||
assert_eq!(levels.pp, 50.0);
|
||||
assert_eq!(levels.r1, 50.0);
|
||||
assert_eq!(levels.s1, 50.0);
|
||||
assert_eq!(levels.r2, 50.0);
|
||||
assert_eq!(levels.s2, 50.0);
|
||||
assert_eq!(levels.r3, 50.0);
|
||||
assert_eq!(levels.s3, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update_warmup_is_one() {
|
||||
let mut pp = ClassicPivots::new();
|
||||
assert!(!pp.is_ready());
|
||||
assert_eq!(pp.warmup_period(), 1);
|
||||
pp.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(pp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pp = ClassicPivots::new();
|
||||
pp.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(pp.is_ready());
|
||||
pp.reset();
|
||||
assert!(!pp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0_i32..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
f64::from(i) + 2.0,
|
||||
f64::from(i),
|
||||
f64::from(i) + 1.0,
|
||||
i.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ClassicPivots::new();
|
||||
let mut b = ClassicPivots::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pp = ClassicPivots::new();
|
||||
assert_eq!(pp.warmup_period(), 1);
|
||||
assert_eq!(pp.name(), "ClassicPivots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Rolling Coefficient of Variation (`StdDev / Mean`).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Coefficient of Variation — the rolling population standard deviation
|
||||
/// divided by the rolling mean.
|
||||
///
|
||||
/// ```text
|
||||
/// mean = (1/n) · Σ price
|
||||
/// sd = √( (1/n) · Σ price² − mean² )
|
||||
/// CV = sd / mean
|
||||
/// ```
|
||||
///
|
||||
/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price
|
||||
/// level so two assets at very different price magnitudes can be compared
|
||||
/// directly. A higher CV means more relative variability for the same
|
||||
/// average price.
|
||||
///
|
||||
/// When the rolling mean is exactly zero the ratio is undefined; the
|
||||
/// indicator returns `0.0` in that degenerate case rather than producing a
|
||||
/// `NaN`/infinity.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CoefficientOfVariation, Indicator};
|
||||
///
|
||||
/// let mut indicator = CoefficientOfVariation::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoefficientOfVariation {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
}
|
||||
|
||||
impl CoefficientOfVariation {
|
||||
/// Construct a new rolling CV with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CoefficientOfVariation {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(value);
|
||||
self.sum += value;
|
||||
self.sum_sq += value * value;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
let variance = (self.sum_sq / n - mean * mean).max(0.0);
|
||||
let sd = variance.sqrt();
|
||||
if mean == 0.0 {
|
||||
// Undefined ratio: return 0 instead of NaN/inf so downstream
|
||||
// consumers can keep arithmetic going on flat or zeroed series.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(sd / mean)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CoefficientOfVariation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
CoefficientOfVariation::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cv = CoefficientOfVariation::new(14).unwrap();
|
||||
assert_eq!(cv.period(), 14);
|
||||
assert_eq!(cv.warmup_period(), 14);
|
||||
assert_eq!(cv.name(), "CoefficientOfVariation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4.
|
||||
let mut cv = CoefficientOfVariation::new(3).unwrap();
|
||||
let out = cv.batch(&[2.0, 4.0, 6.0]);
|
||||
assert_eq!(out[0], None);
|
||||
let expected = (8.0_f64 / 3.0).sqrt() / 4.0;
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut cv = CoefficientOfVariation::new(5).unwrap();
|
||||
for o in cv.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(o, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_mean_returns_zero() {
|
||||
// [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN.
|
||||
let mut cv = CoefficientOfVariation::new(3).unwrap();
|
||||
let out = cv.batch(&[-1.0, 0.0, 1.0]);
|
||||
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cv = CoefficientOfVariation::new(5).unwrap();
|
||||
cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices);
|
||||
let mut b = CoefficientOfVariation::new(14).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Rolling Conditional Value-at-Risk (`CVaR` / Expected Shortfall).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Conditional Value-at-Risk (Expected Shortfall).
|
||||
///
|
||||
/// Where [`crate::ValueAtRisk`] reports the loss at the lower-tail quantile,
|
||||
/// `CVaR` averages **all** returns below that quantile — the expected loss
|
||||
/// conditional on being in the bad tail:
|
||||
///
|
||||
/// ```text
|
||||
/// q = 1 − confidence
|
||||
/// tail = returns over window with rank fraction ≤ q
|
||||
/// CVaR = − mean(tail) if mean is negative
|
||||
/// CVaR = 0 otherwise
|
||||
/// ```
|
||||
///
|
||||
/// The tail comprises the `floor(q · n)` smallest returns; if `floor` rounds
|
||||
/// down to zero the smallest single return is used so the metric stays
|
||||
/// defined for any `period ≥ 2`. Output is the magnitude of the expected
|
||||
/// shortfall (sign-flipped to be non-negative). `CVaR` is by construction
|
||||
/// `≥ VaR` because it averages losses *beyond* the `VaR` threshold.
|
||||
///
|
||||
/// Each `update` is O(period · log period).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{ConditionalValueAtRisk, Indicator};
|
||||
///
|
||||
/// let mut c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = c.update((f64::from(i) * 0.1).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConditionalValueAtRisk {
|
||||
period: usize,
|
||||
confidence: f64,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ConditionalValueAtRisk {
|
||||
/// Construct a new rolling `CVaR`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
|
||||
/// `confidence` is outside `(0, 1)`.
|
||||
pub fn new(period: usize, confidence: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "conditional value-at-risk needs period >= 2",
|
||||
});
|
||||
}
|
||||
if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "confidence must lie strictly between 0 and 1",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
confidence,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured confidence level.
|
||||
pub const fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ConditionalValueAtRisk {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let q = 1.0 - self.confidence;
|
||||
let n = sorted.len();
|
||||
// Number of samples in the tail. Floor, with a min of 1 so the
|
||||
// expectation is always defined.
|
||||
let k = ((q * n as f64).floor() as usize).max(1);
|
||||
let tail = &sorted[..k];
|
||||
let mean = tail.iter().sum::<f64>() / k as f64;
|
||||
Some((-mean).max(0.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ConditionalValueAtRisk"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
ConditionalValueAtRisk::new(1, 0.95),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ConditionalValueAtRisk::new(20, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ConditionalValueAtRisk::new(20, 1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = ConditionalValueAtRisk::new(100, 0.95).unwrap();
|
||||
assert_eq!(c.period(), 100);
|
||||
assert_relative_eq!(c.confidence(), 0.95, epsilon = 1e-12);
|
||||
assert_eq!(c.name(), "ConditionalValueAtRisk");
|
||||
assert_eq!(c.warmup_period(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// 20 returns -10..9 (each *0.01); confidence 0.95.
|
||||
// q = 0.05, n = 20, k = floor(0.05*20) = 1.
|
||||
// Tail = {-0.10}, CVaR = 0.10.
|
||||
let mut c = ConditionalValueAtRisk::new(20, 0.95).unwrap();
|
||||
let returns: Vec<f64> = (-10..10).map(|i| f64::from(i) * 0.01).collect();
|
||||
let out = c.batch(&returns);
|
||||
assert_relative_eq!(out[19].unwrap(), 0.10, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cvar_geq_var_on_same_window() {
|
||||
// Sanity: with confidence 0.9, the tail of 10 returns has 1 sample;
|
||||
// VaR uses interpolation between 0 and 1, so CVaR (mean of just the
|
||||
// worst) >= VaR.
|
||||
use crate::ValueAtRisk;
|
||||
let returns: Vec<f64> = vec![
|
||||
-0.05, -0.02, -0.01, 0.0, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05,
|
||||
];
|
||||
let mut v = ValueAtRisk::new(10, 0.9).unwrap();
|
||||
let mut c = ConditionalValueAtRisk::new(10, 0.9).unwrap();
|
||||
let v_out = v.batch(&returns);
|
||||
let c_out = c.batch(&returns);
|
||||
let var = v_out[9].unwrap();
|
||||
let cvar = c_out[9].unwrap();
|
||||
assert!(cvar >= var - 1e-12, "CVaR {cvar} should be >= VaR {var}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_positive_returns_yield_zero() {
|
||||
let mut c = ConditionalValueAtRisk::new(5, 0.95).unwrap();
|
||||
let out = c.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
|
||||
assert_eq!(out[4], Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
|
||||
assert_eq!(c.update(f64::NAN), None);
|
||||
assert_eq!(c.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = ConditionalValueAtRisk::new(3, 0.95).unwrap();
|
||||
c.batch(&[-0.01, -0.02, -0.03]);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
|
||||
let batch = ConditionalValueAtRisk::new(10, 0.95)
|
||||
.unwrap()
|
||||
.batch(&returns);
|
||||
let mut s = ConditionalValueAtRisk::new(10, 0.95).unwrap();
|
||||
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Connors RSI (CRSI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Larry Connors' RSI — average of three short-term mean-reversion components,
|
||||
/// each individually bounded in `[0, 100]` so the aggregate is too:
|
||||
///
|
||||
/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`).
|
||||
/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length
|
||||
/// (`+1, +2, ...` for consecutive up closes, `−1, −2, ...` for down closes,
|
||||
/// `0` for unchanged). Connors' default `2`.
|
||||
/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's
|
||||
/// 1-period return in the last `period_rank` returns. Connors' default `100`.
|
||||
///
|
||||
/// ```text
|
||||
/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3
|
||||
/// ```
|
||||
///
|
||||
/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors'
|
||||
/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought
|
||||
/// — both rare conditions, hence the short lookbacks.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{ConnorsRsi, Indicator};
|
||||
///
|
||||
/// let mut crsi = ConnorsRsi::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = crsi.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnorsRsi {
|
||||
period_rsi: usize,
|
||||
period_streak: usize,
|
||||
period_rank: usize,
|
||||
rsi_close: Rsi,
|
||||
rsi_streak: Rsi,
|
||||
prev_price: Option<f64>,
|
||||
streak: f64,
|
||||
/// Rolling window of the last `period_rank` 1-period returns
|
||||
/// (`(price_t − price_{t-1}) / price_{t-1}`).
|
||||
rocs: VecDeque<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl ConnorsRsi {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any of the three periods is zero.
|
||||
pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result<Self> {
|
||||
if period_rsi == 0 || period_streak == 0 || period_rank == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period_rsi,
|
||||
period_streak,
|
||||
period_rank,
|
||||
rsi_close: Rsi::new(period_rsi)?,
|
||||
rsi_streak: Rsi::new(period_streak)?,
|
||||
prev_price: None,
|
||||
streak: 0.0,
|
||||
rocs: VecDeque::with_capacity(period_rank),
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period_rsi, period_streak, period_rank)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.period_rsi, self.period_streak, self.period_rank)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ConnorsRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
// Run the close-RSI on every input so it warms up regardless of the
|
||||
// streak / percent-rank branches.
|
||||
let rsi_close = self.rsi_close.update(input);
|
||||
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
|
||||
// Update the up/down streak run length.
|
||||
self.streak = if input > prev {
|
||||
self.streak.max(0.0) + 1.0
|
||||
} else if input < prev {
|
||||
self.streak.min(0.0) - 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rsi_streak = self.rsi_streak.update(self.streak);
|
||||
|
||||
// 1-period return; defined only when the previous price is non-zero.
|
||||
if prev != 0.0 {
|
||||
let roc = (input - prev) / prev;
|
||||
if self.rocs.len() == self.period_rank {
|
||||
self.rocs.pop_front();
|
||||
}
|
||||
self.rocs.push_back(roc);
|
||||
}
|
||||
self.prev_price = Some(input);
|
||||
|
||||
// PercentRank emits once the ROC window has filled.
|
||||
let percent_rank = if self.rocs.len() == self.period_rank {
|
||||
let latest = *self.rocs.back().expect("non-empty window");
|
||||
let below = self.rocs.iter().filter(|&&r| r < latest).count();
|
||||
Some(100.0 * below as f64 / self.period_rank as f64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let value = (rsi_close?, rsi_streak?, percent_rank?);
|
||||
let crsi = (value.0 + value.1 + value.2) / 3.0;
|
||||
self.current = Some(crsi);
|
||||
Some(crsi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi_close.reset();
|
||||
self.rsi_streak.reset();
|
||||
self.prev_price = None;
|
||||
self.streak = 0.0;
|
||||
self.rocs.clear();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The slowest branch is the percent-rank: it needs period_rank + 1
|
||||
// prices (period_rank one-period returns). The close-RSI needs
|
||||
// period_rsi + 1 prices and the streak-RSI needs period_streak + 1
|
||||
// streak values = period_streak + 2 prices. The rank branch dominates
|
||||
// for Connors' defaults.
|
||||
let rsi_close = self.period_rsi + 1;
|
||||
let rsi_streak = self.period_streak + 2;
|
||||
let rank = self.period_rank + 1;
|
||||
rsi_close.max(rsi_streak).max(rank)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ConnorsRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let crsi = ConnorsRsi::classic();
|
||||
assert_eq!(crsi.periods(), (3, 2, 100));
|
||||
assert_eq!(crsi.name(), "ConnorsRSI");
|
||||
// Slowest branch: percent_rank with period_rank + 1 = 101.
|
||||
assert_eq!(crsi.warmup_period(), 101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
// Use small periods so the test is fast.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap();
|
||||
// Slowest: 5 + 1 = 6.
|
||||
assert_eq!(crsi.warmup_period(), 6);
|
||||
let prices: Vec<f64> = (1..=8).map(f64::from).collect();
|
||||
let out = crsi.batch(&prices);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_high() {
|
||||
// A monotonic uptrend drives all three components toward 100:
|
||||
// RSI of monotonic ups is 100, streak stays positive and growing so
|
||||
// its RSI is 100, and every new 1-period return matches the prior
|
||||
// ones so percent rank stabilises near 0 — but the average of all
|
||||
// three still climbs well above 50.
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
for i in 1..=200 {
|
||||
crsi.update(f64::from(i));
|
||||
}
|
||||
let v = crsi.current.unwrap();
|
||||
assert!(
|
||||
v > 60.0,
|
||||
"uptrend should drive Connors RSI well above 50: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_bounded() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (0..300)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0)
|
||||
.collect();
|
||||
for v in crsi.batch(&prices).iter().flatten() {
|
||||
assert!(
|
||||
(0.0..=100.0).contains(v),
|
||||
"Connors RSI out of [0, 100]: {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_resets_to_zero_on_unchanged_close() {
|
||||
// Helper: feed a sequence and inspect the internal streak.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap();
|
||||
crsi.update(10.0);
|
||||
crsi.update(11.0);
|
||||
crsi.update(12.0);
|
||||
assert_eq!(crsi.streak, 2.0);
|
||||
crsi.update(12.0);
|
||||
assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12);
|
||||
crsi.update(11.0);
|
||||
assert_eq!(crsi.streak, -1.0);
|
||||
crsi.update(10.0);
|
||||
assert_eq!(crsi.streak, -2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut a = ConnorsRsi::classic();
|
||||
let mut b = ConnorsRsi::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
crsi.batch(&prices);
|
||||
assert!(crsi.is_ready());
|
||||
crsi.reset();
|
||||
assert!(!crsi.is_ready());
|
||||
assert_eq!(crsi.streak, 0.0);
|
||||
assert!(crsi.prev_price.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut crsi = ConnorsRsi::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
crsi.batch(&prices);
|
||||
let before = crsi.current;
|
||||
assert_eq!(crsi.update(f64::NAN), before);
|
||||
assert_eq!(crsi.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_skips_roc_update() {
|
||||
// A previous price of 0.0 makes the 1-bar return undefined; the
|
||||
// ROC ring buffer must be left unchanged on that step. Feeding
|
||||
// 0.0 as the very first price seeds `prev_price = Some(0.0)`, so
|
||||
// the next bar takes the `prev == 0.0` branch.
|
||||
let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap();
|
||||
// Bar 1 seeds prev_price to 0.0.
|
||||
crsi.update(0.0);
|
||||
// Bar 2 must not push onto the ROC window; we cannot observe the
|
||||
// ring directly but the indicator must not panic and must not
|
||||
// emit until at least period_rank distinct non-zero returns have
|
||||
// accumulated.
|
||||
let after = crsi.update(1.0);
|
||||
assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Ehlers Cybernetic Cycle Component.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Cybernetic Cycle Component (CCC).
|
||||
///
|
||||
/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and
|
||||
/// Futures* (Ehlers 2004, ch. 4):
|
||||
///
|
||||
/// ```text
|
||||
/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6
|
||||
/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2])
|
||||
/// + 2 * (1 - alpha) * cycle[t-1]
|
||||
/// - (1 - alpha)^2 * cycle[t-2]
|
||||
/// ```
|
||||
///
|
||||
/// The result is a near-zero-mean oscillator that tracks the dominant cycle
|
||||
/// component while filtering trend. `alpha` is a smoothing fraction in
|
||||
/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period.
|
||||
///
|
||||
/// The first six outputs follow Ehlers' "use the input directly" initial
|
||||
/// condition so downstream consumers stay reactive.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CyberneticCycle};
|
||||
///
|
||||
/// let mut cc = CyberneticCycle::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CyberneticCycle {
|
||||
period: usize,
|
||||
alpha: f64,
|
||||
in_buf: [Option<f64>; 4],
|
||||
smooth_buf: [Option<f64>; 3],
|
||||
cycle_buf: [Option<f64>; 3],
|
||||
count: usize,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl CyberneticCycle {
|
||||
/// Construct with the dominant-cycle period (alpha = 2 / (period + 1)).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let alpha = 2.0 / (period as f64 + 1.0);
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha,
|
||||
in_buf: [None; 4],
|
||||
smooth_buf: [None; 3],
|
||||
cycle_buf: [None; 3],
|
||||
count: 0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Smoothing alpha.
|
||||
pub const fn alpha(&self) -> f64 {
|
||||
self.alpha
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Shift in `x` at position 0 of a 3-slot buffer.
|
||||
fn push3(buf: &mut [Option<f64>; 3], x: f64) {
|
||||
buf[2] = buf[1];
|
||||
buf[1] = buf[0];
|
||||
buf[0] = Some(x);
|
||||
}
|
||||
fn push4(buf: &mut [Option<f64>; 4], x: f64) {
|
||||
buf[3] = buf[2];
|
||||
buf[2] = buf[1];
|
||||
buf[1] = buf[0];
|
||||
buf[0] = Some(x);
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CyberneticCycle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
self.count += 1;
|
||||
Self::push4(&mut self.in_buf, input);
|
||||
|
||||
// Smooth needs four prior inputs (positions 0..=3).
|
||||
let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = (
|
||||
self.in_buf[0],
|
||||
self.in_buf[1],
|
||||
self.in_buf[2],
|
||||
self.in_buf[3],
|
||||
) {
|
||||
(a + 2.0 * b + 2.0 * c + d) / 6.0
|
||||
} else {
|
||||
// Initial condition: use the raw input.
|
||||
input
|
||||
};
|
||||
Self::push3(&mut self.smooth_buf, smooth);
|
||||
|
||||
// Cycle needs two prior smooths and two prior cycles.
|
||||
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
|
||||
let one_minus_alpha = 1.0 - self.alpha;
|
||||
let drv = one_minus_half_alpha * one_minus_half_alpha;
|
||||
|
||||
// The 3-slot `smooth_buf` and `cycle_buf` ring buffers fill within a
|
||||
// few updates, so the pattern match only fails during warmup. The
|
||||
// `else` branch is therefore the Ehlers initial condition: the
|
||||
// second-difference of the raw input series, scaled by 0.5 — matches
|
||||
// the EasyLanguage implementation's first-bar fallback.
|
||||
let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = (
|
||||
self.smooth_buf[0],
|
||||
self.smooth_buf[1],
|
||||
self.smooth_buf[2],
|
||||
self.cycle_buf[0],
|
||||
self.cycle_buf[1],
|
||||
) {
|
||||
drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1
|
||||
- one_minus_alpha * one_minus_alpha * c2
|
||||
} else {
|
||||
let (x0, x1, x2) = (
|
||||
self.in_buf[0].unwrap_or(input),
|
||||
self.in_buf[1].unwrap_or(input),
|
||||
self.in_buf[2].unwrap_or(input),
|
||||
);
|
||||
(x0 - 2.0 * x1 + x2) / 4.0
|
||||
};
|
||||
|
||||
Self::push3(&mut self.cycle_buf, cycle);
|
||||
self.last_value = Some(cycle);
|
||||
Some(cycle)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.in_buf = [None; 4];
|
||||
self.smooth_buf = [None; 3];
|
||||
self.cycle_buf = [None; 3];
|
||||
self.count = 0;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CyberneticCycle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut cc = CyberneticCycle::new(10).unwrap();
|
||||
assert_eq!(cc.period(), 10);
|
||||
assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15);
|
||||
assert_eq!(cc.warmup_period(), 1);
|
||||
assert_eq!(cc.name(), "CyberneticCycle");
|
||||
assert!(!cc.is_ready());
|
||||
cc.update(100.0);
|
||||
assert!(cc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
let mut cc = CyberneticCycle::new(10).unwrap();
|
||||
let out = cc.batch(&[50.0_f64; 200]);
|
||||
for x in out.iter().skip(50).flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = CyberneticCycle::new(15).unwrap();
|
||||
let mut b = CyberneticCycle::new(15).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut cc = CyberneticCycle::new(10).unwrap();
|
||||
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
let before = cc.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(cc.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cc = CyberneticCycle::new(10).unwrap();
|
||||
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(cc.is_ready());
|
||||
cc.reset();
|
||||
assert!(!cc.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Ehlers Decycler (single-pole high-pass complement).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Decycler: price minus the dominant cycle component.
|
||||
///
|
||||
/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole
|
||||
/// high-pass filter with critical period `period`. Subtracting the high-pass
|
||||
/// from the raw price leaves the slow component — equivalent to a smoothed
|
||||
/// trend line with no group delay at low frequencies. From *Cycle Analytics
|
||||
/// for Traders* (Ehlers 2013, ch. 4).
|
||||
///
|
||||
/// The high-pass uses the standard 2-pole formulation:
|
||||
///
|
||||
/// ```text
|
||||
/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period)
|
||||
/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2])
|
||||
/// + 2*(1 - alpha) * HP[t-1]
|
||||
/// - (1 - alpha)^2 * HP[t-2]
|
||||
/// ```
|
||||
///
|
||||
/// The first two outputs simply equal the input (warmup buffering), which is
|
||||
/// the conventional Ehlers initialisation and keeps downstream consumers
|
||||
/// reactive while the recursion fills.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Decycler};
|
||||
///
|
||||
/// let mut dc = Decycler::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..50 {
|
||||
/// last = dc.update(100.0 + f64::from(i) * 0.5);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Decycler {
|
||||
period: usize,
|
||||
alpha: f64,
|
||||
prev_in_1: Option<f64>,
|
||||
prev_in_2: Option<f64>,
|
||||
prev_hp_1: f64,
|
||||
prev_hp_2: f64,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Decycler {
|
||||
/// Construct a Decycler with the given critical period for the high-pass filter.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let arg = 0.707 * 2.0 * PI / period as f64;
|
||||
let c = arg.cos();
|
||||
let alpha = (c + arg.sin() - 1.0) / c;
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha,
|
||||
prev_in_1: None,
|
||||
prev_in_2: None,
|
||||
prev_hp_1: 0.0,
|
||||
prev_hp_2: 0.0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// High-pass `alpha` coefficient derived from the period.
|
||||
pub const fn alpha(&self) -> f64 {
|
||||
self.alpha
|
||||
}
|
||||
|
||||
/// Current decycler value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Compute and store the high-pass output for the latest input.
|
||||
fn step_hp(&mut self, input: f64) -> f64 {
|
||||
let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
|
||||
self.prev_hp_2 = self.prev_hp_1;
|
||||
self.prev_hp_1 = 0.0;
|
||||
return 0.0;
|
||||
};
|
||||
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
|
||||
let one_minus_alpha = 1.0 - self.alpha;
|
||||
let drv = one_minus_half_alpha * one_minus_half_alpha;
|
||||
let term1 = drv * (input - 2.0 * x1 + x2);
|
||||
let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
|
||||
let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
|
||||
let hp = term1 + term2 - term3;
|
||||
self.prev_hp_2 = self.prev_hp_1;
|
||||
self.prev_hp_1 = hp;
|
||||
hp
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Decycler {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
let hp = self.step_hp(input);
|
||||
let v = input - hp;
|
||||
self.prev_in_2 = self.prev_in_1;
|
||||
self.prev_in_1 = Some(input);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_in_1 = None;
|
||||
self.prev_in_2 = None;
|
||||
self.prev_hp_1 = 0.0;
|
||||
self.prev_hp_2 = 0.0;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Decycler"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut dc = Decycler::new(20).unwrap();
|
||||
assert_eq!(dc.period(), 20);
|
||||
assert_eq!(dc.warmup_period(), 1);
|
||||
assert_eq!(dc.name(), "Decycler");
|
||||
assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
|
||||
assert!(!dc.is_ready());
|
||||
dc.update(100.0);
|
||||
assert!(dc.is_ready());
|
||||
assert!(dc.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_passes_through() {
|
||||
// For a flat input, the high-pass output is zero, so the decycler
|
||||
// equals the input.
|
||||
let mut dc = Decycler::new(20).unwrap();
|
||||
let out = dc.batch(&[42.0_f64; 80]);
|
||||
for x in out.iter().flatten() {
|
||||
assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Decycler::new(20).unwrap();
|
||||
let mut b = Decycler::new(20).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dc = Decycler::new(20).unwrap();
|
||||
dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
let before = dc.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(dc.update(f64::NAN), before);
|
||||
assert_eq!(dc.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dc = Decycler::new(20).unwrap();
|
||||
dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(dc.is_ready());
|
||||
dc.reset();
|
||||
assert!(!dc.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Ehlers Decycler Oscillator (difference of two decyclers).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::decycler::Decycler;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Difference between a fast and a slow [`Decycler`], producing a smoothed
|
||||
/// oscillator that crosses zero at trend changes.
|
||||
///
|
||||
/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`.
|
||||
/// The construct removes the trend component that both decyclers share, leaving
|
||||
/// the medium-frequency cycle band — analogous in spirit to MACD but with
|
||||
/// Ehlers' zero-lag high-pass filters instead of EMAs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, DecyclerOscillator};
|
||||
///
|
||||
/// let mut dco = DecyclerOscillator::new(10, 30).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecyclerOscillator {
|
||||
fast: Decycler,
|
||||
slow: Decycler,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl DecyclerOscillator {
|
||||
/// Construct with the fast and slow periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is zero, and
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "fast period must be strictly less than slow period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast: Decycler::new(fast)?,
|
||||
slow: Decycler::new(slow)?,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(fast, slow)` periods.
|
||||
pub fn periods(&self) -> (usize, usize) {
|
||||
(self.fast.period(), self.slow.period())
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DecyclerOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
// Both child `Decycler` instances emit `Some` from the first bar
|
||||
// (Ehlers' convention is "output = input" until the recursion warms),
|
||||
// so the pair is always populated and the `?` short-circuit never
|
||||
// fires in practice.
|
||||
let f = self.fast.update(input)?;
|
||||
let s = self.slow.update(input)?;
|
||||
let v = f - s;
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.fast.warmup_period().max(self.slow.warmup_period())
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DecyclerOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
DecyclerOscillator::new(0, 20),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DecyclerOscillator::new(10, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DecyclerOscillator::new(20, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
DecyclerOscillator::new(10, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
|
||||
assert_eq!(dco.periods(), (10, 30));
|
||||
assert_eq!(dco.name(), "DecyclerOscillator");
|
||||
assert!(dco.warmup_period() >= 1);
|
||||
assert!(!dco.is_ready());
|
||||
dco.update(100.0);
|
||||
assert!(dco.is_ready());
|
||||
assert!(dco.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
|
||||
let out = dco.batch(&[42.0_f64; 80]);
|
||||
for x in out.iter().flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
|
||||
.collect();
|
||||
let mut a = DecyclerOscillator::new(10, 30).unwrap();
|
||||
let mut b = DecyclerOscillator::new(10, 30).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
|
||||
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
|
||||
let before = dco.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(dco.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
|
||||
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(dco.is_ready());
|
||||
dco.reset();
|
||||
assert!(!dco.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Demand Index (James Sibbet).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// James Sibbet's Demand Index — a smoothed ratio of buying pressure to
|
||||
/// selling pressure, classifying each bar's volume by whether the close rose
|
||||
/// or fell relative to the previous close.
|
||||
///
|
||||
/// Sibbet's original 1970s formulation runs the raw buying/selling pressure
|
||||
/// through several smoothings and yields a number that swings in `[−100, 100]`.
|
||||
/// This implementation uses the textbook simplified form that captures the same
|
||||
/// signal in a streaming-friendly shape:
|
||||
///
|
||||
/// ```text
|
||||
/// pressure_t = volume_t · ((close_t − close_{t−1}) / max(close_{t−1}, ε))
|
||||
/// · (1 + (high_t − low_t) / max(close_{t−1}, ε))
|
||||
/// DI_t = EMA(pressure, period)_t
|
||||
/// ```
|
||||
///
|
||||
/// Positive readings mean the smoothed money flow is leaning to the buy side
|
||||
/// (up-day volume dominates), negative to the sell side. The first candle only
|
||||
/// establishes the previous close, so the first non-`None` value lands once the
|
||||
/// EMA has accumulated `period` pressure samples. A previous close of zero
|
||||
/// contributes no signal (avoids division by zero). The output is unbounded;
|
||||
/// what matters is the sign and the divergence against price.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, DemandIndex, Indicator};
|
||||
///
|
||||
/// let mut indicator = DemandIndex::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DemandIndex {
|
||||
period: usize,
|
||||
ema: Ema,
|
||||
prev_close: Option<f64>,
|
||||
}
|
||||
|
||||
impl DemandIndex {
|
||||
/// Construct a new Demand Index with the given EMA smoothing period.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
ema: Ema::new(period)?,
|
||||
prev_close: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured EMA smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DemandIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let pressure = if prev == 0.0 {
|
||||
// No prior baseline -> can't normalise; treat as no flow.
|
||||
0.0
|
||||
} else {
|
||||
let ret = (candle.close - prev) / prev;
|
||||
let range_norm = (candle.high - candle.low) / prev;
|
||||
candle.volume * ret * (1.0 + range_norm)
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
self.ema.update(pressure)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
self.prev_close = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed bar to establish the previous close, then the EMA needs
|
||||
// `period` samples to seed.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DemandIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let di = DemandIndex::new(10).unwrap();
|
||||
assert_eq!(di.period(), 10);
|
||||
assert_eq!(di.name(), "DemandIndex");
|
||||
assert_eq!(di.warmup_period(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// No close change -> pressure = 0 on every bar -> EMA stays at 0.
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i))
|
||||
.collect();
|
||||
let mut di = DemandIndex::new(5).unwrap();
|
||||
for v in di.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_series_yields_positive_signal() {
|
||||
// Strictly rising closes on constant volume -> pressure is positive every
|
||||
// bar -> smoothed DI must end up strictly positive.
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut di = DemandIndex::new(5).unwrap();
|
||||
let out = di.batch(&candles);
|
||||
let last = out.iter().filter_map(|x| *x).next_back().unwrap();
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"rising series must yield positive DI, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_series_yields_negative_signal() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut di = DemandIndex::new(5).unwrap();
|
||||
let out = di.batch(&candles);
|
||||
let last = out.iter().filter_map(|x| *x).next_back().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"falling series must yield negative DI, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_close_contributes_no_signal() {
|
||||
// First two bars: prev close is exactly zero -> pressure clipped to 0.
|
||||
// We then continue with a non-zero series and confirm output behaves.
|
||||
let mut di = DemandIndex::new(3).unwrap();
|
||||
di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0));
|
||||
// Bar 2 sees prev_close == 0 -> pressure = 0.
|
||||
di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1));
|
||||
// Subsequent bars now have non-zero prev_close.
|
||||
di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2));
|
||||
// Just check that nothing exploded; an EMA(3) needs 3 samples post-seed.
|
||||
// The first sample at bar 2 was zero, the second at bar 3 positive.
|
||||
let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3));
|
||||
assert!(v.is_some());
|
||||
assert!(v.unwrap().is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..100i64)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
let mid = 100.0 + (f * 0.2).sin() * 5.0;
|
||||
c(
|
||||
mid,
|
||||
mid + 1.5,
|
||||
mid - 1.5,
|
||||
mid + 0.3,
|
||||
80.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = DemandIndex::new(10).unwrap();
|
||||
let mut b = DemandIndex::new(10).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut di = DemandIndex::new(5).unwrap();
|
||||
di.batch(&candles);
|
||||
assert!(di.is_ready());
|
||||
di.reset();
|
||||
assert!(!di.is_ready());
|
||||
assert_eq!(di.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `DeMark` Pivot Points.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `DeMark` Pivot Points output: a single resistance, pivot and support.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DemarkPivotsOutput {
|
||||
/// Pivot Point: `X / 4` where `X` is the conditional sum (see [`DemarkPivots`]).
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `X / 2 − L`.
|
||||
pub r1: f64,
|
||||
/// Support 1: `X / 2 − H`.
|
||||
pub s1: f64,
|
||||
}
|
||||
|
||||
/// `DeMark` Pivot Points — Tom `DeMark`'s conditional pivot formulation, derived
|
||||
/// from a sum `X` that depends on whether the bar closed up, down or flat.
|
||||
///
|
||||
/// ```text
|
||||
/// X = 2·H + L + C if C < O (down bar)
|
||||
/// H + 2·L + C if C > O (up bar)
|
||||
/// H + L + 2·C if C == O (doji)
|
||||
///
|
||||
/// PP = X / 4
|
||||
/// R1 = X / 2 − L
|
||||
/// S1 = X / 2 − H
|
||||
/// ```
|
||||
///
|
||||
/// Unlike the classic pivots, only one resistance and one support are
|
||||
/// produced; `DeMark`'s intent is a tighter, condition-sensitive set rather than
|
||||
/// a multi-tier fan. The branching means a bar's open carries information that
|
||||
/// other pivot variants discard.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, DemarkPivots, Indicator};
|
||||
///
|
||||
/// // Up bar: O=100, H=120, L=80, C=110 -> X = H + 2·L + C = 390.
|
||||
/// let up = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
|
||||
/// let lv = DemarkPivots::new().update(up).unwrap();
|
||||
/// assert!((lv.pp - 97.5).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DemarkPivots {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl DemarkPivots {
|
||||
/// Construct a new `DeMark` Pivot Points indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DemarkPivots {
|
||||
type Input = Candle;
|
||||
type Output = DemarkPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<DemarkPivotsOutput> {
|
||||
let open = candle.open;
|
||||
let high = candle.high;
|
||||
let low = candle.low;
|
||||
let close = candle.close;
|
||||
let x = if close < open {
|
||||
2.0 * high + low + close
|
||||
} else if close > open {
|
||||
high + 2.0 * low + close
|
||||
} else {
|
||||
high + low + 2.0 * close
|
||||
};
|
||||
let pp = x / 4.0;
|
||||
let half = x / 2.0;
|
||||
let out = DemarkPivotsOutput {
|
||||
pp,
|
||||
r1: half - low,
|
||||
s1: half - high,
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DemarkPivots"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn down_bar_uses_2h_plus_l_plus_c() {
|
||||
// O=110, H=120, L=80, C=100 (close < open) -> X = 2·120 + 80 + 100 = 420.
|
||||
let cd = Candle::new(110.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
|
||||
let lv = DemarkPivots::new().update(cd).unwrap();
|
||||
assert!((lv.pp - 105.0).abs() < 1e-12);
|
||||
assert!((lv.r1 - (210.0 - 80.0)).abs() < 1e-12);
|
||||
assert!((lv.s1 - (210.0 - 120.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_bar_uses_h_plus_2l_plus_c() {
|
||||
// O=100, H=120, L=80, C=110 (close > open) -> X = 120 + 160 + 110 = 390.
|
||||
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
|
||||
let lv = DemarkPivots::new().update(cd).unwrap();
|
||||
assert!((lv.pp - 97.5).abs() < 1e-12);
|
||||
assert!((lv.r1 - (195.0 - 80.0)).abs() < 1e-12);
|
||||
assert!((lv.s1 - (195.0 - 120.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_uses_h_plus_l_plus_2c() {
|
||||
// O = C = 100, H=120, L=80 -> X = 120 + 80 + 200 = 400.
|
||||
let cd = Candle::new(100.0, 120.0, 80.0, 100.0, 1.0, 0).unwrap();
|
||||
let lv = DemarkPivots::new().update(cd).unwrap();
|
||||
assert!((lv.pp - 100.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_resistance_above_pivot_above_support() {
|
||||
let cd = Candle::new(100.0, 120.0, 80.0, 110.0, 1.0, 0).unwrap();
|
||||
let lv = DemarkPivots::new().update(cd).unwrap();
|
||||
assert!(lv.r1 >= lv.pp);
|
||||
assert!(lv.pp >= lv.s1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
let cd = Candle::new(50.0, 50.0, 50.0, 50.0, 1.0, 0).unwrap();
|
||||
let lv = DemarkPivots::new().update(cd).unwrap();
|
||||
assert_eq!(lv.pp, 50.0);
|
||||
assert_eq!(lv.r1, 50.0);
|
||||
assert_eq!(lv.s1, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_and_ready() {
|
||||
let mut p = DemarkPivots::new();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
|
||||
p.update(cd);
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = DemarkPivots::new();
|
||||
let cd = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
|
||||
p.update(cd);
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = f64::from(i);
|
||||
Candle::new(base, base + 2.0, base - 0.5, base + 1.0, 1.0, i64::from(i)).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut a = DemarkPivots::new();
|
||||
let mut b = DemarkPivots::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = DemarkPivots::new();
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
assert_eq!(p.name(), "DemarkPivots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Population standard deviation of residuals from a rolling OLS detrend.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Detrended (residual) standard deviation over the last `period` inputs.
|
||||
///
|
||||
/// Over the trailing window indexed `x = 0, 1, …, period − 1` the OLS line
|
||||
/// `y = a + b·x` is fitted and the residual sum of squares is then divided
|
||||
/// by `n` (population convention):
|
||||
///
|
||||
/// ```text
|
||||
/// slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
|
||||
/// SS_total = Σy² − n·ȳ²
|
||||
/// RSS = SS_total − slope² · ( denom / n )
|
||||
/// DetrendedStdDev = √( RSS / n )
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`crate::StdDev`], which measures dispersion around the rolling
|
||||
/// **mean**, `DetrendedStdDev` measures dispersion around the rolling
|
||||
/// **linear trend** — the portion of the price action that is *not*
|
||||
/// explained by the local slope. On a strongly trending series this is
|
||||
/// much smaller than `StdDev`; on a sideways, mean-reverting series the
|
||||
/// two converge.
|
||||
///
|
||||
/// The divisor is `n` (population), matching the convention of
|
||||
/// [`crate::StdDev`]; use [`crate::StandardError`] when you want the
|
||||
/// textbook standard error of estimate with `n − 2` residual degrees of
|
||||
/// freedom.
|
||||
///
|
||||
/// Each `update` is O(1) via the same rolling sums as
|
||||
/// [`crate::LinearRegression`], plus a running `Σy²`. Floating-point
|
||||
/// cancellation noise in the residual is clamped to zero before the square
|
||||
/// root.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DetrendedStdDev, Indicator};
|
||||
///
|
||||
/// let mut indicator = DetrendedStdDev::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.3).sin());
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetrendedStdDev {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum_x: f64,
|
||||
/// `n·Σxx − (Σx)²` — OLS denominator, constant in `period`.
|
||||
denom: f64,
|
||||
sum_y: f64,
|
||||
sum_xy: f64,
|
||||
sum_y_sq: f64,
|
||||
}
|
||||
|
||||
impl DetrendedStdDev {
|
||||
/// Construct a new rolling detrended standard deviation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line
|
||||
/// is undefined for fewer than two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "detrended stddev needs period >= 2",
|
||||
});
|
||||
}
|
||||
let n = period as f64;
|
||||
let sum_x = n * (n - 1.0) / 2.0;
|
||||
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x,
|
||||
denom: n * sum_xx - sum_x * sum_x,
|
||||
sum_y: 0.0,
|
||||
sum_xy: 0.0,
|
||||
sum_y_sq: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DetrendedStdDev {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
let y0 = self.window.pop_front().expect("non-empty");
|
||||
self.sum_xy = self.sum_xy - self.sum_y + y0;
|
||||
self.sum_y -= y0;
|
||||
self.sum_y_sq -= y0 * y0;
|
||||
}
|
||||
let k = self.window.len() as f64;
|
||||
self.window.push_back(value);
|
||||
self.sum_y += value;
|
||||
self.sum_xy += k * value;
|
||||
self.sum_y_sq += value * value;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
|
||||
let mean_y = self.sum_y / n;
|
||||
let ss_total = self.sum_y_sq - n * mean_y * mean_y;
|
||||
let s_xx = self.denom / n;
|
||||
let rss = (ss_total - slope * slope * s_xx).max(0.0);
|
||||
Some((rss / n).sqrt())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
self.sum_y_sq = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DetrendedStdDev"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(DetrendedStdDev::new(0).is_err());
|
||||
assert!(DetrendedStdDev::new(1).is_err());
|
||||
assert!(DetrendedStdDev::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DetrendedStdDev::new(14).unwrap();
|
||||
assert_eq!(d.period(), 14);
|
||||
assert_eq!(d.warmup_period(), 14);
|
||||
assert_eq!(d.name(), "DetrendedStdDev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_line_has_zero_residual() {
|
||||
// Residuals are zero on a perfectly linear series.
|
||||
let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
|
||||
let mut d = DetrendedStdDev::new(10).unwrap();
|
||||
for v in d.batch(&prices).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut d = DetrendedStdDev::new(5).unwrap();
|
||||
for v in d.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_exceeds_stddev() {
|
||||
// The detrended residual is the projection of (y - ȳ) orthogonal to
|
||||
// the trend axis, so its norm cannot exceed the raw stddev. Equality
|
||||
// holds iff the OLS slope is exactly zero.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0)
|
||||
.collect();
|
||||
let mut d = DetrendedStdDev::new(14).unwrap();
|
||||
let mut sd = crate::StdDev::new(14).unwrap();
|
||||
for &p in &prices {
|
||||
let (dv, sv) = (d.update(p), sd.update(p));
|
||||
assert_eq!(dv.is_some(), sv.is_some());
|
||||
if let (Some(dv), Some(sv)) = (dv, sv) {
|
||||
assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DetrendedStdDev::new(5).unwrap();
|
||||
d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0)
|
||||
.collect();
|
||||
let batch = DetrendedStdDev::new(14).unwrap().batch(&prices);
|
||||
let mut b = DetrendedStdDev::new(14).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Doji candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Doji — a candle whose body is negligible relative to its range.
|
||||
///
|
||||
/// A Doji prints whenever the absolute distance between open and close is
|
||||
/// small compared to the total `high − low` range. It is the canonical
|
||||
/// indecision bar and a building block for many three-bar reversal patterns.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// range = high − low
|
||||
/// doji = body <= body_threshold * range
|
||||
/// ```
|
||||
///
|
||||
/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is
|
||||
/// directionless — no `−1.0` is emitted. Pattern-shape check only — no trend
|
||||
/// filter is applied; combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Doji, Indicator};
|
||||
///
|
||||
/// let mut indicator = Doji::default();
|
||||
/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Doji {
|
||||
body_threshold: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for Doji {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Doji {
|
||||
/// Construct a Doji detector with the default body threshold (`0.1`).
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
body_threshold: 0.1,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Doji detector with a custom body / range threshold.
|
||||
///
|
||||
/// `body_threshold` must lie in `(0, 1]`.
|
||||
pub fn with_threshold(body_threshold: f64) -> Result<Self> {
|
||||
if !(body_threshold > 0.0 && body_threshold <= 1.0) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "doji body threshold must lie in (0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
body_threshold,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured body / range threshold.
|
||||
pub fn body_threshold(&self) -> f64 {
|
||||
self.body_threshold
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Doji {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
if range <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body = (candle.close - candle.open).abs();
|
||||
Some(if body <= self.body_threshold * range {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Doji"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_threshold() {
|
||||
assert!(Doji::with_threshold(0.0).is_err());
|
||||
assert!(Doji::with_threshold(-0.1).is_err());
|
||||
assert!(Doji::with_threshold(1.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_threshold() {
|
||||
let d = Doji::with_threshold(0.05).unwrap();
|
||||
assert!((d.body_threshold() - 0.05).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = Doji::default();
|
||||
assert_eq!(d.name(), "Doji");
|
||||
assert_eq!(d.warmup_period(), 1);
|
||||
assert!(!d.is_ready());
|
||||
assert!((d.body_threshold() - 0.1).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obvious_doji_is_one() {
|
||||
let mut d = Doji::new();
|
||||
// open == close, full range -> body / range = 0.
|
||||
assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0));
|
||||
assert!(d.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_doji() {
|
||||
// Big body, no shadows -> body / range = 1.0 > 0.1.
|
||||
let mut d = Doji::new();
|
||||
assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut d = Doji::new();
|
||||
assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 2.0, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Doji::new();
|
||||
let mut b = Doji::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = Doji::new();
|
||||
d.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Donchian Channel Stop (Turtle).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Donchian Channel Stop output: the long-side and short-side trailing stops.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DonchianStopOutput {
|
||||
/// Long-position stop: the lowest low over the lookback.
|
||||
pub stop_long: f64,
|
||||
/// Short-position stop: the highest high over the lookback.
|
||||
pub stop_short: f64,
|
||||
}
|
||||
|
||||
/// Donchian Channel Stop — the original Turtle-trader exit rule. A long is
|
||||
/// trailed at the lowest low of the last `period` bars; a short at the highest
|
||||
/// high. There is no ATR, no multiplier, and no flip-bit — the two levels are
|
||||
/// always emitted and the caller selects whichever side matches the position.
|
||||
///
|
||||
/// ```text
|
||||
/// stop_long = min(low, over period bars)
|
||||
/// stop_short = max(high, over period bars)
|
||||
/// ```
|
||||
///
|
||||
/// Richard Dennis' original Turtle System used a 20-bar entry channel and a
|
||||
/// 10-bar exit channel — feed this indicator the exit window. The first
|
||||
/// `period` candles are warmup; on the bar that fills the window it begins
|
||||
/// emitting both stops.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, DonchianStop};
|
||||
///
|
||||
/// let mut indicator = DonchianStop::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DonchianStop {
|
||||
period: usize,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl DonchianStop {
|
||||
/// Construct a Donchian Channel Stop with an explicit lookback.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// The Turtle-system exit window: a `10`-bar lookback.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10).expect("classic Donchian Stop period is valid")
|
||||
}
|
||||
|
||||
/// Configured lookback.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DonchianStop {
|
||||
type Input = Candle;
|
||||
type Output = DonchianStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<DonchianStopOutput> {
|
||||
if self.highs.len() == self.period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Some(DonchianStopOutput {
|
||||
stop_long,
|
||||
stop_short,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.highs.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DonchianStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(DonchianStop::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = DonchianStop::classic();
|
||||
assert_eq!(s.period(), 10);
|
||||
assert_eq!(s.warmup_period(), 10);
|
||||
assert_eq!(s.name(), "DonchianStop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = DonchianStop::new(5).unwrap();
|
||||
let out = s.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(4) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_uptrend_window() {
|
||||
// Highs 0..5 = 1..6; lowest low = 0, highest high = 5.
|
||||
let candles: Vec<Candle> = (0..5)
|
||||
.map(|i| {
|
||||
let base = i as f64 + 0.5;
|
||||
c(base + 0.5, base - 0.5, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = DonchianStop::new(5).unwrap();
|
||||
let out = s.batch(&candles);
|
||||
let v = out[4].expect("ready at index 4");
|
||||
assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_holds_both_stops() {
|
||||
let candles: Vec<Candle> = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut s = DonchianStop::new(5).unwrap();
|
||||
for v in s.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut s = DonchianStop::classic();
|
||||
s.batch(&candles);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = DonchianStop::classic();
|
||||
let mut b = DonchianStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Double Bollinger Bands (Kathy Lien).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::bollinger::BollingerBands;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Double Bollinger Bands output: two concentric bands at `k_inner` and
|
||||
/// `k_outer` standard deviations around a shared SMA middle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DoubleBollingerOutput {
|
||||
/// Outer upper band: `middle + k_outer · stddev`.
|
||||
pub upper_outer: f64,
|
||||
/// Inner upper band: `middle + k_inner · stddev`.
|
||||
pub upper_inner: f64,
|
||||
/// Middle band: SMA over the window.
|
||||
pub middle: f64,
|
||||
/// Inner lower band: `middle − k_inner · stddev`.
|
||||
pub lower_inner: f64,
|
||||
/// Outer lower band: `middle − k_outer · stddev`.
|
||||
pub lower_outer: f64,
|
||||
}
|
||||
|
||||
/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien).
|
||||
///
|
||||
/// ```text
|
||||
/// middle = SMA(period)
|
||||
/// sigma = population stddev over the window
|
||||
/// upper_outer = middle + k_outer · sigma // wide channel (often 2σ)
|
||||
/// upper_inner = middle + k_inner · sigma // narrow channel (often 1σ)
|
||||
/// lower_inner = middle − k_inner · sigma
|
||||
/// lower_outer = middle − k_outer · sigma
|
||||
/// ```
|
||||
///
|
||||
/// Lien's trading framework partitions price into three zones:
|
||||
///
|
||||
/// - **Sell zone:** close below `lower_inner`.
|
||||
/// - **Neutral zone:** close between `lower_inner` and `upper_inner`.
|
||||
/// - **Buy zone:** close above `upper_inner`.
|
||||
///
|
||||
/// A close beyond the outer band marks an extended move that traders typically
|
||||
/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs
|
||||
/// remain monotonically ordered.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DoubleBollinger, Indicator};
|
||||
///
|
||||
/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoubleBollinger {
|
||||
inner: BollingerBands,
|
||||
k_inner: f64,
|
||||
k_outer: f64,
|
||||
}
|
||||
|
||||
impl DoubleBollinger {
|
||||
/// Construct a new Double Bollinger Bands indicator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`,
|
||||
/// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is
|
||||
/// non-positive or non-finite, and [`Error::InvalidPeriod`] if
|
||||
/// `k_outer <= k_inner` (the outer band must strictly enclose the inner
|
||||
/// band so the zone-partitioning interpretation holds).
|
||||
pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result<Self> {
|
||||
if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
if k_outer <= k_inner {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "double bollinger requires k_outer > k_inner",
|
||||
});
|
||||
}
|
||||
// Build the inner state on the outer multiplier so the upper/lower
|
||||
// outputs of `BollingerBands::update` already give us the outer band;
|
||||
// the inner band is reconstructed from the same `stddev`.
|
||||
Ok(Self {
|
||||
inner: BollingerBands::new(period, k_outer)?,
|
||||
k_inner,
|
||||
k_outer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, k_inner, k_outer)`.
|
||||
pub const fn parameters(&self) -> (usize, f64, f64) {
|
||||
(self.inner.period(), self.k_inner, self.k_outer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DoubleBollinger {
|
||||
type Input = f64;
|
||||
type Output = DoubleBollingerOutput;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<DoubleBollingerOutput> {
|
||||
let o = self.inner.update(value)?;
|
||||
Some(DoubleBollingerOutput {
|
||||
upper_outer: o.upper,
|
||||
upper_inner: o.middle + self.k_inner * o.stddev,
|
||||
middle: o.middle,
|
||||
lower_inner: o.middle - self.k_inner * o.stddev,
|
||||
lower_outer: o.lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DoubleBollinger"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(0, 1.0, 2.0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(20, 0.0, 2.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(20, 1.0, -2.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(20, f64::NAN, 2.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_outer_not_greater_than_inner() {
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(20, 2.0, 1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
DoubleBollinger::new(20, 2.0, 2.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let db = DoubleBollinger::classic();
|
||||
let (p, ki, ko) = db.parameters();
|
||||
assert_eq!(p, 20);
|
||||
assert_relative_eq!(ki, 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(ko, 2.0, epsilon = 1e-12);
|
||||
assert_eq!(db.warmup_period(), 20);
|
||||
assert_eq!(db.name(), "DoubleBollinger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_all_bands() {
|
||||
let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
|
||||
let last = db
|
||||
.batch(&[5.0_f64; 20])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_strictly_ordered_with_dispersion() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
|
||||
.collect();
|
||||
let mut db = DoubleBollinger::classic();
|
||||
for o in db.batch(&prices).into_iter().flatten() {
|
||||
assert!(o.upper_outer >= o.upper_inner);
|
||||
assert!(o.upper_inner >= o.middle);
|
||||
assert!(o.middle >= o.lower_inner);
|
||||
assert!(o.lower_inner >= o.lower_outer);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..50).map(|i| f64::from(i) * 0.7).collect();
|
||||
let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
|
||||
let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap();
|
||||
db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(db.is_ready());
|
||||
db.reset();
|
||||
assert!(!db.is_ready());
|
||||
assert_eq!(db.update(1.0), None);
|
||||
}
|
||||
|
||||
/// The inner band must agree with running a separate `BollingerBands` at
|
||||
/// the inner multiplier.
|
||||
#[test]
|
||||
fn inner_band_matches_separate_bollinger() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
|
||||
.collect();
|
||||
let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
|
||||
let mut bb_inner = BollingerBands::new(20, 1.0).unwrap();
|
||||
let mut bb_outer = BollingerBands::new(20, 2.0).unwrap();
|
||||
for p in &prices {
|
||||
let d = db.update(*p);
|
||||
let i = bb_inner.update(*p);
|
||||
let o = bb_outer.update(*p);
|
||||
if let (Some(d), Some(i), Some(o)) = (d, i, o) {
|
||||
assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9);
|
||||
assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9);
|
||||
assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9);
|
||||
assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9);
|
||||
assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Drawdown Duration — bars since the last all-time peak ("time under water").
|
||||
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cumulative drawdown duration in bars.
|
||||
///
|
||||
/// Each `update` receives one equity-curve sample. The indicator tracks the
|
||||
/// **running all-time peak** seen since construction (or last `reset`) and
|
||||
/// reports how many bars have elapsed since that peak was set:
|
||||
///
|
||||
/// ```text
|
||||
/// peak_t = max(input over [0..=t])
|
||||
/// duration_t = bars elapsed since peak_t was first set
|
||||
/// ```
|
||||
///
|
||||
/// A new peak resets the duration to `0`. As long as the series stays under
|
||||
/// water the duration grows linearly with each bar.
|
||||
///
|
||||
/// The indicator emits a value on every bar (no warmup beyond the first
|
||||
/// input) and runs in O(1) per `update`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DrawdownDuration, Indicator};
|
||||
///
|
||||
/// let mut dd = DrawdownDuration::new();
|
||||
/// assert_eq!(dd.update(100.0), Some(0)); // first bar -> new peak
|
||||
/// assert_eq!(dd.update(95.0), Some(1)); // 1 bar under water
|
||||
/// assert_eq!(dd.update(90.0), Some(2)); // 2 bars under water
|
||||
/// assert_eq!(dd.update(110.0), Some(0)); // new peak -> reset
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DrawdownDuration {
|
||||
peak: f64,
|
||||
bars_under_water: u32,
|
||||
seen: bool,
|
||||
}
|
||||
|
||||
impl DrawdownDuration {
|
||||
/// Construct a new Drawdown Duration tracker.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
peak: f64::NEG_INFINITY,
|
||||
bars_under_water: 0,
|
||||
seen: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bars elapsed since the running all-time peak was set.
|
||||
pub const fn value(&self) -> Option<u32> {
|
||||
if self.seen {
|
||||
Some(self.bars_under_water)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DrawdownDuration {
|
||||
type Input = f64;
|
||||
type Output = u32;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<u32> {
|
||||
if !input.is_finite() {
|
||||
return self.value();
|
||||
}
|
||||
if !self.seen || input >= self.peak {
|
||||
self.peak = input;
|
||||
self.bars_under_water = 0;
|
||||
} else {
|
||||
self.bars_under_water = self.bars_under_water.saturating_add(1);
|
||||
}
|
||||
self.seen = true;
|
||||
Some(self.bars_under_water)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.peak = f64::NEG_INFINITY;
|
||||
self.bars_under_water = 0;
|
||||
self.seen = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.seen
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DrawdownDuration"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
assert_eq!(d.name(), "DrawdownDuration");
|
||||
assert_eq!(d.warmup_period(), 1);
|
||||
assert_eq!(d.value(), None);
|
||||
d.update(100.0);
|
||||
assert_eq!(d.value(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_is_peak() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
assert_eq!(d.update(100.0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_water_counter_increments() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
d.update(100.0);
|
||||
assert_eq!(d.update(90.0), Some(1));
|
||||
assert_eq!(d.update(80.0), Some(2));
|
||||
assert_eq!(d.update(85.0), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_peak_resets_counter() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
d.update(100.0);
|
||||
d.update(90.0);
|
||||
d.update(80.0);
|
||||
assert_eq!(d.update(105.0), Some(0));
|
||||
assert_eq!(d.update(95.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_value_is_treated_as_peak() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
d.update(100.0);
|
||||
assert_eq!(d.update(100.0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
d.update(100.0);
|
||||
d.update(90.0);
|
||||
let v = d.value();
|
||||
assert_eq!(d.update(f64::NAN), v);
|
||||
assert_eq!(d.update(f64::INFINITY), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DrawdownDuration::new();
|
||||
d.batch(&[100.0, 90.0, 80.0]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(100.0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..30)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = DrawdownDuration::new().batch(&prices);
|
||||
let mut s = DrawdownDuration::new();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roofing_filter::RoofingFilter;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Adaptive Stochastic.
|
||||
///
|
||||
/// Implements the construction described in *Cycle Analytics for Traders*
|
||||
/// (Ehlers 2013, ch. 7): the raw price is first passed through a
|
||||
/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the
|
||||
/// tradable cycle band, then the classic Stochastic %K formula is applied
|
||||
/// to the filtered output over `period` bars and finally re-smoothed by a
|
||||
/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that
|
||||
/// reacts to cycles without trending bias from low-frequency drift.
|
||||
///
|
||||
/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention,
|
||||
/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, EhlersStochastic};
|
||||
///
|
||||
/// let mut es = EhlersStochastic::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EhlersStochastic {
|
||||
period: usize,
|
||||
roofing: RoofingFilter,
|
||||
filtered_buf: VecDeque<f64>,
|
||||
// Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing).
|
||||
prev_stoch: f64,
|
||||
has_prev: bool,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl EhlersStochastic {
|
||||
/// Construct with the rolling window length used by the inner stochastic.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
// Defaults match Ehlers' (10, 48) roofing filter cutoffs.
|
||||
roofing: RoofingFilter::new(10, 48)?,
|
||||
filtered_buf: VecDeque::with_capacity(period),
|
||||
prev_stoch: 0.0,
|
||||
has_prev: false,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EhlersStochastic {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
let filtered = self.roofing.update(input)?;
|
||||
if self.filtered_buf.len() == self.period {
|
||||
self.filtered_buf.pop_front();
|
||||
}
|
||||
self.filtered_buf.push_back(filtered);
|
||||
if self.filtered_buf.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let max = self
|
||||
.filtered_buf
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let min = self
|
||||
.filtered_buf
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let range = max - min;
|
||||
let raw = if range > 0.0 {
|
||||
((filtered - min) / range).mul_add(2.0, -1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// 2-bar SMA smoothing.
|
||||
let smoothed = if self.has_prev {
|
||||
0.5 * (raw + self.prev_stoch)
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
self.prev_stoch = raw;
|
||||
self.has_prev = true;
|
||||
self.last_value = Some(smoothed);
|
||||
Some(smoothed)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roofing.reset();
|
||||
self.filtered_buf.clear();
|
||||
self.prev_stoch = 0.0;
|
||||
self.has_prev = false;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + self.roofing.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EhlersStochastic"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut es = EhlersStochastic::new(20).unwrap();
|
||||
assert_eq!(es.period(), 20);
|
||||
assert_eq!(es.warmup_period(), 22);
|
||||
assert_eq!(es.name(), "EhlersStochastic");
|
||||
assert!(!es.is_ready());
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
es.batch(&prices);
|
||||
assert!(es.is_ready());
|
||||
assert!(es.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_bounded_in_unit_interval() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut es = EhlersStochastic::new(20).unwrap();
|
||||
for v in es.batch(&prices).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "value out of band: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = EhlersStochastic::new(20).unwrap();
|
||||
let mut b = EhlersStochastic::new(20).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut es = EhlersStochastic::new(20).unwrap();
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
es.batch(&prices);
|
||||
let before = es.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(es.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut es = EhlersStochastic::new(20).unwrap();
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
es.batch(&prices);
|
||||
assert!(es.is_ready());
|
||||
es.reset();
|
||||
assert!(!es.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_emits_zero() {
|
||||
// A constant series has zero high-pass output, so `max == min` and the
|
||||
// `range > 0.0` guard takes the `0.0` fallback rather than dividing.
|
||||
let mut es = EhlersStochastic::new(20).unwrap();
|
||||
for v in es.batch(&[100.0_f64; 150]).into_iter().flatten() {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Elder Impulse System.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::macd::MacdIndicator;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Alexander Elder's Impulse System — a tri-state momentum gauge combining the
|
||||
/// slope of an `EMA` trend filter with the slope of the `MACD` histogram.
|
||||
///
|
||||
/// On each bar Wickra reports:
|
||||
///
|
||||
/// - `+1` ("green / buy") when both the `EMA` trend and the `MACD` histogram
|
||||
/// are rising bar-over-bar.
|
||||
/// - `−1` ("red / sell") when both are falling.
|
||||
/// - `0` ("blue / neutral") when the two disagree.
|
||||
///
|
||||
/// The defaults track Elder's *Come Into My Trading Room* parameterisation:
|
||||
/// `EMA(13)` for the trend, `MACD(12, 26, 9)` for the histogram.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{ElderImpulse, Indicator};
|
||||
///
|
||||
/// let mut elder = ElderImpulse::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = elder.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderImpulse {
|
||||
ema_period: usize,
|
||||
macd_fast: usize,
|
||||
macd_slow: usize,
|
||||
macd_signal: usize,
|
||||
ema: Ema,
|
||||
macd: MacdIndicator,
|
||||
prev_ema: Option<f64>,
|
||||
prev_hist: Option<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl ElderImpulse {
|
||||
/// # Errors
|
||||
/// Forwarded from [`Ema::new`] / [`MacdIndicator::new`].
|
||||
pub fn new(
|
||||
ema_period: usize,
|
||||
macd_fast: usize,
|
||||
macd_slow: usize,
|
||||
macd_signal: usize,
|
||||
) -> Result<Self> {
|
||||
if ema_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
ema_period,
|
||||
macd_fast,
|
||||
macd_slow,
|
||||
macd_signal,
|
||||
ema: Ema::new(ema_period)?,
|
||||
macd: MacdIndicator::new(macd_fast, macd_slow, macd_signal)?,
|
||||
prev_ema: None,
|
||||
prev_hist: None,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Elder's recommended defaults `(ema_period = 13, macd = 12/26/9)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(13, 12, 26, 9).expect("classic Elder Impulse parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ema_period, macd_fast, macd_slow, macd_signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize, usize) {
|
||||
(
|
||||
self.ema_period,
|
||||
self.macd_fast,
|
||||
self.macd_slow,
|
||||
self.macd_signal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderImpulse {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Feed both branches on every input so they warm in parallel.
|
||||
let ema_now = self.ema.update(input);
|
||||
let macd_now = self.macd.update(input);
|
||||
let (ema_now, macd_now) = (ema_now?, macd_now?);
|
||||
|
||||
// The Impulse needs two consecutive readings on both branches to
|
||||
// judge direction. The first ready bar seeds prev_*; the second emits.
|
||||
let prev_ema = self.prev_ema;
|
||||
let prev_hist = self.prev_hist;
|
||||
self.prev_ema = Some(ema_now);
|
||||
self.prev_hist = Some(macd_now.histogram);
|
||||
let prev_ema = prev_ema?;
|
||||
let prev_hist = prev_hist?;
|
||||
|
||||
let ema_rising = ema_now > prev_ema;
|
||||
let ema_falling = ema_now < prev_ema;
|
||||
let hist_rising = macd_now.histogram > prev_hist;
|
||||
let hist_falling = macd_now.histogram < prev_hist;
|
||||
|
||||
let value = if ema_rising && hist_rising {
|
||||
1.0
|
||||
} else if ema_falling && hist_falling {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
self.macd.reset();
|
||||
self.prev_ema = None;
|
||||
self.prev_hist = None;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// MACD's warmup is slow + signal − 1; EMA's is ema_period. The
|
||||
// slowest branch fires the *first* impulse-ready reading, but
|
||||
// judging direction needs one *more* bar on top.
|
||||
let macd_warmup = self.macd_slow + self.macd_signal - 1;
|
||||
self.ema_period.max(macd_warmup) + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderImpulse"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
ElderImpulse::new(0, 12, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
ElderImpulse::new(13, 0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_macd_params() {
|
||||
// MacdIndicator validates fast < slow.
|
||||
assert!(ElderImpulse::new(13, 26, 12, 9).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let elder = ElderImpulse::classic();
|
||||
assert_eq!(elder.periods(), (13, 12, 26, 9));
|
||||
assert_eq!(elder.name(), "ElderImpulse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(ElderImpulse::classic().periods(), (13, 12, 26, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_neutral() {
|
||||
// Both EMA and MACD-histogram are flat on a constant series, so
|
||||
// neither is rising nor falling -> Impulse = 0.
|
||||
let mut elder = ElderImpulse::classic();
|
||||
let out = elder.batch(&[42.0_f64; 120]);
|
||||
// Take values from the post-warmup region.
|
||||
for v in out.iter().skip(40).flatten() {
|
||||
assert_eq!(*v, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_signals_buy() {
|
||||
// Monotonic uptrend: EMA rises every bar; MACD histogram is positive
|
||||
// and (after the slow EMA catches up) also rising bar-over-bar.
|
||||
let mut elder = ElderImpulse::classic();
|
||||
for i in 1..=300 {
|
||||
elder.update(f64::from(i));
|
||||
}
|
||||
// The final reading should be +1 (buy) or 0 — never -1 on a clean
|
||||
// up trend.
|
||||
let v = elder.current.unwrap();
|
||||
assert!(v >= 0.0, "uptrend should not signal sell: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut elder = ElderImpulse::new(3, 2, 4, 3).unwrap();
|
||||
// MACD warmup: 4 + 3 - 1 = 6; EMA warmup: 3; max = 6; +1 for the
|
||||
// direction bar = 7.
|
||||
assert_eq!(elder.warmup_period(), 7);
|
||||
let prices: Vec<f64> = (1..=10).map(f64::from).collect();
|
||||
let out = elder.batch(&prices);
|
||||
for v in out.iter().take(6) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[6].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = ElderImpulse::classic();
|
||||
let mut b = ElderImpulse::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut elder = ElderImpulse::classic();
|
||||
elder.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(elder.is_ready());
|
||||
elder.reset();
|
||||
assert!(!elder.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Ehlers Empirical Mode Decomposition (bandpass + envelope).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' adaptation of Empirical Mode Decomposition (EMD).
|
||||
///
|
||||
/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14).
|
||||
/// The procedure is:
|
||||
///
|
||||
/// 1. Apply a bandpass filter centred on `period` to the price.
|
||||
/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction`
|
||||
/// of the period.
|
||||
/// 3. Average the peaks and valleys separately to form an upper / lower
|
||||
/// envelope, then return the centred bandpass minus the envelope mean
|
||||
/// (the "EMD" line).
|
||||
///
|
||||
/// The output crosses zero at trend changes and stays near zero in
|
||||
/// non-trending markets — the classic visual cue Ehlers documents.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, EmpiricalModeDecomposition};
|
||||
///
|
||||
/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmpiricalModeDecomposition {
|
||||
period: usize,
|
||||
fraction: f64,
|
||||
bandpass: f64,
|
||||
prev_bp_1: f64,
|
||||
prev_bp_2: f64,
|
||||
prev_in_1: Option<f64>,
|
||||
prev_in_2: Option<f64>,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
smoother: SuperSmoother,
|
||||
peak_smoother: SuperSmoother,
|
||||
valley_smoother: SuperSmoother,
|
||||
bp_buf: VecDeque<f64>,
|
||||
bp_history_len: usize,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl EmpiricalModeDecomposition {
|
||||
/// Construct with the bandpass centre period and the peak-detection
|
||||
/// window fraction.
|
||||
///
|
||||
/// `fraction` is multiplied by `period` to size the rolling peak/valley
|
||||
/// window; Ehlers recommends `0.5`. Both must be positive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, and
|
||||
/// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite.
|
||||
pub fn new(period: usize, fraction: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "fraction must be in (0, 1]",
|
||||
});
|
||||
}
|
||||
let beta = (2.0 * PI / period as f64).cos();
|
||||
let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
|
||||
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
|
||||
let history = (period as f64 * fraction).round().max(1.0) as usize;
|
||||
Ok(Self {
|
||||
period,
|
||||
fraction,
|
||||
bandpass: 0.0,
|
||||
prev_bp_1: 0.0,
|
||||
prev_bp_2: 0.0,
|
||||
prev_in_1: None,
|
||||
prev_in_2: None,
|
||||
beta,
|
||||
alpha,
|
||||
smoother: SuperSmoother::new(period.max(2))?,
|
||||
peak_smoother: SuperSmoother::new(period.max(2))?,
|
||||
valley_smoother: SuperSmoother::new(period.max(2))?,
|
||||
bp_buf: VecDeque::with_capacity(history),
|
||||
bp_history_len: history,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured fraction.
|
||||
pub const fn fraction(&self) -> f64 {
|
||||
self.fraction
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EmpiricalModeDecomposition {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
// 2nd-order resonant bandpass per Ehlers ch. 6.
|
||||
let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
|
||||
0.5 * (1.0 - self.alpha) * (input - x2)
|
||||
+ self.beta * (1.0 + self.alpha) * self.prev_bp_1
|
||||
- self.alpha * self.prev_bp_2
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev_bp_2 = self.prev_bp_1;
|
||||
self.prev_bp_1 = bp;
|
||||
self.bandpass = bp;
|
||||
self.prev_in_2 = self.prev_in_1;
|
||||
self.prev_in_1 = Some(input);
|
||||
|
||||
if self.bp_buf.len() == self.bp_history_len {
|
||||
self.bp_buf.pop_front();
|
||||
}
|
||||
self.bp_buf.push_back(bp);
|
||||
if self.bp_buf.len() < self.bp_history_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Identify the current peak (largest), valley (smallest) within the window.
|
||||
let peak = self
|
||||
.bp_buf
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
|
||||
let avg_peak = self.peak_smoother.update(peak)?;
|
||||
let avg_valley = self.valley_smoother.update(valley)?;
|
||||
|
||||
// The EMD line is the bandpass minus the smoothed mean envelope.
|
||||
let mean = 0.5 * (avg_peak + avg_valley);
|
||||
let raw = bp - mean;
|
||||
let v = self.smoother.update(raw)?;
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bandpass = 0.0;
|
||||
self.prev_bp_1 = 0.0;
|
||||
self.prev_bp_2 = 0.0;
|
||||
self.prev_in_1 = None;
|
||||
self.prev_in_2 = None;
|
||||
self.smoother.reset();
|
||||
self.peak_smoother.reset();
|
||||
self.valley_smoother.reset();
|
||||
self.bp_buf.clear();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bp_history_len
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EmpiricalModeDecomposition"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
EmpiricalModeDecomposition::new(0, 0.5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
EmpiricalModeDecomposition::new(20, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
EmpiricalModeDecomposition::new(20, 1.5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
EmpiricalModeDecomposition::new(20, f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
assert_eq!(emd.period(), 20);
|
||||
assert!((emd.fraction() - 0.5).abs() < 1e-15);
|
||||
assert_eq!(emd.name(), "EmpiricalModeDecomposition");
|
||||
assert!(emd.warmup_period() >= 1);
|
||||
assert!(!emd.is_ready());
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
emd.batch(&prices);
|
||||
assert!(emd.is_ready());
|
||||
assert!(emd.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
|
||||
.collect();
|
||||
let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
let mut b = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
emd.batch(&prices);
|
||||
let before = emd.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(emd.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
emd.batch(&prices);
|
||||
assert!(emd.is_ready());
|
||||
emd.reset();
|
||||
assert!(!emd.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Bullish / Bearish Engulfing candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Engulfing — a 2-bar reversal pattern. The current candle's body fully
|
||||
/// engulfs the prior candle's body and points in the opposite direction.
|
||||
///
|
||||
/// ```text
|
||||
/// prev_body = |prev.close − prev.open|
|
||||
/// curr_body = |curr.close − curr.open|
|
||||
/// bullish = prev red & curr green
|
||||
/// & curr.open <= prev.close & curr.close >= prev.open
|
||||
/// & curr_body > prev_body
|
||||
/// bearish = prev green & curr red
|
||||
/// & curr.open >= prev.close & curr.close <= prev.open
|
||||
/// & curr_body > prev_body
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` for a bullish engulfing, `−1.0` for a bearish one, and
|
||||
/// `0.0` otherwise. The first bar always returns `0.0` because no previous
|
||||
/// body exists to engulf. Pattern-shape check only — no trend filter is
|
||||
/// applied; combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Engulfing, Indicator};
|
||||
///
|
||||
/// let mut indicator = Engulfing::new();
|
||||
/// // Prior red candle followed by a larger green engulfing candle.
|
||||
/// indicator.update(Candle::new(11.0, 11.2, 9.8, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(9.5, 12.0, 9.5, 11.5, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Engulfing {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Engulfing {
|
||||
/// Construct a new Engulfing detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Engulfing {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body = (p.close - p.open).abs();
|
||||
let curr_body = (candle.close - candle.open).abs();
|
||||
if prev_body <= 0.0 || curr_body <= prev_body {
|
||||
return Some(0.0);
|
||||
}
|
||||
let prev_red = p.close < p.open;
|
||||
let prev_green = p.close > p.open;
|
||||
let curr_green = candle.close > candle.open;
|
||||
let curr_red = candle.close < candle.open;
|
||||
if prev_red && curr_green && candle.open <= p.close && candle.close >= p.open {
|
||||
Some(1.0)
|
||||
} else if prev_green && curr_red && candle.open >= p.close && candle.close <= p.open {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Engulfing"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = Engulfing::new();
|
||||
assert_eq!(e.name(), "Engulfing");
|
||||
assert_eq!(e.warmup_period(), 2);
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_engulfing_is_plus_one() {
|
||||
let mut e = Engulfing::new();
|
||||
// Prior red 11 -> 10, current green 9.5 -> 11.5 (body 2 > 1).
|
||||
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_engulfing_is_minus_one() {
|
||||
let mut e = Engulfing::new();
|
||||
// Prior green 10 -> 11, current red 12 -> 9.
|
||||
assert_eq!(e.update(c(10.0, 11.2, 9.8, 11.0, 0)), Some(0.0));
|
||||
assert_eq!(e.update(c(12.0, 12.0, 9.0, 9.0, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_not_engulfing() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(10.0, 11.0, 9.8, 11.0, 0));
|
||||
// Another green candle that engulfs but matches direction -> 0.
|
||||
assert_eq!(e.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smaller_body_is_not_engulfing() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(11.0, 11.2, 8.0, 8.5, 0));
|
||||
// Body 0.5 < 2.5 -> not engulfing.
|
||||
assert_eq!(e.update(c(8.6, 9.0, 8.4, 8.7, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut e = Engulfing::new();
|
||||
assert_eq!(e.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
if i % 3 == 0 {
|
||||
c(base + 1.0, base + 1.5, base - 0.5, base, i)
|
||||
} else {
|
||||
c(base - 1.0, base + 2.0, base - 1.5, base + 2.0, i)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = Engulfing::new();
|
||||
let mut b = Engulfing::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Engulfing::new();
|
||||
e.update(c(10.0, 11.0, 9.0, 11.0, 0));
|
||||
e.update(c(11.0, 12.0, 10.0, 12.0, 1));
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
// After reset the next bar again has no prev.
|
||||
assert_eq!(e.update(c(11.0, 11.2, 9.8, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Elastic Volume-Weighted Moving Average (EVWMA).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Christian P. Fries' Elastic Volume-Weighted Moving Average.
|
||||
///
|
||||
/// Unlike `VWMA` which is a per-bar weighted mean, `EVWMA` runs an
|
||||
/// "elastic" recurrence whose smoothing weight is the bar's volume relative
|
||||
/// to the running window-volume:
|
||||
///
|
||||
/// ```text
|
||||
/// V_sum_t = Σ volume_i over the last `period` candles
|
||||
/// EVWMA_t = ((V_sum_t - volume_t) * EVWMA_{t-1} + volume_t * close_t) / V_sum_t
|
||||
/// ```
|
||||
///
|
||||
/// A bar whose volume is small compared to the window total barely moves the
|
||||
/// average; a bar whose volume dominates the window pulls it strongly toward
|
||||
/// the bar's close. The series is seeded with the close of the first candle
|
||||
/// after the volume window has filled (i.e. after `period` candles).
|
||||
///
|
||||
/// If `V_sum_t == 0` (every candle in the window has zero volume), the
|
||||
/// recurrence is undefined; the indicator holds its previous value.
|
||||
///
|
||||
/// Reference: Christian P. Fries, *Wilmott Magazine*, 2001.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Evwma, Indicator};
|
||||
///
|
||||
/// let mut evwma = Evwma::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap();
|
||||
/// last = evwma.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Evwma {
|
||||
period: usize,
|
||||
/// Rolling window of `(close, volume)` pairs, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_v: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Evwma {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_v: 0.0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Evwma {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let close = candle.close;
|
||||
let volume = candle.volume;
|
||||
if self.window.len() == self.period {
|
||||
let (_, old_v) = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum_v -= old_v;
|
||||
}
|
||||
self.window.push_back((close, volume));
|
||||
self.sum_v += volume;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// The volume sum may be zero (every bar in the window had zero
|
||||
// volume); the recurrence is undefined, so seed/hold instead.
|
||||
if self.sum_v <= 0.0 {
|
||||
if self.current.is_none() {
|
||||
self.current = Some(close);
|
||||
}
|
||||
return self.current;
|
||||
}
|
||||
let prev = self.current.unwrap_or(close);
|
||||
let next = ((self.sum_v - volume) * prev + volume * close) / self.sum_v;
|
||||
self.current = Some(next);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_v = 0.0;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EVWMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, close, close, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Evwma::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut e = Evwma::new(5).unwrap();
|
||||
assert_eq!(e.period(), 5);
|
||||
assert_eq!(e.warmup_period(), 5);
|
||||
assert_eq!(e.name(), "EVWMA");
|
||||
assert_eq!(e.value(), None);
|
||||
for i in 0..5 {
|
||||
e.update(candle(10.0, 1.0, i));
|
||||
}
|
||||
assert!(e.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// A flat close — every (V_sum - v) * prev + v * close reduces to
|
||||
// V_sum * close, so the recurrence preserves the constant after the
|
||||
// first seeded sample.
|
||||
let mut e = Evwma::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, 3.0, i)).collect();
|
||||
let out = e.batch(&candles);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_period_2() {
|
||||
// EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1).
|
||||
// Bar 1: window not full (size 1) -> None.
|
||||
// Bar 2: window full, sum_v = 4, prev seeds to 20.
|
||||
// EVWMA = ((4 - 3) * 20 + 3 * 20) / 4 = 80 / 4 = 20.
|
||||
// Bar 3: window slides, sum_v = 4 (drops the 1, gains the 1).
|
||||
// EVWMA = ((4 - 1) * 20 + 1 * 30) / 4 = (60 + 30) / 4 = 22.5.
|
||||
let mut e = Evwma::new(2).unwrap();
|
||||
assert_eq!(e.update(candle(10.0, 1.0, 0)), None);
|
||||
assert_relative_eq!(
|
||||
e.update(candle(20.0, 3.0, 1)).unwrap(),
|
||||
20.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
assert_relative_eq!(
|
||||
e.update(candle(30.0, 1.0, 2)).unwrap(),
|
||||
22.5,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut e = Evwma::new(4).unwrap();
|
||||
for i in 0..3 {
|
||||
assert_eq!(e.update(candle(10.0, 1.0, i)), None);
|
||||
}
|
||||
assert!(e.update(candle(10.0, 1.0, 3)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_holds_value() {
|
||||
// Every bar has zero volume: no participation, so the recurrence
|
||||
// can't move and EVWMA simply seeds to the first close.
|
||||
let mut e = Evwma::new(3).unwrap();
|
||||
e.update(candle(10.0, 0.0, 0));
|
||||
e.update(candle(15.0, 0.0, 1));
|
||||
let v = e.update(candle(20.0, 0.0, 2)).unwrap();
|
||||
assert_relative_eq!(v, 20.0, epsilon = 1e-12);
|
||||
// Next bar still flat-zero volume: holds 20.
|
||||
let v2 = e.update(candle(50.0, 0.0, 3)).unwrap();
|
||||
assert_relative_eq!(v2, 20.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(c, 1.0 + (i % 7) as f64, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Evwma::new(10).unwrap().batch(&candles);
|
||||
let mut b = Evwma::new(10).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Evwma::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect();
|
||||
e.batch(&candles);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.update(candle(10.0, 1.0, 0)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Ehlers Following Adaptive Moving Average (FAMA).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::mama::Mama;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator.
|
||||
///
|
||||
/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in
|
||||
/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it
|
||||
/// reacts later than MAMA — MAMA crossing above FAMA marks a trend
|
||||
/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint
|
||||
/// `(mama, fama)` output; this wrapper exposes the slow line as a plain
|
||||
/// scalar indicator so it can be chained directly.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Fama};
|
||||
///
|
||||
/// let mut fama = Fama::new(0.5, 0.05).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Fama {
|
||||
inner: Mama,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Fama {
|
||||
/// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Forwards [`Mama::new`]'s validation errors.
|
||||
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Mama::new(fast_limit, slow_limit)?,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default `(0.5, 0.05)` parameters.
|
||||
pub fn classic() -> Self {
|
||||
Self {
|
||||
inner: Mama::classic(),
|
||||
last_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured `(fast_limit, slow_limit)`.
|
||||
pub const fn limits(&self) -> (f64, f64) {
|
||||
self.inner.limits()
|
||||
}
|
||||
|
||||
/// Current FAMA value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Fama {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let v = self.inner.update(input)?.fama;
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FAMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_limits() {
|
||||
assert!(matches!(
|
||||
Fama::new(0.0, 0.05),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Fama::new(0.05, 0.5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_valid_limits_constructs_via_mama() {
|
||||
// `classic()` bypasses `new` by going through `Mama::classic`; this
|
||||
// test exercises the happy-path `Ok(Self { inner: Mama::new(..)? })`
|
||||
// arm so the `?` doesn't only collapse to the error path.
|
||||
let mut fama = Fama::new(0.5, 0.05).expect("valid Mama limits");
|
||||
assert_eq!(fama.limits(), (0.5, 0.05));
|
||||
for i in 0..60 {
|
||||
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
}
|
||||
assert!(fama.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut fama = Fama::classic();
|
||||
assert_eq!(fama.limits(), (0.5, 0.05));
|
||||
assert_eq!(fama.warmup_period(), 33);
|
||||
assert_eq!(fama.name(), "FAMA");
|
||||
assert!(!fama.is_ready());
|
||||
for i in 0..60 {
|
||||
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
}
|
||||
assert!(fama.is_ready());
|
||||
assert!(fama.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0)
|
||||
.collect();
|
||||
let mut a = Fama::classic();
|
||||
let mut b = Fama::classic();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut fama = Fama::classic();
|
||||
let prices: Vec<f64> = (0..100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
fama.batch(&prices);
|
||||
let before = fama.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(fama.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut fama = Fama::classic();
|
||||
let prices: Vec<f64> = (0..100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
fama.batch(&prices);
|
||||
assert!(fama.is_ready());
|
||||
fama.reset();
|
||||
assert!(!fama.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Fibonacci Pivot Points.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fibonacci Pivot Points output: pivot plus three Fib-spaced resistances and
|
||||
/// supports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibonacciPivotsOutput {
|
||||
/// Pivot Point: `(H + L + C) / 3`.
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `PP + 0.382·(H − L)`.
|
||||
pub r1: f64,
|
||||
/// Resistance 2: `PP + 0.618·(H − L)`.
|
||||
pub r2: f64,
|
||||
/// Resistance 3: `PP + 1.000·(H − L)`.
|
||||
pub r3: f64,
|
||||
/// Support 1: `PP − 0.382·(H − L)`.
|
||||
pub s1: f64,
|
||||
/// Support 2: `PP − 0.618·(H − L)`.
|
||||
pub s2: f64,
|
||||
/// Support 3: `PP − 1.000·(H − L)`.
|
||||
pub s3: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Pivot Points — the classic pivot plus three resistances and
|
||||
/// supports spaced by the Fibonacci ratios 0.382 / 0.618 / 1.000 applied to
|
||||
/// the prior bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// PP = (H + L + C) / 3
|
||||
/// R1 = PP + 0.382·(H − L) S1 = PP − 0.382·(H − L)
|
||||
/// R2 = PP + 0.618·(H − L) S2 = PP − 0.618·(H − L)
|
||||
/// R3 = PP + 1.000·(H − L) S3 = PP − 1.000·(H − L)
|
||||
/// ```
|
||||
///
|
||||
/// As with [`crate::ClassicPivots`], levels are typically built from the
|
||||
/// previous session's bar. There are no parameters and no warmup.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, FibonacciPivots, Indicator};
|
||||
///
|
||||
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
|
||||
/// let levels = FibonacciPivots::new().update(prev).unwrap();
|
||||
/// assert!(levels.r3 > levels.r2);
|
||||
/// assert!(levels.r2 > levels.r1);
|
||||
/// assert!(levels.s1 > levels.s2);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FibonacciPivots {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl FibonacciPivots {
|
||||
/// Construct a new Fibonacci Pivot Points indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
const FIB1: f64 = 0.382;
|
||||
const FIB2: f64 = 0.618;
|
||||
const FIB3: f64 = 1.000;
|
||||
|
||||
impl Indicator for FibonacciPivots {
|
||||
type Input = Candle;
|
||||
type Output = FibonacciPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibonacciPivotsOutput> {
|
||||
let (h, l, c) = (candle.high, candle.low, candle.close);
|
||||
let pp = (h + l + c) / 3.0;
|
||||
let range = h - l;
|
||||
let out = FibonacciPivotsOutput {
|
||||
pp,
|
||||
r1: pp + FIB1 * range,
|
||||
r2: pp + FIB2 * range,
|
||||
r3: pp + FIB3 * range,
|
||||
s1: pp - FIB1 * range,
|
||||
s2: pp - FIB2 * range,
|
||||
s3: pp - FIB3 * range,
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibonacciPivots"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, h, l, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, range=20, PP = (110+90+100)/3 = 100.
|
||||
let levels = FibonacciPivots::new()
|
||||
.update(c(110.0, 90.0, 100.0, 0))
|
||||
.unwrap();
|
||||
assert!((levels.pp - 100.0).abs() < 1e-12);
|
||||
assert!((levels.r1 - (100.0 + 0.382 * 20.0)).abs() < 1e-12);
|
||||
assert!((levels.r2 - (100.0 + 0.618 * 20.0)).abs() < 1e-12);
|
||||
assert!((levels.r3 - (100.0 + 20.0)).abs() < 1e-12);
|
||||
assert!((levels.s1 - (100.0 - 0.382 * 20.0)).abs() < 1e-12);
|
||||
assert!((levels.s2 - (100.0 - 0.618 * 20.0)).abs() < 1e-12);
|
||||
assert!((levels.s3 - (100.0 - 20.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resistances_strictly_above_pp_supports_strictly_below() {
|
||||
let levels = FibonacciPivots::new()
|
||||
.update(c(120.0, 80.0, 110.0, 0))
|
||||
.unwrap();
|
||||
assert!(levels.r3 > levels.r2);
|
||||
assert!(levels.r2 > levels.r1);
|
||||
assert!(levels.r1 > levels.pp);
|
||||
assert!(levels.pp > levels.s1);
|
||||
assert!(levels.s1 > levels.s2);
|
||||
assert!(levels.s2 > levels.s3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
let levels = FibonacciPivots::new()
|
||||
.update(c(50.0, 50.0, 50.0, 0))
|
||||
.unwrap();
|
||||
assert_eq!(levels.pp, 50.0);
|
||||
assert_eq!(levels.r1, 50.0);
|
||||
assert_eq!(levels.s3, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_and_ready() {
|
||||
let mut p = FibonacciPivots::new();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = FibonacciPivots::new();
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0_i32..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
f64::from(i) + 2.0,
|
||||
f64::from(i),
|
||||
f64::from(i) + 1.0,
|
||||
i.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = FibonacciPivots::new();
|
||||
let mut b = FibonacciPivots::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = FibonacciPivots::new();
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
assert_eq!(p.name(), "FibonacciPivots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Ehlers Fisher Transform.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Fisher Transform of price.
|
||||
///
|
||||
/// Normalises the most recent price to `[-1, +1]` via min/max over a `period`
|
||||
/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and
|
||||
/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a
|
||||
/// near-Gaussian distribution, so extreme readings stand out cleanly. A
|
||||
/// secondary signal is produced by lagging the Fisher value by one bar (the
|
||||
/// classic trigger), making the indicator a two-line crossover system in
|
||||
/// charts.
|
||||
///
|
||||
/// Only the primary Fisher value is exposed here as a scalar; the lagged
|
||||
/// trigger is one update behind by construction.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, FisherTransform};
|
||||
///
|
||||
/// let mut ft = FisherTransform::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FisherTransform {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
smoothed: f64,
|
||||
last_fisher: Option<f64>,
|
||||
}
|
||||
|
||||
impl FisherTransform {
|
||||
/// Construct with the rolling extrema window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
smoothed: 0.0,
|
||||
last_fisher: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current Fisher value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_fisher
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FisherTransform {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_fisher;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let max = self
|
||||
.window
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let range = max - min;
|
||||
// Normalise to roughly [-1, +1]; centred midpoint when range == 0.
|
||||
let raw = if range > 0.0 {
|
||||
((input - min) / range).mul_add(2.0, -1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
|
||||
self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
|
||||
// Clamp strictly inside (-1, +1) to keep the log finite.
|
||||
let clamped = self.smoothed.clamp(-0.999, 0.999);
|
||||
let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
|
||||
self.last_fisher = Some(fisher);
|
||||
Some(fisher)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.smoothed = 0.0;
|
||||
self.last_fisher = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_fisher.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FisherTransform"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut ft = FisherTransform::new(10).unwrap();
|
||||
assert_eq!(ft.period(), 10);
|
||||
assert_eq!(ft.warmup_period(), 10);
|
||||
assert_eq!(ft.name(), "FisherTransform");
|
||||
assert!(ft.value().is_none());
|
||||
for i in 1..=10 {
|
||||
ft.update(f64::from(i));
|
||||
}
|
||||
assert!(ft.value().is_some());
|
||||
assert!(ft.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none_until_seed() {
|
||||
let mut ft = FisherTransform::new(5).unwrap();
|
||||
for i in 1..=4 {
|
||||
assert_eq!(ft.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(ft.update(5.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_zero_range_yields_zero() {
|
||||
let mut ft = FisherTransform::new(5).unwrap();
|
||||
let out = ft.batch(&[42.0_f64; 30]);
|
||||
for x in out.iter().skip(5).flatten() {
|
||||
assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
|
||||
.collect();
|
||||
let mut a = FisherTransform::new(10).unwrap();
|
||||
let mut b = FisherTransform::new(10).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ft = FisherTransform::new(5).unwrap();
|
||||
ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let before = ft.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(ft.update(f64::NAN), before);
|
||||
assert_eq!(ft.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ft = FisherTransform::new(5).unwrap();
|
||||
ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(ft.is_ready());
|
||||
ft.reset();
|
||||
assert!(!ft.is_ready());
|
||||
assert_eq!(ft.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Fractal Chaos Bands (Bill Williams Fractals).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fractal Chaos Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FractalChaosBandsOutput {
|
||||
/// Upper band: high of the most recent confirmed fractal high.
|
||||
pub upper: f64,
|
||||
/// Lower band: low of the most recent confirmed fractal low.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Fractal Chaos Bands: a step-function envelope of the most recent Bill
|
||||
/// Williams fractal highs and lows.
|
||||
///
|
||||
/// A bar is a **fractal high** when its high is the maximum of the window
|
||||
/// `[i − k, …, i + k]`. A **fractal low** is defined symmetrically on lows.
|
||||
/// The bands hold the high (low) of the latest confirmed fractal high (low),
|
||||
/// stepping outwards whenever a new fractal forms and otherwise staying flat:
|
||||
///
|
||||
/// ```text
|
||||
/// confirmation_lag = k // the centre bar is known only k bars later
|
||||
/// upper = high of the most recent confirmed fractal high
|
||||
/// lower = low of the most recent confirmed fractal low
|
||||
/// ```
|
||||
///
|
||||
/// `k = 2` (5-bar fractals) is the canonical Williams setting and matches the
|
||||
/// "Fractal Chaos Bands" oscillator shipped with several chart vendors. With
|
||||
/// `k` bars of look-ahead, every band update reflects price `k` bars ago —
|
||||
/// strict streaming preserves this lag rather than peeking into the future.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, FractalChaosBands, Indicator};
|
||||
///
|
||||
/// let mut indicator = FractalChaosBands::new(2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// // Confirmation requires `2k + 1` bars plus at least one fractal of each
|
||||
/// // kind, so `last` may legitimately be `None` on a single sweep without
|
||||
/// // both a peak and a trough in the window.
|
||||
/// let _ = last;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FractalChaosBands {
|
||||
k: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last_upper: Option<f64>,
|
||||
last_lower: Option<f64>,
|
||||
}
|
||||
|
||||
impl FractalChaosBands {
|
||||
/// Construct a new Fractal Chaos Bands indicator with the given fractal
|
||||
/// half-width `k` (a bar is a fractal high if its high exceeds the highs
|
||||
/// of the `k` bars on either side; canonical `k = 2`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `k == 0` (a single bar is always its
|
||||
/// own trivial fractal).
|
||||
pub fn new(k: usize) -> Result<Self> {
|
||||
if k == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
k,
|
||||
window: VecDeque::with_capacity(2 * k + 1),
|
||||
last_upper: None,
|
||||
last_lower: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical Bill Williams configuration: `k = 2` (5-bar fractals).
|
||||
pub fn classic() -> Self {
|
||||
Self::new(2).expect("classic Fractal Chaos Bands parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured half-width `k`.
|
||||
pub const fn k(&self) -> usize {
|
||||
self.k
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FractalChaosBands {
|
||||
type Input = Candle;
|
||||
type Output = FractalChaosBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FractalChaosBandsOutput> {
|
||||
let window_len = 2 * self.k + 1;
|
||||
if self.window.len() == window_len {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < window_len {
|
||||
return None;
|
||||
}
|
||||
// The centre bar is at index `k`. Strictly compare against the `k`
|
||||
// bars on either side: `>` for the high and `<` for the low (a ties-
|
||||
// included pattern would fire on flat tops/bottoms, against Williams'
|
||||
// intent).
|
||||
let center = &self.window[self.k];
|
||||
let mut is_high = true;
|
||||
let mut is_low = true;
|
||||
for (i, c) in self.window.iter().enumerate() {
|
||||
if i == self.k {
|
||||
continue;
|
||||
}
|
||||
if c.high >= center.high {
|
||||
is_high = false;
|
||||
}
|
||||
if c.low <= center.low {
|
||||
is_low = false;
|
||||
}
|
||||
}
|
||||
if is_high {
|
||||
self.last_upper = Some(center.high);
|
||||
}
|
||||
if is_low {
|
||||
self.last_lower = Some(center.low);
|
||||
}
|
||||
// Both bands must have been seen at least once before we can emit.
|
||||
match (self.last_upper, self.last_lower) {
|
||||
(Some(u), Some(l)) => Some(FractalChaosBandsOutput { upper: u, lower: l }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last_upper = None;
|
||||
self.last_lower = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.k + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_upper.is_some() && self.last_lower.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FractalChaosBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_k() {
|
||||
assert!(matches!(FractalChaosBands::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FractalChaosBands::classic();
|
||||
assert_eq!(f.k(), 2);
|
||||
assert_eq!(f.warmup_period(), 5);
|
||||
assert_eq!(f.name(), "FractalChaosBands");
|
||||
}
|
||||
|
||||
/// Detect a single peak and a single trough with `k = 2`.
|
||||
/// Bars (high, low, close): (1,1,1), (2,2,2), (5,3,4), (3,1,2),
|
||||
/// (2,2,2), (1,1,1), (2,2,2), (5,3,4).
|
||||
/// Indices: 0..7. The peak at i=2 is `>` its 2 neighbours on each side
|
||||
/// (after index 4 lands). The trough at i=3 is `<` its 2 neighbours on
|
||||
/// each side (after index 5 lands). Both bands first emit on index 5.
|
||||
#[test]
|
||||
fn detects_simple_peak_and_trough() {
|
||||
let candles = vec![
|
||||
c(1.0, 1.0, 1.0),
|
||||
c(2.0, 2.0, 2.0),
|
||||
c(5.0, 3.0, 4.0), // peak: high 5 is the max of neighbouring 4
|
||||
c(3.0, 0.5, 1.0), // trough: low 0.5 is the min
|
||||
c(2.0, 2.0, 2.0),
|
||||
c(1.0, 1.0, 1.0),
|
||||
c(2.0, 2.0, 2.0),
|
||||
];
|
||||
let mut f = FractalChaosBands::new(2).unwrap();
|
||||
let out = f.batch(&candles);
|
||||
// Bars 0..4 are warmup or single-band only — both bands haven't been
|
||||
// confirmed yet.
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
// Bar 5 confirms the trough at i=3 (low 0.5); the peak at i=2 was
|
||||
// confirmed by bar 4 (centre 2, look-ahead 2 → index 4). So index 5
|
||||
// is the first bar with *both* upper and lower set.
|
||||
let v = out[5].unwrap();
|
||||
assert_relative_eq!(v.upper, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
/// In a flat market no bar is strictly higher (or lower) than its
|
||||
/// neighbours, so no fractal ever confirms and the indicator never emits.
|
||||
#[test]
|
||||
fn flat_market_never_emits() {
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut f = FractalChaosBands::new(2).unwrap();
|
||||
for v in f.batch(&candles) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.5).sin() * 3.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut a = FractalChaosBands::new(2).unwrap();
|
||||
let mut b = FractalChaosBands::new(2).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles = vec![
|
||||
c(1.0, 1.0, 1.0),
|
||||
c(2.0, 2.0, 2.0),
|
||||
c(5.0, 3.0, 4.0),
|
||||
c(3.0, 0.5, 1.0),
|
||||
c(2.0, 2.0, 2.0),
|
||||
c(1.0, 1.0, 1.0),
|
||||
c(2.0, 2.0, 2.0),
|
||||
];
|
||||
let mut f = FractalChaosBands::new(2).unwrap();
|
||||
f.batch(&candles);
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_lower_when_both_set() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut f = FractalChaosBands::new(2).unwrap();
|
||||
for o in f.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Fractal Adaptive Moving Average (FRAMA).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Fractal Adaptive Moving Average.
|
||||
///
|
||||
/// FRAMA picks its smoothing constant from the fractal dimension `D` of the
|
||||
/// recent window: in a trending (low-`D`) market it follows price tightly, in
|
||||
/// a choppy (high-`D`) market it smooths heavily. The window of `period`
|
||||
/// closes is split into two equal halves; the fractal dimension comes from
|
||||
/// the price ranges of the halves vs. the whole window:
|
||||
///
|
||||
/// ```text
|
||||
/// N1 = (max(first half) - min(first half)) / (period / 2)
|
||||
/// N2 = (max(second half) - min(second half)) / (period / 2)
|
||||
/// N3 = (max(window) - min(window)) / period
|
||||
/// D = (log(N1 + N2) - log(N3)) / log(2)
|
||||
/// alpha = exp(-4.6 * (D - 1)) clamped to [0.01, 1.0]
|
||||
/// ```
|
||||
///
|
||||
/// The output is an EMA-like recurrence
|
||||
/// `FRAMA_t = alpha * close_t + (1 - alpha) * FRAMA_{t - 1}`, seeded with the
|
||||
/// first close. `period` must be even and at least 2.
|
||||
///
|
||||
/// Reference: John F. Ehlers, *Fractal Adaptive Moving Average*, 2005.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Frama, Indicator};
|
||||
///
|
||||
/// let mut frama = Frama::new(16).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = frama.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frama {
|
||||
period: usize,
|
||||
half: usize,
|
||||
window: VecDeque<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Frama {
|
||||
/// # Errors
|
||||
/// - [`Error::PeriodZero`] if `period == 0`.
|
||||
/// - [`Error::InvalidPeriod`] if `period` is odd or below 2.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "FRAMA period must be at least 2",
|
||||
});
|
||||
}
|
||||
if period % 2 != 0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "FRAMA period must be even",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
half: period / 2,
|
||||
window: VecDeque::with_capacity(period),
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Frama {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let half = self.half;
|
||||
let mut h_first = f64::NEG_INFINITY;
|
||||
let mut l_first = f64::INFINITY;
|
||||
let mut h_second = f64::NEG_INFINITY;
|
||||
let mut l_second = f64::INFINITY;
|
||||
let mut h_whole = f64::NEG_INFINITY;
|
||||
let mut l_whole = f64::INFINITY;
|
||||
for (i, &p) in self.window.iter().enumerate() {
|
||||
if p > h_whole {
|
||||
h_whole = p;
|
||||
}
|
||||
if p < l_whole {
|
||||
l_whole = p;
|
||||
}
|
||||
if i < half {
|
||||
if p > h_first {
|
||||
h_first = p;
|
||||
}
|
||||
if p < l_first {
|
||||
l_first = p;
|
||||
}
|
||||
} else {
|
||||
if p > h_second {
|
||||
h_second = p;
|
||||
}
|
||||
if p < l_second {
|
||||
l_second = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let half_f = half as f64;
|
||||
let period_f = self.period as f64;
|
||||
let n1 = (h_first - l_first) / half_f;
|
||||
let n2 = (h_second - l_second) / half_f;
|
||||
let n3 = (h_whole - l_whole) / period_f;
|
||||
|
||||
let alpha = if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
|
||||
let d = ((n1 + n2).ln() - n3.ln()) / 2.0_f64.ln();
|
||||
(-4.6 * (d - 1.0)).exp().clamp(0.01, 1.0)
|
||||
} else {
|
||||
// Degenerate (perfectly flat half or whole window): use the slowest
|
||||
// smoothing so the indicator coasts on its previous value.
|
||||
0.01
|
||||
};
|
||||
|
||||
let prev = self.current.unwrap_or(input);
|
||||
let next = alpha * input + (1.0 - alpha) * prev;
|
||||
self.current = Some(next);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FRAMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Frama::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(Frama::new(1), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Frama::new(3), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Frama::new(15), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let frama = Frama::new(16).unwrap();
|
||||
assert_eq!(frama.period(), 16);
|
||||
assert_eq!(frama.warmup_period(), 16);
|
||||
assert_eq!(frama.name(), "FRAMA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// Flat input -> alpha clamps to 0.01 (degenerate ranges) and the
|
||||
// EMA recurrence holds the seed value forever.
|
||||
let mut frama = Frama::new(4).unwrap();
|
||||
let out = frama.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(3).flatten() {
|
||||
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut frama = Frama::new(4).unwrap();
|
||||
assert_eq!(frama.update(1.0), None);
|
||||
assert_eq!(frama.update(2.0), None);
|
||||
assert_eq!(frama.update(3.0), None);
|
||||
assert!(frama.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_alpha_close_to_one() {
|
||||
// A strict monotonic uptrend has fractal dimension ~1, so alpha is
|
||||
// pushed to 1.0 and FRAMA reduces to the latest price.
|
||||
let mut frama = Frama::new(4).unwrap();
|
||||
let prices: Vec<f64> = (1..=8).map(f64::from).collect();
|
||||
let out = frama.batch(&prices);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(
|
||||
(last - 8.0).abs() < 0.05,
|
||||
"FRAMA on a clean uptrend should hug the latest close: {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Frama::new(8).unwrap();
|
||||
let mut b = Frama::new(8).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut frama = Frama::new(4).unwrap();
|
||||
frama.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(frama.is_ready());
|
||||
frama.reset();
|
||||
assert!(!frama.is_ready());
|
||||
assert_eq!(frama.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut frama = Frama::new(4).unwrap();
|
||||
frama.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let before = frama.update(5.0).unwrap();
|
||||
assert_eq!(frama.update(f64::NAN), Some(before));
|
||||
assert_eq!(frama.update(f64::INFINITY), Some(before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Rolling Gain/Loss Ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Gain/Loss Ratio.
|
||||
///
|
||||
/// Over the trailing window:
|
||||
///
|
||||
/// ```text
|
||||
/// avg_win = mean(r for r in window if r > 0)
|
||||
/// avg_loss = mean(−r for r in window if r < 0)
|
||||
/// GLR = avg_win / avg_loss
|
||||
/// ```
|
||||
///
|
||||
/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages
|
||||
/// them: it answers "for the typical winning bar, how big is the win
|
||||
/// compared to the typical losing bar?". If there are no losers the
|
||||
/// indicator returns `f64::INFINITY`; if there are no winners and no losers
|
||||
/// it returns `0.0`.
|
||||
///
|
||||
/// Each `update` is O(period).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GainLossRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl GainLossRatio {
|
||||
/// Construct a new rolling Gain/Loss Ratio.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GainLossRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut sum_win = 0.0_f64;
|
||||
let mut n_win = 0_u32;
|
||||
let mut sum_loss = 0.0_f64;
|
||||
let mut n_loss = 0_u32;
|
||||
for &r in &self.window {
|
||||
if r > 0.0 {
|
||||
sum_win += r;
|
||||
n_win += 1;
|
||||
} else if r < 0.0 {
|
||||
sum_loss += -r;
|
||||
n_loss += 1;
|
||||
}
|
||||
}
|
||||
if n_loss == 0 {
|
||||
return Some(if n_win == 0 { 0.0 } else { f64::INFINITY });
|
||||
}
|
||||
let avg_win = if n_win == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum_win / f64::from(n_win)
|
||||
};
|
||||
let avg_loss = sum_loss / f64::from(n_loss);
|
||||
Some(avg_win / avg_loss)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GainLossRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GainLossRatio::new(10).unwrap();
|
||||
assert_eq!(g.period(), 10);
|
||||
assert_eq!(g.name(), "GainLossRatio");
|
||||
assert_eq!(g.warmup_period(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns = [0.02, -0.01, 0.04, -0.03]
|
||||
// avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
|
||||
let mut g = GainLossRatio::new(4).unwrap();
|
||||
let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
|
||||
assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_losses_yields_infinity() {
|
||||
let mut g = GainLossRatio::new(3).unwrap();
|
||||
let out = g.batch(&[0.01, 0.02, 0.03]);
|
||||
assert!(out[2].unwrap().is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_yields_zero() {
|
||||
let mut g = GainLossRatio::new(3).unwrap();
|
||||
let out = g.batch(&[0.0_f64; 3]);
|
||||
assert_eq!(out[2], Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut g = GainLossRatio::new(3).unwrap();
|
||||
assert_eq!(g.update(f64::NAN), None);
|
||||
assert_eq!(g.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_wins_but_losses_yields_zero() {
|
||||
// Window with only losses: avg_win is 0, GLR = 0.
|
||||
let mut g = GainLossRatio::new(3).unwrap();
|
||||
let out = g.batch(&[-0.01, -0.02, -0.03]);
|
||||
assert_eq!(out[2], Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GainLossRatio::new(3).unwrap();
|
||||
g.batch(&[0.01, -0.02, 0.03]);
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
|
||||
let batch = GainLossRatio::new(10).unwrap().batch(&returns);
|
||||
let mut s = GainLossRatio::new(10).unwrap();
|
||||
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Garman-Klass Volatility (OHLC estimator).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Garman-Klass Volatility — an OHLC realised-volatility estimator.
|
||||
///
|
||||
/// Garman & Klass (1980) extended Parkinson's high-low estimator by adding
|
||||
/// an open-to-close term, removing some of the bias introduced when the
|
||||
/// closing price drifts within the bar. The per-bar sample is
|
||||
///
|
||||
/// ```text
|
||||
/// s_t = 0.5 · (ln(H_t / L_t))² − (2·ln 2 − 1) · (ln(C_t / O_t))²
|
||||
/// ```
|
||||
///
|
||||
/// and the indicator returns the annualised square root of the rolling
|
||||
/// mean of `s_t`:
|
||||
///
|
||||
/// ```text
|
||||
/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100
|
||||
/// ```
|
||||
///
|
||||
/// Garman & Klass showed the estimator is ~7.4× more statistically efficient
|
||||
/// than the close-to-close estimator under driftless Geometric Brownian
|
||||
/// Motion (Parkinson sits at ~5.0×). It is still biased when there is
|
||||
/// significant overnight drift between bars — use the Yang-Zhang estimator
|
||||
/// when the dataset has meaningful close-to-open gaps.
|
||||
///
|
||||
/// The per-bar sample `s_t` can be slightly negative when the bar's range
|
||||
/// is small relative to its open-to-close move; this matches the original
|
||||
/// paper's algebra and is handled by clamping the rolling mean to zero
|
||||
/// before taking the square root.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, GarmanKlassVolatility, Indicator};
|
||||
///
|
||||
/// let mut indicator = GarmanKlassVolatility::new(20, 252).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i))
|
||||
/// .unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GarmanKlassVolatility {
|
||||
period: usize,
|
||||
trading_periods: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
/// `2 · ln 2 − 1` — the Garman-Klass open-to-close weight.
|
||||
const GK_OC_COEFF: f64 = 0.386_294_361_119_890_6;
|
||||
|
||||
impl GarmanKlassVolatility {
|
||||
/// Construct a Garman-Klass Volatility estimator.
|
||||
///
|
||||
/// `period` is the rolling window of bars; `trading_periods` is the
|
||||
/// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
|
||||
/// `1` for raw per-bar volatility).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either parameter is `0`.
|
||||
pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
|
||||
if period == 0 || trading_periods == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
trading_periods,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, trading_periods)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.period, self.trading_periods)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GarmanKlassVolatility {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
// `Candle::new` enforces finite, positive OHLC with `high >= max(open,
|
||||
// low, close)` and `low <= min(open, high, close)`, so every log
|
||||
// ratio below is well-defined and `ln(H/L) >= 0`.
|
||||
let log_hl = (candle.high / candle.low).ln();
|
||||
let log_co = (candle.close / candle.open).ln();
|
||||
let sample = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum -= old;
|
||||
}
|
||||
self.window.push_back(sample);
|
||||
self.sum += sample;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let n = self.period as f64;
|
||||
// Rolling mean. Garman-Klass samples can be marginally negative on
|
||||
// narrow-range bars with large O-to-C moves; the rolling mean is
|
||||
// theoretically `>= 0` but a clamp absorbs FP cancellation and the
|
||||
// pathological all-negative case.
|
||||
let variance = (self.sum / n).max(0.0);
|
||||
let sigma = variance.sqrt();
|
||||
let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GarmanKlassVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
|
||||
Candle::new(o, h, l, c, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
GarmanKlassVolatility::new(0, 252),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
GarmanKlassVolatility::new(20, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let gk = GarmanKlassVolatility::new(20, 252).unwrap();
|
||||
assert_eq!(gk.periods(), (20, 252));
|
||||
assert_eq!(gk.value(), None);
|
||||
assert_eq!(gk.warmup_period(), 20);
|
||||
assert_eq!(gk.name(), "GarmanKlassVolatility");
|
||||
assert!(!gk.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_movement_yields_zero() {
|
||||
// O == H == L == C -> both log terms are zero -> sigma is zero.
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
|
||||
let mut gk = GarmanKlassVolatility::new(14, 1).unwrap();
|
||||
for v in gk.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_bar_shape_yields_constant_sigma() {
|
||||
// Every bar has identical O/H/L/C ratios -> per-bar sample is a
|
||||
// constant `k`, so the rolling mean is `k` and the output is
|
||||
// `sqrt(k) * 100` (trading_periods = 1).
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
|
||||
let log_hl = (11.0_f64 / 9.0_f64).ln();
|
||||
let log_co = (10.2_f64 / 10.0_f64).ln();
|
||||
let k = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
|
||||
let expected = k.max(0.0).sqrt() * 100.0;
|
||||
|
||||
let mut gk = GarmanKlassVolatility::new(10, 1).unwrap();
|
||||
let out = gk.batch(&candles);
|
||||
for v in out.iter().skip(9).flatten() {
|
||||
assert_relative_eq!(*v, expected, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
|
||||
let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
|
||||
let open = base - 0.1;
|
||||
let close = base + 0.2;
|
||||
candle(open, base + half, base - half, close, i64::from(i))
|
||||
})
|
||||
.collect();
|
||||
for v in gk.batch(&candles).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "Garman-Klass must be non-negative: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annualisation_scales_by_sqrt_trading_periods() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
|
||||
candle(base, base + half, base - half, base + 0.3, i64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let raw = GarmanKlassVolatility::new(10, 1).unwrap().batch(&candles);
|
||||
let annual = GarmanKlassVolatility::new(10, 252).unwrap().batch(&candles);
|
||||
let scale = (252.0_f64).sqrt();
|
||||
for (r, a) in raw.iter().zip(annual.iter()) {
|
||||
assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
|
||||
if let (Some(r), Some(a)) = (r, a) {
|
||||
assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
|
||||
let mut gk = GarmanKlassVolatility::new(5, 1).unwrap();
|
||||
let out = gk.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
|
||||
let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
|
||||
candle(base, base + half, base - half, base + 0.5, i64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = GarmanKlassVolatility::new(14, 252).unwrap().batch(&candles);
|
||||
let mut streamer = GarmanKlassVolatility::new(14, 252).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
|
||||
let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
|
||||
gk.batch(&candles);
|
||||
assert!(gk.is_ready());
|
||||
gk.reset();
|
||||
assert!(!gk.is_ready());
|
||||
assert_eq!(gk.value(), None);
|
||||
assert_eq!(gk.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Hammer candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hammer — a single-bar bullish reversal candidate.
|
||||
///
|
||||
/// A Hammer has a small real body sitting near the top of the bar, a long
|
||||
/// lower shadow at least twice the body, and a short or absent upper shadow.
|
||||
/// It is traditionally read as a rejection of lower prices.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// hammer = lower_shadow >= 2 * body
|
||||
/// && upper_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Hammer, Indicator};
|
||||
///
|
||||
/// let mut indicator = Hammer::new();
|
||||
/// // Open 10, close 10.5, low 5, high 10.6: long lower shadow, tiny upper.
|
||||
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Hammer {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Hammer {
|
||||
/// Construct a new Hammer detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Hammer {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
if range <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body = (candle.close - candle.open).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if lower >= 2.0 * body && upper <= body {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Hammer"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = Hammer::new();
|
||||
assert_eq!(h.name(), "Hammer");
|
||||
assert_eq!(h.warmup_period(), 1);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_hammer_is_one() {
|
||||
let mut h = Hammer::new();
|
||||
// body 0.5 (10 -> 10.5), lower shadow 5.0, upper shadow 0.1.
|
||||
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_hammer() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shooting_star_shape_is_not_hammer() {
|
||||
// Long upper, short lower -> not a hammer.
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.5, 15.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_hammer() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut h = Hammer::new();
|
||||
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 4.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Hammer::new();
|
||||
let mut b = Hammer::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = Hammer::new();
|
||||
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Hanging Man candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hanging Man — a single-bar bearish reversal candidate.
|
||||
///
|
||||
/// A Hanging Man has the same geometry as a Hammer (small body near the top,
|
||||
/// long lower shadow ≥ 2× body, short upper shadow) but is read bearishly
|
||||
/// because it appears at the top of an uptrend.
|
||||
///
|
||||
/// ```text
|
||||
/// body = |close − open|
|
||||
/// upper_shadow = high − max(open, close)
|
||||
/// lower_shadow = min(open, close) − low
|
||||
/// hanging = lower_shadow >= 2 * body
|
||||
/// && upper_shadow <= body
|
||||
/// && body > 0
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the shape matches, `0.0` otherwise. Pattern-shape
|
||||
/// check only — no trend filter is applied; combine with a trend indicator
|
||||
/// for actionable signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, HangingMan, Indicator};
|
||||
///
|
||||
/// let mut indicator = HangingMan::new();
|
||||
/// let candle = Candle::new(10.0, 10.6, 5.0, 10.5, 1.0, 0).unwrap();
|
||||
/// assert_eq!(indicator.update(candle), Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HangingMan {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HangingMan {
|
||||
/// Construct a new Hanging Man detector.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HangingMan {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
if range <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let body = (candle.close - candle.open).abs();
|
||||
if body <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let upper = candle.high - candle.open.max(candle.close);
|
||||
let lower = candle.open.min(candle.close) - candle.low;
|
||||
Some(if lower >= 2.0 * body && upper <= body {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HangingMan"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HangingMan::new();
|
||||
assert_eq!(h.name(), "HangingMan");
|
||||
assert_eq!(h.warmup_period(), 1);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_hanging_man_is_minus_one() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marubozu_is_not_hanging_man() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_is_not_hanging_man() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_yields_zero() {
|
||||
let mut h = HangingMan::new();
|
||||
assert_eq!(h.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 4.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = HangingMan::new();
|
||||
let mut b = HangingMan::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HangingMan::new();
|
||||
h.update(c(10.0, 10.6, 5.0, 10.5, 0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Bullish / Bearish Harami candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Harami — a 2-bar reversal pattern. The current candle's body sits entirely
|
||||
/// inside the previous candle's body and points in the opposite direction.
|
||||
///
|
||||
/// ```text
|
||||
/// prev_body = |prev.close − prev.open|
|
||||
/// curr_body = |curr.close − curr.open|
|
||||
/// bullish = prev red & curr green
|
||||
/// & curr.open >= prev.close & curr.close <= prev.open
|
||||
/// & curr_body < prev_body
|
||||
/// bearish = prev green & curr red
|
||||
/// & curr.open <= prev.close & curr.close >= prev.open
|
||||
/// & curr_body < prev_body
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` for a bullish harami (small green inside a prior red),
|
||||
/// `−1.0` for a bearish harami (small red inside a prior green), `0.0`
|
||||
/// otherwise. The first bar always returns `0.0`. Pattern-shape check only —
|
||||
/// no trend filter is applied; combine with a trend indicator for actionable
|
||||
/// signals.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Harami, Indicator};
|
||||
///
|
||||
/// let mut indicator = Harami::new();
|
||||
/// indicator.update(Candle::new(12.0, 12.5, 9.5, 10.0, 1.0, 0).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(10.5, 11.5, 10.4, 11.0, 1.0, 1).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Harami {
|
||||
prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Harami {
|
||||
/// Construct a new Harami detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Harami {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let Some(p) = prev else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body = (p.close - p.open).abs();
|
||||
let curr_body = (candle.close - candle.open).abs();
|
||||
if prev_body <= 0.0 || curr_body <= 0.0 || curr_body >= prev_body {
|
||||
return Some(0.0);
|
||||
}
|
||||
let prev_red = p.close < p.open;
|
||||
let prev_green = p.close > p.open;
|
||||
let curr_green = candle.close > candle.open;
|
||||
let curr_red = candle.close < candle.open;
|
||||
// Bullish: small green strictly inside prior red body (open >= prev.close, close <= prev.open).
|
||||
if prev_red && curr_green && candle.open >= p.close && candle.close <= p.open {
|
||||
Some(1.0)
|
||||
} else if prev_green && curr_red && candle.open <= p.close && candle.close >= p.open {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Harami"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = Harami::new();
|
||||
assert_eq!(h.name(), "Harami");
|
||||
assert_eq!(h.warmup_period(), 2);
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_harami_is_plus_one() {
|
||||
let mut h = Harami::new();
|
||||
// Prior red 12 -> 10 (body 2). Current green 10.5 -> 11 inside.
|
||||
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
assert_eq!(h.update(c(10.5, 11.5, 10.4, 11.0, 1)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_harami_is_minus_one() {
|
||||
let mut h = Harami::new();
|
||||
// Prior green 10 -> 12 (body 2). Current red 11.5 -> 11 inside.
|
||||
assert_eq!(h.update(c(10.0, 12.5, 9.5, 12.0, 0)), Some(0.0));
|
||||
assert_eq!(h.update(c(11.5, 11.6, 10.9, 11.0, 1)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn larger_body_is_not_harami() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(11.0, 11.2, 9.8, 10.0, 0));
|
||||
// Current body bigger than prior.
|
||||
assert_eq!(h.update(c(9.5, 12.0, 9.5, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_not_harami() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(10.0, 12.5, 9.5, 12.0, 0));
|
||||
// Smaller candle but also green -> 0.
|
||||
assert_eq!(h.update(c(11.0, 11.6, 10.9, 11.5, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_returns_zero() {
|
||||
let mut h = Harami::new();
|
||||
assert_eq!(h.update(c(10.0, 11.0, 9.0, 11.0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
if i % 2 == 0 {
|
||||
c(base + 2.0, base + 2.5, base - 0.5, base, i)
|
||||
} else {
|
||||
c(base + 1.0, base + 1.5, base + 0.7, base + 1.3, i)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut a = Harami::new();
|
||||
let mut b = Harami::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = Harami::new();
|
||||
h.update(c(12.0, 12.5, 9.5, 10.0, 0));
|
||||
h.update(c(10.5, 11.5, 10.4, 11.0, 1));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
// After reset the next bar again has no prev.
|
||||
assert_eq!(h.update(c(12.0, 12.5, 9.5, 10.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Heikin-Ashi candle transform.
|
||||
#![allow(clippy::manual_midpoint)]
|
||||
//!
|
||||
//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so
|
||||
//! trends are easier to read at a glance. The transform is purely local except
|
||||
//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains
|
||||
//! a streaming O(1) state machine.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One Heikin-Ashi candle.
|
||||
///
|
||||
/// Fields use the same names as the source `Candle` but represent the
|
||||
/// transformed OHLC.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HeikinAshiOutput {
|
||||
/// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close.
|
||||
pub open: f64,
|
||||
/// Heikin-Ashi high: `max(real high, ha_open, ha_close)`.
|
||||
pub high: f64,
|
||||
/// Heikin-Ashi low: `min(real low, ha_open, ha_close)`.
|
||||
pub low: f64,
|
||||
/// Heikin-Ashi close: average of the real open, high, low, close.
|
||||
pub close: f64,
|
||||
}
|
||||
|
||||
/// Streaming Heikin-Ashi transform.
|
||||
///
|
||||
/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very
|
||||
/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n`
|
||||
/// inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, HeikinAshi, Indicator};
|
||||
///
|
||||
/// let mut ha = HeikinAshi::new();
|
||||
/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap();
|
||||
/// let out = ha.update(c).unwrap();
|
||||
/// // First bar: ha_open = (open + close) / 2 = 10.25.
|
||||
/// assert!((out.open - 10.25).abs() < 1e-12);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HeikinAshi {
|
||||
prev: Option<HeikinAshiOutput>,
|
||||
}
|
||||
|
||||
impl HeikinAshi {
|
||||
/// Construct a fresh transform with no prior state.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { prev: None }
|
||||
}
|
||||
|
||||
/// Most recently emitted Heikin-Ashi candle, if any.
|
||||
pub const fn value(&self) -> Option<HeikinAshiOutput> {
|
||||
self.prev
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeikinAshi {
|
||||
type Input = Candle;
|
||||
type Output = HeikinAshiOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<HeikinAshiOutput> {
|
||||
let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0;
|
||||
let ha_open = match self.prev {
|
||||
Some(p) => f64::midpoint(p.open, p.close),
|
||||
// Seed: average of the real open and close.
|
||||
None => f64::midpoint(candle.open, candle.close),
|
||||
};
|
||||
let ha_high = candle.high.max(ha_open).max(ha_close);
|
||||
let ha_low = candle.low.min(ha_open).min(ha_close);
|
||||
let out = HeikinAshiOutput {
|
||||
open: ha_open,
|
||||
high: ha_high,
|
||||
low: ha_low,
|
||||
close: ha_close,
|
||||
};
|
||||
self.prev = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeikinAshi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle {
|
||||
Candle::new(o, h, l, c, 0.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_open_from_real_open_close() {
|
||||
let mut ha = HeikinAshi::new();
|
||||
let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
|
||||
assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12);
|
||||
// high/low must envelope ha_open & ha_close along with the real H/L.
|
||||
assert!(out.high >= out.open);
|
||||
assert!(out.high >= out.close);
|
||||
assert!(out.low <= out.open);
|
||||
assert!(out.low <= out.close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_bar_uses_previous_ha_midpoint_as_open() {
|
||||
let mut ha = HeikinAshi::new();
|
||||
let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap();
|
||||
let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap();
|
||||
assert_relative_eq!(
|
||||
second.open,
|
||||
(first.open + first.close) / 2.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
assert_relative_eq!(
|
||||
second.close,
|
||||
(11.5 + 13.0 + 10.5 + 12.0) / 4.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let p = 100.0 + f64::from(i);
|
||||
cnd(p, p + 1.5, p - 1.5, p + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let mut a = HeikinAshi::new();
|
||||
let mut b = HeikinAshi::new();
|
||||
let batched = a.batch(&candles);
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batched, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut ha = HeikinAshi::new();
|
||||
assert!(!ha.is_ready());
|
||||
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
|
||||
assert!(ha.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ha = HeikinAshi::new();
|
||||
ha.update(cnd(10.0, 11.0, 9.0, 10.5));
|
||||
assert!(ha.is_ready());
|
||||
ha.reset();
|
||||
assert!(!ha.is_ready());
|
||||
assert!(ha.value().is_none());
|
||||
// After reset, the next bar re-seeds from real open/close.
|
||||
let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap();
|
||||
assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata() {
|
||||
let ha = HeikinAshi::new();
|
||||
assert_eq!(ha.warmup_period(), 1);
|
||||
assert_eq!(ha.name(), "HeikinAshi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_envelopes_open_and_close() {
|
||||
// Real high below the synthetic ha_open/close still inflates ha_high.
|
||||
let mut ha = HeikinAshi::new();
|
||||
// Bar 1 sets a baseline.
|
||||
ha.update(cnd(100.0, 101.0, 99.0, 100.5));
|
||||
// Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5,
|
||||
// ha_open = midpoint of prev open/close — and a real high of 200.
|
||||
let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap();
|
||||
assert_eq!(out.high, 200.0);
|
||||
assert!(out.low <= out.open.min(out.close));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Ehlers Hilbert Transform Dominant Cycle period estimator.
|
||||
#![allow(clippy::manual_clamp)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Hilbert Transform–based Dominant Cycle period estimator.
|
||||
///
|
||||
/// Decomposes price into in-phase and quadrature components via Ehlers'
|
||||
/// truncated Hilbert transform, then derives the instantaneous phase. The
|
||||
/// dominant cycle period is recovered from the phase rate of change and
|
||||
/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7),
|
||||
/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`.
|
||||
///
|
||||
/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies
|
||||
/// as the meaningful tradable cycle range. The estimator emits its first
|
||||
/// value after ~50 inputs as the moving-average chain fills.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HilbertDominantCycle};
|
||||
///
|
||||
/// let mut ht = HilbertDominantCycle::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HilbertDominantCycle {
|
||||
// Rolling 7-tap smoother input buffer.
|
||||
smooth_buf: Vec<f64>,
|
||||
// Detrender / Q1 / I1 ring history (need 6 prior).
|
||||
detrender_buf: Vec<f64>,
|
||||
q1_buf: Vec<f64>,
|
||||
i1_buf: Vec<f64>,
|
||||
// Smoothed I/Q lines for phase computation.
|
||||
prev_i2: f64,
|
||||
prev_q2: f64,
|
||||
prev_re: f64,
|
||||
prev_im: f64,
|
||||
prev_period: f64,
|
||||
prev_smooth_period: f64,
|
||||
count: usize,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl HilbertDominantCycle {
|
||||
/// Construct a new dominant cycle estimator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current period estimate if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HilbertDominantCycle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
self.count += 1;
|
||||
|
||||
// 4-bar weighted moving average of the input (smoothed price).
|
||||
// Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10.
|
||||
Self::push_front(&mut self.smooth_buf, input, 7);
|
||||
if self.smooth_buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let smooth = (4.0 * self.smooth_buf[0]
|
||||
+ 3.0 * self.smooth_buf[1]
|
||||
+ 2.0 * self.smooth_buf[2]
|
||||
+ self.smooth_buf[3])
|
||||
/ 10.0;
|
||||
|
||||
// Adaptive coefficient based on the previous period estimate.
|
||||
let period = self.prev_period.max(6.0).min(50.0);
|
||||
let adj = 0.075 * period + 0.54;
|
||||
|
||||
// We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps.
|
||||
if self.smooth_buf.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags).
|
||||
let s0 = smooth;
|
||||
let s2 = self.smooth_buf[2];
|
||||
let s4 = self.smooth_buf[4];
|
||||
let s6 = self.smooth_buf[6];
|
||||
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
|
||||
Self::push_front(&mut self.detrender_buf, detrender, 7);
|
||||
|
||||
if self.detrender_buf.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
// In-phase and quadrature components.
|
||||
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
|
||||
- 0.5769 * self.detrender_buf[4]
|
||||
- 0.0962 * self.detrender_buf[6])
|
||||
* adj;
|
||||
let i1 = self.detrender_buf[3];
|
||||
|
||||
Self::push_front(&mut self.q1_buf, q1, 7);
|
||||
Self::push_front(&mut self.i1_buf, i1, 7);
|
||||
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Advance the phase 90 deg via a second Hilbert pass.
|
||||
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
|
||||
- 0.5769 * self.i1_buf[4]
|
||||
- 0.0962 * self.i1_buf[6])
|
||||
* adj;
|
||||
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
|
||||
- 0.5769 * self.q1_buf[4]
|
||||
- 0.0962 * self.q1_buf[6])
|
||||
* adj;
|
||||
|
||||
// Phasor smoothing.
|
||||
let mut i2 = i1 - jq;
|
||||
let mut q2 = q1 + ji;
|
||||
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
|
||||
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
|
||||
|
||||
// Homodyne discriminator.
|
||||
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
|
||||
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
|
||||
re = 0.2 * re + 0.8 * self.prev_re;
|
||||
im = 0.2 * im + 0.8 * self.prev_im;
|
||||
|
||||
self.prev_i2 = i2;
|
||||
self.prev_q2 = q2;
|
||||
self.prev_re = re;
|
||||
self.prev_im = im;
|
||||
|
||||
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
|
||||
2.0 * PI / im.atan2(re)
|
||||
} else {
|
||||
self.prev_period
|
||||
};
|
||||
// Rate-of-change clamp per Ehlers.
|
||||
new_period = new_period.min(1.5 * self.prev_period);
|
||||
new_period = new_period.max(0.67 * self.prev_period);
|
||||
new_period = new_period.clamp(6.0, 50.0);
|
||||
|
||||
// EMA smoothing of the period.
|
||||
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
|
||||
// Second smoothing step (TA-Lib uses 0.33/0.67).
|
||||
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
|
||||
|
||||
if self.count < 50 {
|
||||
return None;
|
||||
}
|
||||
self.last_value = Some(self.prev_smooth_period);
|
||||
Some(self.prev_smooth_period)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smooth_buf.clear();
|
||||
self.detrender_buf.clear();
|
||||
self.q1_buf.clear();
|
||||
self.i1_buf.clear();
|
||||
self.prev_i2 = 0.0;
|
||||
self.prev_q2 = 0.0;
|
||||
self.prev_re = 0.0;
|
||||
self.prev_im = 0.0;
|
||||
self.prev_period = 0.0;
|
||||
self.prev_smooth_period = 0.0;
|
||||
self.count = 0;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HilbertDominantCycle"
|
||||
}
|
||||
}
|
||||
|
||||
impl HilbertDominantCycle {
|
||||
/// Push `v` at the front of `buf`, capping the length at `cap`.
|
||||
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
|
||||
buf.insert(0, v);
|
||||
if buf.len() > cap {
|
||||
buf.truncate(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut ht = HilbertDominantCycle::new();
|
||||
assert_eq!(ht.warmup_period(), 50);
|
||||
assert_eq!(ht.name(), "HilbertDominantCycle");
|
||||
assert!(!ht.is_ready());
|
||||
assert!(ht.value().is_none());
|
||||
for i in 0..120 {
|
||||
ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
}
|
||||
assert!(ht.is_ready());
|
||||
assert!(ht.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_clamp_band() {
|
||||
let mut ht = HilbertDominantCycle::new();
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
let out = ht.batch(&prices);
|
||||
for v in out.iter().flatten() {
|
||||
assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = HilbertDominantCycle::new();
|
||||
let mut b = HilbertDominantCycle::new();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ht = HilbertDominantCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ht.batch(&prices);
|
||||
let before = ht.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(ht.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ht = HilbertDominantCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ht.batch(&prices);
|
||||
assert!(ht.is_ready());
|
||||
ht.reset();
|
||||
assert!(!ht.is_ready());
|
||||
assert!(ht.value().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! `HiLo` Activator (Crabel).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `HiLo` Activator — Robert Krausz's adaptation of Linda Bradford Raschke and
|
||||
/// Larry Connors' "`HiLo`" rule, popularised by Toby Crabel. Two simple moving
|
||||
/// averages — of the high and of the low — bracket price; the trailing stop
|
||||
/// for a long sits at the SMA-of-low, and for a short at the SMA-of-high.
|
||||
///
|
||||
/// ```text
|
||||
/// hi_sma = SMA(high, period) // potential short stop
|
||||
/// lo_sma = SMA(low, period) // potential long stop
|
||||
///
|
||||
/// state-machine:
|
||||
/// long while close > hi_sma_prev -> emit lo_sma_prev
|
||||
/// short while close < lo_sma_prev -> emit hi_sma_prev
|
||||
/// else: hold the previous side
|
||||
/// ```
|
||||
///
|
||||
/// Comparing the close to the *previous* bar's SMA avoids look-ahead and gives
|
||||
/// the indicator a one-bar lag — the classic Crabel formulation. A long signal
|
||||
/// fires the bar after price closes above the high-SMA; the stop then trails
|
||||
/// at the low-SMA. The first input that fills the SMA window seeds a long.
|
||||
/// A common configuration is a `3`-period window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HiLoActivator};
|
||||
///
|
||||
/// let mut indicator = HiLoActivator::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HiLoActivator {
|
||||
period: usize,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
sum_high: f64,
|
||||
sum_low: f64,
|
||||
/// Last bar's `(hi_sma, lo_sma)`, used so today's signal is based on
|
||||
/// yesterday's SMAs (no look-ahead).
|
||||
prev_smas: Option<(f64, f64)>,
|
||||
/// `true` while the current trail is on the long side.
|
||||
long: bool,
|
||||
/// `true` once a signal has been emitted at least once.
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl HiLoActivator {
|
||||
/// Construct a `HiLo` Activator with an explicit SMA window.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
sum_high: 0.0,
|
||||
sum_low: 0.0,
|
||||
prev_smas: None,
|
||||
long: true,
|
||||
started: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Crabel's classic configuration: a `3`-bar window.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(3).expect("classic period is valid")
|
||||
}
|
||||
|
||||
/// Configured SMA window.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HiLoActivator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.highs.len() == self.period {
|
||||
self.sum_high -= self.highs.pop_front().expect("non-empty by check");
|
||||
self.sum_low -= self.lows.pop_front().expect("non-empty by check");
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
self.sum_high += candle.high;
|
||||
self.sum_low += candle.low;
|
||||
|
||||
// Need today's SMA + yesterday's SMA to compare close vs the *previous*
|
||||
// bar's bands — so the very first ready bar only computes today's SMA
|
||||
// and stores it; emission begins on the next bar.
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let p = self.period as f64;
|
||||
let hi_sma = self.sum_high / p;
|
||||
let lo_sma = self.sum_low / p;
|
||||
|
||||
let out = if let Some((prev_hi, prev_lo)) = self.prev_smas {
|
||||
if candle.close > prev_hi {
|
||||
self.long = true;
|
||||
} else if candle.close < prev_lo {
|
||||
self.long = false;
|
||||
}
|
||||
self.started = true;
|
||||
if self.long {
|
||||
prev_lo
|
||||
} else {
|
||||
prev_hi
|
||||
}
|
||||
} else {
|
||||
// First SMA-ready bar seeds yesterday's bands for the next call.
|
||||
self.prev_smas = Some((hi_sma, lo_sma));
|
||||
return None;
|
||||
};
|
||||
self.prev_smas = Some((hi_sma, lo_sma));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
self.sum_high = 0.0;
|
||||
self.sum_low = 0.0;
|
||||
self.prev_smas = None;
|
||||
self.long = true;
|
||||
self.started = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.started
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HiLoActivator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(HiLoActivator::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = HiLoActivator::classic();
|
||||
assert_eq!(s.period(), 3);
|
||||
assert_eq!(s.warmup_period(), 4);
|
||||
assert_eq!(s.name(), "HiLoActivator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_none_until_period_plus_one() {
|
||||
let mut s = HiLoActivator::new(3).unwrap();
|
||||
// The first 3 candles fill the SMA; the 4th is the first emission.
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let out = s.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_none());
|
||||
assert!(out[3].is_some(), "first emission lands at index period");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_stays_long_on_lo_sma() {
|
||||
let mut s = HiLoActivator::new(3).unwrap();
|
||||
// Flat candles: H=11, L=9, C=10. Both SMAs are constant.
|
||||
let candles: Vec<Candle> = (0..10).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
for v in s.batch(&candles).into_iter().flatten() {
|
||||
// close (10) is not > 11 nor < 9, so the long seed persists -> lo_sma = 9.
|
||||
assert_relative_eq!(v, 9.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_emitting_low_sma_below_close() {
|
||||
let mut s = HiLoActivator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let paired: Vec<(f64, f64)> = s
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
assert!(
|
||||
paired.iter().all(|(stop, close)| stop < close),
|
||||
"uptrend stop should sit below the close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = HiLoActivator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
s.batch(&candles);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = HiLoActivator::classic();
|
||||
let mut b = HiLoActivator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Hurst Channel (Brian Millard / Hurst-cycle channel).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hurst Channel output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HurstChannelOutput {
|
||||
/// Upper channel: `middle + multiplier · (highest_high − lowest_low)`.
|
||||
pub upper: f64,
|
||||
/// Middle line: SMA of close over the period.
|
||||
pub middle: f64,
|
||||
/// Lower channel: `middle − multiplier · (highest_high − lowest_low)`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range.
|
||||
///
|
||||
/// ```text
|
||||
/// middle = SMA(close, period)
|
||||
/// range = max(high, period) − min(low, period)
|
||||
/// upper = middle + multiplier · range
|
||||
/// lower = middle − multiplier · range
|
||||
/// ```
|
||||
///
|
||||
/// The Hurst Channel sizes its envelope by the *realised* high-low range of
|
||||
/// the window — a simpler, range-based volatility proxy than Bollinger's
|
||||
/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel
|
||||
/// reduces to a centerline that hugs the midpoint of the Donchian envelope;
|
||||
/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and
|
||||
/// `multiplier = 0.5` for the "inner" channel.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, HurstChannel, Indicator};
|
||||
///
|
||||
/// let mut indicator = HurstChannel::new(10, 0.5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HurstChannel {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
sma: Sma,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl HurstChannel {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
|
||||
/// invalid inputs.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
sma: Sma::new(period)?,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured range multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HurstChannel {
|
||||
type Input = Candle;
|
||||
type Output = HurstChannelOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<HurstChannelOutput> {
|
||||
if self.highs.len() == self.period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
|
||||
let middle = self.sma.update(candle.close)?;
|
||||
let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let range = hi - lo;
|
||||
Some(HurstChannelOutput {
|
||||
upper: middle + self.multiplier * range,
|
||||
middle,
|
||||
lower: middle - self.multiplier * range,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HurstChannel"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
HurstChannel::new(10, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
HurstChannel::new(10, -0.5),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
HurstChannel::new(10, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HurstChannel::new(10, 0.5).unwrap();
|
||||
assert_eq!(h.period(), 10);
|
||||
assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12);
|
||||
assert_eq!(h.warmup_period(), 10);
|
||||
assert_eq!(h.name(), "HurstChannel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_bands() {
|
||||
let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut h = HurstChannel::new(5, 0.5).unwrap();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut h = HurstChannel::new(10, 0.5).unwrap();
|
||||
for o in h.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle);
|
||||
assert!(o.middle >= o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = HurstChannel::new(10, 0.5).unwrap();
|
||||
let mut b = HurstChannel::new(10, 0.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut h = HurstChannel::new(5, 0.5).unwrap();
|
||||
h.batch(&candles);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// Reference: five identical candles `(high=12, low=8, close=10)`:
|
||||
/// SMA(close, 5) = 10, range = 12 − 8 = 4, multiplier = 0.5
|
||||
/// upper = 10 + 0.5·4 = 12, lower = 10 − 0.5·4 = 8.
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
let candles: Vec<Candle> = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect();
|
||||
let mut h = HurstChannel::new(5, 0.5).unwrap();
|
||||
let out = h.batch(&candles);
|
||||
assert!(out[0].is_none() && out[3].is_none());
|
||||
let v = out[4].unwrap();
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Rolling Hurst Exponent via simplified R/S analysis.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hurst Exponent of the last `period` values, estimated by rescaled-range
|
||||
/// (R/S) analysis.
|
||||
///
|
||||
/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n,
|
||||
/// R(n)/S(n))` for several window lengths `n` and reports the slope of the
|
||||
/// least-squares fit. Wickra uses a streaming-friendly variant that
|
||||
/// partitions the trailing window into `chunks` of equal size,
|
||||
/// computes `(R/S)` for each chunk length, and fits a log-log line to the
|
||||
/// resulting points:
|
||||
///
|
||||
/// ```text
|
||||
/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}:
|
||||
/// mean_m = (1/m) · Σ x_i over the chunk
|
||||
/// dev_m_i = (Σ_{j ≤ i} (x_j − mean_m)) // cumulative deviation
|
||||
/// R_m = max(dev_m) − min(dev_m)
|
||||
/// S_m = population_stddev(chunk)
|
||||
/// pair = (log m, log(R_m / S_m))
|
||||
/// H = slope of OLS line through the (log m, log(R/S)) points
|
||||
/// ```
|
||||
///
|
||||
/// The interpretation is unchanged from the textbook:
|
||||
///
|
||||
/// - `H ≈ 0.5` → random walk; recent moves carry no information about
|
||||
/// future direction (the efficient-markets baseline).
|
||||
/// - `H > 0.5` → persistent / trending; up moves are likelier to be
|
||||
/// followed by more up moves.
|
||||
/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to
|
||||
/// reverse.
|
||||
///
|
||||
/// Use it as a regime filter: trend-following strategies prefer
|
||||
/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped
|
||||
/// to `[0, 1]` to absorb degenerate fits on very small windows.
|
||||
///
|
||||
/// `period` must be at least `2 · chunks` so every chunk has at least two
|
||||
/// points (otherwise its stddev is zero). A perfectly flat window has all
|
||||
/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to
|
||||
/// avoid divide-by-zero / log-zero failures.
|
||||
///
|
||||
/// Each `update` is O(period); the window is stored in a deque and the
|
||||
/// chunked R/S computation runs once per emission, not per input.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{HurstExponent, Indicator};
|
||||
///
|
||||
/// let mut indicator = HurstExponent::new(100, 4).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HurstExponent {
|
||||
period: usize,
|
||||
chunks: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl HurstExponent {
|
||||
/// Construct a new Hurst Exponent over a window of `period` inputs,
|
||||
/// fitted across `chunks` log-log points.
|
||||
///
|
||||
/// `chunks` controls the number of R/S pairs that go into the slope
|
||||
/// fit; the typical value is `4` (the original Hurst paper used 5 — 9
|
||||
/// points; smaller windows constrain the choice).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `chunks < 2` or
|
||||
/// `period < 2 · chunks`.
|
||||
pub fn new(period: usize, chunks: usize) -> Result<Self> {
|
||||
if chunks < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Hurst chunks must be >= 2",
|
||||
});
|
||||
}
|
||||
if period < 2 * chunks {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Hurst period must be >= 2 * chunks",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
chunks,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured chunk count.
|
||||
pub const fn chunks(&self) -> usize {
|
||||
self.chunks
|
||||
}
|
||||
}
|
||||
|
||||
/// R/S over a single chunk; returns `None` if the chunk has zero dispersion
|
||||
/// (its stddev is zero, so the ratio is undefined).
|
||||
fn rescaled_range(chunk: &[f64]) -> Option<f64> {
|
||||
let n = chunk.len() as f64;
|
||||
let mean = chunk.iter().sum::<f64>() / n;
|
||||
let mut cum = 0.0;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut sum_sq = 0.0;
|
||||
for &x in chunk {
|
||||
let d = x - mean;
|
||||
cum += d;
|
||||
if cum > hi {
|
||||
hi = cum;
|
||||
}
|
||||
if cum < lo {
|
||||
lo = cum;
|
||||
}
|
||||
sum_sq += d * d;
|
||||
}
|
||||
let r = hi - lo;
|
||||
let s = (sum_sq / n).sqrt();
|
||||
if s == 0.0 || r == 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(r / s)
|
||||
}
|
||||
|
||||
impl Indicator for HurstExponent {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Materialise the window contiguously so chunk slicing is trivial.
|
||||
let buf: Vec<f64> = self.window.iter().copied().collect();
|
||||
// Build (log m, log(R/S)) points. The chunk size sweeps from period
|
||||
// (one big chunk) down to period / chunks (chunks small chunks).
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut count = 0usize;
|
||||
for k in 1..=self.chunks {
|
||||
// k chunks each of size m; ignore the integer-division leftover
|
||||
// bars at the end of the window. The `period >= 2 * chunks`
|
||||
// constructor invariant guarantees m >= 2 for every k in range.
|
||||
let m = self.period / k;
|
||||
// Average R/S across the k chunks of size m to reduce noise.
|
||||
let mut acc = 0.0;
|
||||
let mut chunks_used = 0;
|
||||
for c in 0..k {
|
||||
let start = c * m;
|
||||
let end = start + m;
|
||||
if let Some(rs) = rescaled_range(&buf[start..end]) {
|
||||
acc += rs;
|
||||
chunks_used += 1;
|
||||
}
|
||||
}
|
||||
if chunks_used == 0 {
|
||||
continue;
|
||||
}
|
||||
let avg_rs = acc / f64::from(chunks_used);
|
||||
let x = (m as f64).ln();
|
||||
let y = avg_rs.ln();
|
||||
sum_x += x;
|
||||
sum_y += y;
|
||||
sum_xy += x * y;
|
||||
sum_xx += x * x;
|
||||
count += 1;
|
||||
}
|
||||
if count < 2 {
|
||||
// A perfectly flat window yields no usable R/S point; the
|
||||
// canonical fallback for R/S on white noise is H = 0.5.
|
||||
return Some(0.5);
|
||||
}
|
||||
// With chunks >= 2 and period >= 2 * chunks, m_1 = period and
|
||||
// m_2 = period / 2 are always distinct, so the variance of the
|
||||
// log-m values is strictly positive and `denom > 0`.
|
||||
let n = count as f64;
|
||||
let denom = n * sum_xx - sum_x * sum_x;
|
||||
let slope = (n * sum_xy - sum_x * sum_y) / denom;
|
||||
Some(slope.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HurstExponent"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_parameters() {
|
||||
assert!(HurstExponent::new(10, 0).is_err());
|
||||
assert!(HurstExponent::new(10, 1).is_err());
|
||||
assert!(HurstExponent::new(3, 2).is_err());
|
||||
assert!(HurstExponent::new(4, 2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HurstExponent::new(100, 4).unwrap();
|
||||
assert_eq!(h.period(), 100);
|
||||
assert_eq!(h.chunks(), 4);
|
||||
assert_eq!(h.warmup_period(), 100);
|
||||
assert_eq!(h.name(), "HurstExponent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_is_one_half() {
|
||||
let mut h = HurstExponent::new(40, 4).unwrap();
|
||||
for v in h.batch(&[42.0; 80]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_zero_one_range() {
|
||||
let prices: Vec<f64> = (0..400)
|
||||
.map(|i| {
|
||||
100.0
|
||||
+ (f64::from(i) * 0.05).sin() * 8.0
|
||||
+ (f64::from(i) * 0.21).cos() * 3.0
|
||||
+ f64::from(i) * 0.1
|
||||
})
|
||||
.collect();
|
||||
let mut h = HurstExponent::new(100, 4).unwrap();
|
||||
for v in h.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_series_above_half() {
|
||||
// A clean monotonic ramp is the textbook persistent series; the R/S
|
||||
// pairs must lie above the random-walk baseline.
|
||||
let prices: Vec<f64> = (0..200).map(f64::from).collect();
|
||||
let mut h = HurstExponent::new(100, 4).unwrap();
|
||||
let last = h.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.5,
|
||||
"trending series should have H > 0.5, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HurstExponent::new(20, 4).unwrap();
|
||||
for i in 0..20 {
|
||||
h.update(f64::from(i));
|
||||
}
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
|
||||
let mut b = HurstExponent::new(50, 4).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Ichimoku Kinko Hyo — the five-line cloud chart.
|
||||
//!
|
||||
//! The Ichimoku system bundles five distinct lines computed from highs, lows
|
||||
//! and closes:
|
||||
//!
|
||||
//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period`
|
||||
//! highs and lows (default 9).
|
||||
//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26).
|
||||
//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward*
|
||||
//! `displacement` bars.
|
||||
//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default
|
||||
//! 52), also shifted forward `displacement` bars.
|
||||
//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement`
|
||||
//! bars *backwards*.
|
||||
//!
|
||||
//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible
|
||||
//! Senkou A/B are computed from data at step *n − displacement*; the visible
|
||||
//! Chikou is the close from step *n + displacement* in a chart, but in a
|
||||
//! streaming setting the only Chikou we can emit at step *n* is the close from
|
||||
//! *n − displacement*. That convention matches every TA library that processes
|
||||
//! candles in chronological order.
|
||||
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// All five Ichimoku lines at one step.
|
||||
///
|
||||
/// `tenkan` and `kijun` reflect data up to and including the current bar.
|
||||
/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current
|
||||
/// bar*, computed from `displacement` bars ago. `chikou` is the close from
|
||||
/// `displacement` bars ago (its "lagging" placement on charts).
|
||||
///
|
||||
/// Any field that is not yet defined (insufficient history) is `None`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct IchimokuOutput {
|
||||
/// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows.
|
||||
pub tenkan: Option<f64>,
|
||||
/// Kijun-sen — midpoint of the last `kijun_period` highs/lows.
|
||||
pub kijun: Option<f64>,
|
||||
/// Senkou Span A as visible at the current bar (computed from
|
||||
/// `(tenkan + kijun) / 2` at step `n - displacement`).
|
||||
pub senkou_a: Option<f64>,
|
||||
/// Senkou Span B as visible at the current bar (computed from the
|
||||
/// `senkou_b_period` midpoint at step `n - displacement`).
|
||||
pub senkou_b: Option<f64>,
|
||||
/// Chikou Span — the close from `displacement` bars ago.
|
||||
pub chikou: Option<f64>,
|
||||
}
|
||||
|
||||
/// Ichimoku Kinko Hyo indicator.
|
||||
///
|
||||
/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output
|
||||
/// (every field `Some`) appears after `senkou_b_period + displacement - 1`
|
||||
/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar
|
||||
/// midpoint *and* a 26-bar history of those midpoints to displace from.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Ichimoku, Indicator};
|
||||
///
|
||||
/// let mut ichi = Ichimoku::classic();
|
||||
/// for i in 0..120 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap();
|
||||
/// ichi.update(candle);
|
||||
/// }
|
||||
/// let out = ichi.value().unwrap();
|
||||
/// assert!(out.tenkan.is_some() && out.kijun.is_some());
|
||||
/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some());
|
||||
/// assert!(out.chikou.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ichimoku {
|
||||
tenkan_period: usize,
|
||||
kijun_period: usize,
|
||||
senkou_b_period: usize,
|
||||
displacement: usize,
|
||||
// Rolling window of recent highs/lows for the longest lookback we need.
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
// Past (tenkan+kijun)/2 values used to emit the displaced Senkou A.
|
||||
senkou_a_history: VecDeque<f64>,
|
||||
// Past Senkou B midpoint values used to emit the displaced Senkou B.
|
||||
senkou_b_history: VecDeque<f64>,
|
||||
// Past closes for the lagging Chikou span.
|
||||
close_history: VecDeque<f64>,
|
||||
last: Option<IchimokuOutput>,
|
||||
}
|
||||
|
||||
impl Ichimoku {
|
||||
/// Construct an Ichimoku indicator with custom periods.
|
||||
///
|
||||
/// `tenkan_period` is the short midpoint window (default 9), `kijun_period`
|
||||
/// the medium (default 26), `senkou_b_period` the long (default 52), and
|
||||
/// `displacement` the forward/backward shift in bars (default 26).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`,
|
||||
/// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`]
|
||||
/// if the periods are not in strictly increasing order
|
||||
/// (`tenkan < kijun < senkou_b`).
|
||||
pub fn new(
|
||||
tenkan_period: usize,
|
||||
kijun_period: usize,
|
||||
senkou_b_period: usize,
|
||||
displacement: usize,
|
||||
) -> Result<Self> {
|
||||
if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if tenkan_period >= kijun_period || kijun_period >= senkou_b_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b",
|
||||
});
|
||||
}
|
||||
let cap = senkou_b_period;
|
||||
Ok(Self {
|
||||
tenkan_period,
|
||||
kijun_period,
|
||||
senkou_b_period,
|
||||
displacement,
|
||||
highs: VecDeque::with_capacity(cap),
|
||||
lows: VecDeque::with_capacity(cap),
|
||||
senkou_a_history: VecDeque::with_capacity(displacement),
|
||||
senkou_b_history: VecDeque::with_capacity(displacement),
|
||||
close_history: VecDeque::with_capacity(displacement),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Classical `(9, 26, 52, 26)` configuration.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid")
|
||||
}
|
||||
|
||||
/// Configured periods as `(tenkan, kijun, senkou_b, displacement)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize, usize) {
|
||||
(
|
||||
self.tenkan_period,
|
||||
self.kijun_period,
|
||||
self.senkou_b_period,
|
||||
self.displacement,
|
||||
)
|
||||
}
|
||||
|
||||
/// Most recent output if at least one bar has been consumed.
|
||||
pub const fn value(&self) -> Option<IchimokuOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n`
|
||||
/// (the caller checks).
|
||||
fn midpoint(&self, n: usize) -> f64 {
|
||||
let len = self.highs.len();
|
||||
let start = len - n;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut lo = f64::INFINITY;
|
||||
for i in start..len {
|
||||
hi = hi.max(self.highs[i]);
|
||||
lo = lo.min(self.lows[i]);
|
||||
}
|
||||
f64::midpoint(hi, lo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Ichimoku {
|
||||
type Input = Candle;
|
||||
type Output = IchimokuOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<IchimokuOutput> {
|
||||
// Ring-buffer the new bar; cap at the longest lookback.
|
||||
if self.highs.len() == self.senkou_b_period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
|
||||
let tenkan =
|
||||
(self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period));
|
||||
let kijun =
|
||||
(self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period));
|
||||
let senkou_b_now =
|
||||
(self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period));
|
||||
|
||||
// Today's contribution to the leading spans (will become visible after
|
||||
// `displacement` more bars).
|
||||
let senkou_a_now = match (tenkan, kijun) {
|
||||
(Some(t), Some(k)) => Some(f64::midpoint(t, k)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// The currently-visible Senkou A/B at this bar are the values that were
|
||||
// computed `displacement` bars ago. We always push the freshly-computed
|
||||
// `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with
|
||||
// bars; NaN encodes "no value yet" so the buffer indices stay simple.
|
||||
let push_or_nan = |q: &mut VecDeque<f64>, v: Option<f64>, cap: usize| {
|
||||
if q.len() == cap {
|
||||
q.pop_front();
|
||||
}
|
||||
q.push_back(v.unwrap_or(f64::NAN));
|
||||
};
|
||||
push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement);
|
||||
push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement);
|
||||
|
||||
// The visible Senkou A/B at the current bar were buffered exactly
|
||||
// `displacement` updates ago, which is `self.senkou_*_history.front()`
|
||||
// once the buffer is full.
|
||||
let take_front = |q: &VecDeque<f64>, cap: usize| -> Option<f64> {
|
||||
if q.len() == cap {
|
||||
let v = q[0];
|
||||
if v.is_nan() {
|
||||
None
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let senkou_a = take_front(&self.senkou_a_history, self.displacement);
|
||||
let senkou_b = take_front(&self.senkou_b_history, self.displacement);
|
||||
|
||||
// Chikou: close from `displacement` bars ago.
|
||||
if self.close_history.len() == self.displacement {
|
||||
self.close_history.pop_front();
|
||||
}
|
||||
self.close_history.push_back(candle.close);
|
||||
let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]);
|
||||
|
||||
let out = IchimokuOutput {
|
||||
tenkan,
|
||||
kijun,
|
||||
senkou_a,
|
||||
senkou_b,
|
||||
chikou,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
self.senkou_a_history.clear();
|
||||
self.senkou_b_history.clear();
|
||||
self.close_history.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// First fully-populated row needs senkou_b's midpoint to have travelled
|
||||
// `displacement` bars forward.
|
||||
self.senkou_b_period + self.displacement - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some_and(|o| {
|
||||
o.tenkan.is_some()
|
||||
&& o.kijun.is_some()
|
||||
&& o.senkou_a.is_some()
|
||||
&& o.senkou_b.is_some()
|
||||
&& o.chikou.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Ichimoku"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 0.0, i).unwrap()
|
||||
}
|
||||
|
||||
fn ramp(n: i64) -> Vec<Candle> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let p = 100.0 + f64::from(i32::try_from(i).unwrap());
|
||||
c(p + 2.0, p - 2.0, p + 1.0, i)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(matches!(
|
||||
Ichimoku::new(0, 26, 52, 26),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
Ichimoku::new(9, 0, 52, 26),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
Ichimoku::new(9, 26, 0, 26),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
Ichimoku::new(9, 26, 52, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_increasing_periods() {
|
||||
assert!(matches!(
|
||||
Ichimoku::new(26, 26, 52, 26),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Ichimoku::new(9, 52, 52, 26),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Ichimoku::new(52, 26, 9, 26),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ichi = Ichimoku::classic();
|
||||
assert_eq!(ichi.periods(), (9, 26, 52, 26));
|
||||
assert_eq!(ichi.warmup_period(), 77);
|
||||
assert_eq!(ichi.name(), "Ichimoku");
|
||||
assert!(ichi.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenkan_emits_at_period() {
|
||||
let mut ichi = Ichimoku::classic();
|
||||
let candles = ramp(10);
|
||||
let out = ichi.batch(&candles);
|
||||
// The 9th update is the first time tenkan has 9 highs/lows.
|
||||
for (i, o) in out.iter().enumerate() {
|
||||
let v = o.unwrap();
|
||||
if i < 8 {
|
||||
assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars");
|
||||
} else {
|
||||
assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_populated_after_warmup() {
|
||||
let mut ichi = Ichimoku::classic();
|
||||
let candles = ramp(120);
|
||||
let out = ichi.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(last.tenkan.is_some());
|
||||
assert!(last.kijun.is_some());
|
||||
assert!(last.senkou_a.is_some());
|
||||
assert!(last.senkou_b.is_some());
|
||||
assert!(last.chikou.is_some());
|
||||
assert!(ichi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_tenkan_equals_window_midpoint() {
|
||||
// On a strict ramp the midpoint of the last 9 (high, low) candles is
|
||||
// the midpoint of the first and last bar in that window.
|
||||
let mut ichi = Ichimoku::classic();
|
||||
let candles = ramp(20);
|
||||
let out = ichi.batch(&candles);
|
||||
// At index 8 (9th bar), the window is bars 0..=8 with highs 102..110
|
||||
// and lows 98..106. Midpoint = (110 + 98) / 2 = 104.
|
||||
let v = out[8].unwrap();
|
||||
assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chikou_is_close_displacement_bars_back() {
|
||||
let mut ichi = Ichimoku::classic();
|
||||
let candles = ramp(60);
|
||||
let out = ichi.batch(&candles);
|
||||
// Displacement = 26; at bar index 25, chikou is the close from bar 0.
|
||||
let v = out[25].unwrap();
|
||||
assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12);
|
||||
let v = out[50].unwrap();
|
||||
assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = ramp(120);
|
||||
let mut a = Ichimoku::classic();
|
||||
let mut b = Ichimoku::classic();
|
||||
let batched = a.batch(&candles);
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batched.len(), streamed.len());
|
||||
for (lhs, rhs) in batched.iter().zip(streamed.iter()) {
|
||||
let (l, r) = (lhs.unwrap(), rhs.unwrap());
|
||||
assert_eq!(l.tenkan, r.tenkan);
|
||||
assert_eq!(l.kijun, r.kijun);
|
||||
assert_eq!(l.senkou_a, r.senkou_a);
|
||||
assert_eq!(l.senkou_b, r.senkou_b);
|
||||
assert_eq!(l.chikou, r.chikou);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ichi = Ichimoku::classic();
|
||||
ichi.batch(&ramp(100));
|
||||
assert!(ichi.is_ready());
|
||||
ichi.reset();
|
||||
assert!(!ichi.is_ready());
|
||||
assert!(ichi.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_periods_accepted() {
|
||||
let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap();
|
||||
let out = ichi.batch(&ramp(40));
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(last.tenkan.is_some());
|
||||
assert!(last.senkou_a.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Inertia (Donald Dorsey).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::linreg::LinearRegression;
|
||||
use crate::indicators::rvi::Rvi;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor
|
||||
/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is
|
||||
/// taken as the indicator's reading, smoothing the underlying ratio while
|
||||
/// preserving its trend direction.
|
||||
///
|
||||
/// ```text
|
||||
/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t
|
||||
/// ```
|
||||
///
|
||||
/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Inertia};
|
||||
///
|
||||
/// let mut inertia = Inertia::new(14, 20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let o = 100.0 + f64::from(i);
|
||||
/// let c = o + 0.5;
|
||||
/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap();
|
||||
/// last = inertia.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Inertia {
|
||||
rvi_period: usize,
|
||||
linreg_period: usize,
|
||||
rvi: Rvi,
|
||||
linreg: LinearRegression,
|
||||
}
|
||||
|
||||
impl Inertia {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either period is zero.
|
||||
pub fn new(rvi_period: usize, linreg_period: usize) -> Result<Self> {
|
||||
if rvi_period == 0 || linreg_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rvi_period,
|
||||
linreg_period,
|
||||
rvi: Rvi::new(rvi_period)?,
|
||||
linreg: LinearRegression::new(linreg_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 20).expect("classic Inertia parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(rvi_period, linreg_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.rvi_period, self.linreg_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Inertia {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let rvi = self.rvi.update(candle)?;
|
||||
self.linreg.update(rvi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rvi.reset();
|
||||
self.linreg.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RVI emits at `rvi_period` candles; the LinearRegression then needs
|
||||
// `linreg_period − 1` more RVI values to fill its window.
|
||||
self.rvi_period + self.linreg_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.linreg.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Inertia"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let inertia = Inertia::classic();
|
||||
assert_eq!(inertia.periods(), (14, 20));
|
||||
assert_eq!(inertia.warmup_period(), 33);
|
||||
assert_eq!(inertia.name(), "Inertia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(Inertia::classic().periods(), (14, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
// Smaller periods for a fast test: RVI(3) emits at 3 candles, then
|
||||
// LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6.
|
||||
let mut inertia = Inertia::new(3, 4).unwrap();
|
||||
assert_eq!(inertia.warmup_period(), 6);
|
||||
for i in 0..5 {
|
||||
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None);
|
||||
}
|
||||
assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_rvi_yields_constant_inertia() {
|
||||
// Every bar identical -> RVI is constant -> LinReg of a constant
|
||||
// series equals that constant after warmup.
|
||||
let mut inertia = Inertia::new(3, 4).unwrap();
|
||||
let mut last = None;
|
||||
for i in 0..40 {
|
||||
last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
// RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar.
|
||||
let v = last.unwrap();
|
||||
assert_relative_eq!(v, 0.25, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let o = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||
let c = o + (i as f64 * 0.1).cos();
|
||||
candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Inertia::classic().batch(&candles);
|
||||
let mut b = Inertia::classic();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut inertia = Inertia::classic();
|
||||
for i in 0..50 {
|
||||
inertia.update(candle(10.0, 11.0, 9.0, 10.5, i));
|
||||
}
|
||||
assert!(inertia.is_ready());
|
||||
inertia.reset();
|
||||
assert!(!inertia.is_ready());
|
||||
assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user