Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a252ea66 | |||
| 11c52b8e86 | |||
| c6d2030103 | |||
| a10bda188f | |||
| c4ffcc2138 | |||
| f3ad0940c3 | |||
| a32719d709 | |||
| 6fd110b4ce | |||
| 896b71fc62 | |||
| 62fcab1c86 | |||
| 6786917cee | |||
| 961fc1f3dc | |||
| 183ebec7ba | |||
| 510013fc5a | |||
| 2db546ac12 | |||
| b340ecd3d6 | |||
| 2aef8c8db5 | |||
| b003321562 | |||
| 0995f8d66a | |||
| a530f1b4cb | |||
| efcd6216c1 | |||
| 3a6b5ebae3 | |||
| c99cf54a1f | |||
| 2bc6cc5505 | |||
| 6e3190a44a | |||
| 04c3a83fdb | |||
| c87a16953b | |||
| 5ea36a6064 | |||
| 303ff0a163 | |||
| b948b0b9cf | |||
| a707eb5d62 | |||
| 8b4a847d24 | |||
| 43b0b26736 | |||
| 962ced0712 | |||
| 5a4cf66022 | |||
| d87005577e | |||
| 8b9e8e30b9 | |||
| 747d1a5b1b | |||
| a1c646ae7c | |||
| ba10898801 | |||
| 25454fa89a | |||
| 2eabda5fa3 | |||
| c6938e8473 | |||
| 91f24946a6 | |||
| d5ff0a9df6 | |||
| 2b3a1b7384 | |||
| d2f99efd78 | |||
| 6643f7a81d | |||
| e452d35a27 | |||
| 27f37f5347 | |||
| 2f3b5cc3be | |||
| 2d0ee926c5 | |||
| 21bbd521b3 | |||
| 0b11a523a0 | |||
| 81962485af | |||
| 99dd144576 | |||
| 6c58d3827c | |||
| 16c0639f0c | |||
| 54148cad5b | |||
| e24e7726ce | |||
| 7728151c87 | |||
| 780a176072 | |||
| ed7324115c | |||
| abd2d80f8d | |||
| 3e8c48eefc | |||
| a4d8c40dc2 | |||
| 78c31d1bed | |||
| 4b3227a15f | |||
| 5568596e18 | |||
| 5917d4928f | |||
| 9d822d26aa | |||
| 0d20973a4d | |||
| f628bd5fb7 | |||
| 87b3f383d6 | |||
| 71e46a1ea6 | |||
| 51f64b53c0 | |||
| b919c33dee | |||
| a79606b4ce | |||
| 9fd926ecd8 | |||
| b3ddbea584 | |||
| 278b6afaa4 | |||
| 94cab88278 | |||
| 53b8b6e282 | |||
| 9b11d73273 | |||
| 0d451ac584 | |||
| 8ccb885906 | |||
| ad17915e49 | |||
| 79d705a746 | |||
| a8fb0b8181 | |||
| f0471ba824 | |||
| 2cebb3cca1 | |||
| f33f59ad68 | |||
| 783e40069d | |||
| 6b468824ce | |||
| 81680bbb4b | |||
| 80295eec87 | |||
| 0910ee6d37 | |||
| 62d0fb623e | |||
| 23e5890265 | |||
| 90aa695a02 | |||
| 2fc7a90ec0 | |||
| 490ce28645 | |||
| 59c435fbd5 | |||
| 41d52ec5be | |||
| aeea6a89bb | |||
| 4a9d27fb52 | |||
| 8192e576cf | |||
| 4880fea075 | |||
| ad3b068d47 | |||
| b4613a74c8 | |||
| a0884fa010 | |||
| 4b6f25a32c | |||
| 014e9afa51 | |||
| 8aa480101e | |||
| 5627612d44 | |||
| f3dcee1cb5 | |||
| bdc4c744f2 | |||
| 528e5c9174 | |||
| 323e9ce153 |
@@ -0,0 +1,6 @@
|
||||
# Code owners for Wickra.
|
||||
#
|
||||
# The owner listed here is requested for review automatically on every pull
|
||||
# request. See https://docs.github.com/articles/about-code-owners.
|
||||
|
||||
* @kingchenc
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report incorrect behaviour in Wickra
|
||||
title: "[bug] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear description of what is wrong. -->
|
||||
|
||||
## Reproduction
|
||||
|
||||
<!-- Minimal code that reproduces the problem. -->
|
||||
|
||||
```rust
|
||||
// or python / javascript
|
||||
```
|
||||
|
||||
## Expected behaviour
|
||||
|
||||
<!-- What you expected to happen, ideally with a reference value
|
||||
(TA-Lib, pandas-ta, hand-computed). -->
|
||||
|
||||
## Actual behaviour
|
||||
|
||||
<!-- What happened instead. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- Wickra version:
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||
- OS and architecture:
|
||||
- Rust / Python / Node version (if relevant):
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Logs, screenshots, anything else. -->
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/kingchenc/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
|
||||
about: Ask usage questions and discuss ideas here.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new indicator or capability for Wickra
|
||||
title: "[feature] "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What are you trying to do that Wickra cannot do today? -->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- For a new indicator: its name, formula, and the standard parameters.
|
||||
Link a reference implementation (TA-Lib, pandas-ta) if one exists. -->
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!-- Other approaches and why they fall short. -->
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] Affects the Rust core
|
||||
- [ ] Should be exposed in the Python binding
|
||||
- [ ] Should be exposed in the Node binding
|
||||
- [ ] Should be exposed in the WASM binding
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Anything else that helps. -->
|
||||
@@ -0,0 +1,33 @@
|
||||
<!-- Thanks for contributing to Wickra. Please fill in the sections below. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change, and why? -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- e.g. Closes #123 -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Indicator addition / change
|
||||
- [ ] Documentation
|
||||
- [ ] CI / build / tooling
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `cargo fmt --all --check` is clean.
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean.
|
||||
- [ ] `cargo test --workspace` passes.
|
||||
- [ ] 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).
|
||||
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
|
||||
(if applicable).
|
||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything that needs extra attention, trade-offs, follow-ups. -->
|
||||
@@ -0,0 +1,38 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Rust workspace (root Cargo.toml + all member crates).
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(cargo)"
|
||||
|
||||
# Node binding npm dependencies.
|
||||
- package-ecosystem: npm
|
||||
directory: "/bindings/node"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(npm)"
|
||||
|
||||
# Python binding pip dependencies.
|
||||
- package-ecosystem: pip
|
||||
directory: "/bindings/python"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(pip)"
|
||||
|
||||
# GitHub Actions — keeps the SHA-pinned actions current (Dependabot reads
|
||||
# the version comment after each pinned SHA and bumps both together).
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Cross-library benchmark
|
||||
|
||||
# Audit finding R10: previously the cross-library benchmark ran on every push
|
||||
# and every pull-request to `main`, adding 5–10 minutes of build + bench time
|
||||
# per CI run with no consumer of the resulting artefact. The benchmark is now
|
||||
# scheduled (nightly at 03:00 UTC) and on-demand via `workflow_dispatch`. The
|
||||
# CI pipeline proper (.github/workflows/ci.yml) still verifies build / tests /
|
||||
# lints on every push and pull-request.
|
||||
on:
|
||||
schedule:
|
||||
# Nightly at 03:00 UTC. Pick a slot well away from common European /
|
||||
# American working-hours pushes to keep this off the critical path.
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
size:
|
||||
description: "Number of bars per indicator (default 20000)"
|
||||
required: false
|
||||
default: "20000"
|
||||
iterations:
|
||||
description: "Batch iterations per indicator (default 10)"
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries \
|
||||
--size ${{ github.event.inputs.size || '20000' }} \
|
||||
--iterations ${{ github.event.inputs.iterations || '10' }} \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
+149
-57
@@ -19,15 +19,15 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -52,6 +52,132 @@ jobs:
|
||||
cargo build -p wickra --example backtest
|
||||
cargo build -p wickra-data --example live_binance --features live-binance
|
||||
|
||||
# Verify the crates still build and test on their declared minimum supported
|
||||
# Rust version. The workspace pins rust-version = "1.75"; bindings/node needs
|
||||
# 1.77 because napi-build emits `cargo::` directives. Without this job an
|
||||
# accidental use of a newer API would only surface for downstream users.
|
||||
msrv:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: MSRV workspace (Rust 1.75)
|
||||
toolchain: "1.75"
|
||||
packages: "-p wickra-core -p wickra -p wickra-data"
|
||||
- name: MSRV node binding (Rust 1.77)
|
||||
toolchain: "1.77"
|
||||
packages: "-p wickra-node"
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust ${{ matrix.toolchain }}
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
|
||||
- name: Test on MSRV
|
||||
run: cargo test ${{ matrix.packages }} --verbose
|
||||
|
||||
# Code coverage for the pure-Rust crates, uploaded to Codecov.
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@e0eafa9a0d485c37f97c0f7beb930a58a2facbac # v2.79.4
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
- name: Generate coverage (lcov)
|
||||
run: >
|
||||
cargo llvm-cov
|
||||
-p wickra-core -p wickra -p wickra-data
|
||||
--features wickra-data/live-binance
|
||||
--lcov --output-path lcov.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Supply-chain audit: security advisories, license policy, banned crates,
|
||||
# and source restrictions. Configured by deny.toml at the repo root.
|
||||
supply-chain:
|
||||
name: Supply-chain (cargo-deny)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823 # v2.0.19
|
||||
with:
|
||||
command: check
|
||||
|
||||
# Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic
|
||||
# fails the job. The goal is to catch a regression in the harness (e.g. a
|
||||
# newly added indicator that panics on a particular input shape), not to
|
||||
# discover novel bugs — long fuzz campaigns should be run on dedicated
|
||||
# infrastructure with persistent corpora.
|
||||
fuzz-smoke:
|
||||
name: Fuzz (smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install nightly Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
toolchain: nightly
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: fuzz
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
|
||||
- name: Fuzz csv_reader (30 s)
|
||||
run: cargo +nightly fuzz run csv_reader -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz binance_envelope (30 s)
|
||||
run: cargo +nightly fuzz run binance_envelope -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz indicator_update (30 s)
|
||||
run: cargo +nightly fuzz run indicator_update -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz indicator_update_candle (30 s)
|
||||
run: cargo +nightly fuzz run indicator_update_candle -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz tick_aggregator (30 s)
|
||||
run: cargo +nightly fuzz run tick_aggregator -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
python:
|
||||
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -59,18 +185,18 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.9", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -96,22 +222,25 @@ jobs:
|
||||
name: WASM build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust toolchain (with wasm target)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install wasm-pack
|
||||
uses: jetli/wasm-pack-action@v0.4.0
|
||||
uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
|
||||
|
||||
- name: Build WASM package
|
||||
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
- name: Run WASM tests
|
||||
run: wasm-pack test --node bindings/wasm
|
||||
|
||||
- name: Verify generated artefacts
|
||||
run: |
|
||||
test -f bindings/wasm/pkg/wickra_wasm.js
|
||||
@@ -127,16 +256,16 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ["18", "20"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -156,44 +285,7 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries --size 20000 --iterations 10 \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
# the every-push / every-PR critical path.
|
||||
|
||||
+205
-35
@@ -15,10 +15,17 @@ jobs:
|
||||
cargo-publish:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
# The publish jobs run with long-lived registry tokens. Binding them to a
|
||||
# protected GitHub environment lets the org require a reviewer to approve
|
||||
# each release and restrict which tags/branches may deploy, so the secrets
|
||||
# are not reachable from an arbitrary workflow run. The `release`
|
||||
# environment and its protection rules are configured under repo
|
||||
# Settings -> Environments.
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# Idempotent publishing: if the version is already on crates.io we
|
||||
# treat that as success so re-runs of the workflow don't fail.
|
||||
@@ -52,48 +59,77 @@ jobs:
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# Produce .crate files for the GitHub Release attachments. `cargo package`
|
||||
# writes them to target/package/<name>-<version>.crate.
|
||||
#
|
||||
# No --allow-dirty: `actions/checkout` gives a clean tree and nothing
|
||||
# above mutates it. No --no-verify: every crate was just published to
|
||||
# crates.io in the steps above, so the verification build resolves its
|
||||
# workspace dependencies from the registry and confirms each .crate
|
||||
# actually builds before it is attached to the release.
|
||||
- name: Build .crate files for release attachment
|
||||
run: |
|
||||
cargo package -p wickra-core
|
||||
cargo package -p wickra-data
|
||||
cargo package -p wickra
|
||||
|
||||
- name: Upload .crate files
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: crate-files
|
||||
path: target/package/*.crate
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PyPI: cross-platform wheels + sdist
|
||||
# --------------------------------------------------------------------------
|
||||
python-wheels:
|
||||
name: Build wheels (${{ matrix.target }} on ${{ matrix.os }})
|
||||
name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# glibc Linux (manylinux)
|
||||
- { os: ubuntu-latest, target: x86_64, manylinux: auto }
|
||||
- { os: ubuntu-latest, target: aarch64, manylinux: auto }
|
||||
# musl Linux (Alpine and other musl distros)
|
||||
- { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
|
||||
- { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
|
||||
# macOS
|
||||
- { os: macos-latest, target: x86_64, manylinux: auto }
|
||||
- { os: macos-latest, target: aarch64, manylinux: auto }
|
||||
# Windows
|
||||
- { os: windows-latest, target: x64, manylinux: auto }
|
||||
- { os: windows-11-arm, target: aarch64, manylinux: auto }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: PyO3/maturin-action@v1
|
||||
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
working-directory: bindings/python
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --strip --out dist
|
||||
manylinux: ${{ matrix.manylinux }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.target }}
|
||||
# Include manylinux in the name so the glibc and musl x86_64/aarch64
|
||||
# builds do not collide on the same artifact name.
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
|
||||
path: bindings/python/dist/*
|
||||
|
||||
python-sdist:
|
||||
name: Build Python sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: PyO3/maturin-action@v1
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
working-directory: bindings/python
|
||||
command: sdist
|
||||
args: --out dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: wheels-sdist
|
||||
path: bindings/python/dist/*
|
||||
@@ -102,15 +138,16 @@ jobs:
|
||||
name: Publish to PyPI
|
||||
needs: [python-wheels, python-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
path: dist
|
||||
pattern: wheels-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload to PyPI
|
||||
uses: PyO3/maturin-action@v1
|
||||
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
command: upload
|
||||
args: --skip-existing dist/*
|
||||
@@ -127,22 +164,24 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
@@ -153,7 +192,7 @@ jobs:
|
||||
run: npx napi build --platform --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: bindings-${{ matrix.target }}
|
||||
path: bindings/node/wickra.*.node
|
||||
@@ -163,10 +202,11 @@ jobs:
|
||||
name: Publish to npm
|
||||
needs: node-build
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -176,7 +216,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: Download all platform binaries
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
path: bindings/node/artifacts
|
||||
pattern: bindings-*
|
||||
@@ -188,10 +228,16 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: npx napi artifacts --dir artifacts
|
||||
|
||||
# Publish each platform package individually so one failure doesn't kill
|
||||
# the others. Skip versions that are already on npm. Tolerate spam-filter
|
||||
# 403s with a one-time retry after a short delay (spam detection is
|
||||
# often rate-limit-based and clears on the next request).
|
||||
# Publish each platform package individually. Skip versions that are
|
||||
# already on npm. A first-attempt 403 from npm's spam filter is
|
||||
# tolerated for a single 30-second retry — that historically clears
|
||||
# rate-limit-driven false positives. Anything that still fails after
|
||||
# the retry is a *real* failure (the platform binary will be missing
|
||||
# from `optionalDependencies` and Windows-style installs will break,
|
||||
# exactly the regression that produced audit finding R20) — fail the
|
||||
# job loudly so the release does not silently land in a half-published
|
||||
# state. Previously this loop swallowed the second-attempt failure with
|
||||
# a `::warning::` and `return 0`; that mask is removed.
|
||||
- name: Publish platform packages (idempotent)
|
||||
working-directory: bindings/node
|
||||
env:
|
||||
@@ -199,6 +245,7 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
fail=0
|
||||
publish_dir() {
|
||||
local dir=$1
|
||||
local pkg=$(basename "$dir")
|
||||
@@ -210,23 +257,29 @@ jobs:
|
||||
return 0
|
||||
fi
|
||||
echo "::group::publish $pkgname@$version"
|
||||
(cd "$dir" && npm publish --access public)
|
||||
# --ignore-scripts: a per-platform package must never run lifecycle
|
||||
# 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)
|
||||
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)
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts)
|
||||
rc=$?
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::$pkgname could not be published; the main package will skip the missing optional dep"
|
||||
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
for dir in npm/*/; do
|
||||
publish_dir "$dir"
|
||||
publish_dir "$dir" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Publish main package to npm (idempotent)
|
||||
working-directory: bindings/node
|
||||
@@ -257,25 +310,46 @@ jobs:
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
- name: Pack node tarballs for release attachment
|
||||
working-directory: bindings/node
|
||||
run: |
|
||||
# Main package
|
||||
npm pack --ignore-scripts
|
||||
# Each per-platform package (the binaries were already moved in by
|
||||
# napi artifacts). --ignore-scripts for the same reason as publish:
|
||||
# packing must not execute lifecycle scripts from the packed dir.
|
||||
for d in npm/*/; do
|
||||
(cd "$d" && npm pack --ignore-scripts)
|
||||
done
|
||||
|
||||
- name: Upload Node tarballs
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: node-tarballs
|
||||
path: |
|
||||
bindings/node/*.tgz
|
||||
bindings/node/npm/*/*.tgz
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
|
||||
# --------------------------------------------------------------------------
|
||||
wasm-publish:
|
||||
name: Publish wickra-wasm to npm
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: jetli/wasm-pack-action@v0.4.0
|
||||
- uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
|
||||
|
||||
- name: Build WASM package (bundler target)
|
||||
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
|
||||
@@ -290,10 +364,20 @@ jobs:
|
||||
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.license = 'SEE LICENSE IN LICENSE';
|
||||
pkg.license = 'PolyForm-Noncommercial-1.0.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
- name: Pack wickra-wasm for release attachment
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: npm pack
|
||||
|
||||
- name: Upload WASM tarball
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: wasm-tarball
|
||||
path: bindings/wasm/pkg/wickra-wasm-*.tgz
|
||||
|
||||
- name: Publish wickra-wasm to npm (idempotent)
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: |
|
||||
@@ -302,3 +386,89 @@ jobs:
|
||||
|| (echo "$out"; exit 1))
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GitHub Release: attach every built artefact to the tag's release page.
|
||||
# --------------------------------------------------------------------------
|
||||
github-release:
|
||||
name: Attach assets to the GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve target tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
else
|
||||
# workflow_dispatch / non-tag push: attach to the latest v* tag.
|
||||
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::no v* tag found to attach assets to"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::attaching assets to release $tag"
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Stage release assets
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p release-assets
|
||||
# Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
|
||||
find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
|
||||
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
|
||||
# Native Node binaries (one per platform).
|
||||
find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
|
||||
# Node npm-pack tarballs (main + per-platform).
|
||||
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/ \;
|
||||
ls -lh release-assets/
|
||||
echo "asset-count=$(ls release-assets/ | wc -l)"
|
||||
|
||||
- name: Create / update GitHub Release with assets
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: Wickra ${{ steps.tag.outputs.tag }}
|
||||
files: release-assets/*
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
body: |
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cargo add wickra
|
||||
pip install wickra
|
||||
npm install wickra
|
||||
npm install wickra-wasm
|
||||
```
|
||||
|
||||
### Attached assets
|
||||
|
||||
Pre-built artefacts for every supported platform — the same files that
|
||||
were uploaded to crates.io, PyPI, and npm by this workflow run.
|
||||
|
||||
- `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
|
||||
- `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
|
||||
darwin-arm64, win32-x64-msvc)
|
||||
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
|
||||
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
+1
-1
@@ -42,7 +42,7 @@ tarpaulin-report.html
|
||||
*.local.toml
|
||||
|
||||
# Node binding artifacts
|
||||
bindings/node/node_modules/
|
||||
**/node_modules/
|
||||
bindings/node/*.node
|
||||
bindings/node/index.d.ts
|
||||
bindings/node/npm-debug.log*
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Wickra are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- `HistoricalVolatility::update` no longer substitutes a `0.0` log-return on
|
||||
non-positive prices (audit finding R13). Negative or zero prices are
|
||||
semantically invalid for a log-return calculation; silently treating them as
|
||||
"no movement" underreported realised volatility. They are now skipped — the
|
||||
previous valid value is returned and the indicator's state (`prev_price`,
|
||||
window, sums) is left untouched — matching how every other indicator handles
|
||||
invalid inputs.
|
||||
- `Tick::new` now returns the new `Error::InvalidTick` variant for negative
|
||||
volume instead of `Error::InvalidCandle` (audit finding R14). A tick is not
|
||||
a candle, and downstream tick-stream pipelines should be able to match on a
|
||||
semantically-correct error. The Python binding's `map_err` was extended to
|
||||
forward the new variant as a `ValueError`; the Node and WASM bindings format
|
||||
via `Error::to_string()` and pick the new variant up automatically.
|
||||
- `Psar::is_ready` now matches the convention shared by every other indicator:
|
||||
`is_ready() == true` iff a real value has been produced (audit finding R6).
|
||||
The previous implementation returned `self.initialised`, which flipped to
|
||||
`true` after the seed candle even though the seed candle itself returns
|
||||
`None`. A streaming consumer that wrote
|
||||
`if ind.is_ready() { use(ind.update(c)?) }` would hit an unexpected `None`
|
||||
on the first post-seed update. The fix introduces a `has_emitted` gate set
|
||||
when the first `Some` value is returned.
|
||||
- `Psar::reset` now restores the compute fields (`prev_high`, `prev_low`,
|
||||
`sar`, `ep`) to `f64::NAN` sentinels instead of `0.0` (audit Opus-Bonus 1).
|
||||
The fields are gated by `initialised` today, so the `0.0` sentinel never
|
||||
leaked into output — but a future refactor that read them pre-init would
|
||||
have silently treated `0.0` as a real price. A `debug_assert!` at the read
|
||||
site makes the invariant explicit.
|
||||
|
||||
### Changed
|
||||
- `Sma` and `BollingerBands` now reseed their incremental `sum` (and `sum_sq`
|
||||
for Bollinger) from the live window every `16 · period` finite updates,
|
||||
capping floating-point drift on long-running streams (audit findings R7 and
|
||||
L2-Rust). Previously the incremental single-subtract `sum -= old` could
|
||||
accumulate catastrophic-cancellation error on streams with alternating
|
||||
large/small magnitudes; the misleading `sma.rs` comment that claimed the
|
||||
drift was already bounded "by recomputing the sum after each pop" is
|
||||
replaced with an accurate description of the new reseed strategy. Amortised
|
||||
cost stays at O(1) (`O(period)` work amortised over `O(period)` updates),
|
||||
values are bit-identical on inputs that did not drift to begin with, and
|
||||
two new `long_stream_drift_stays_bounded` tests stress the recompute by
|
||||
alternating `1e9` / `1.0` (SMA) and `1e6` / `1.0` (Bollinger) for several
|
||||
recompute cycles and verify the reported values track a fresh from-scratch
|
||||
computation over the live window.
|
||||
- `LinearRegression`, `LinRegSlope` and `LinRegAngle` (via composition over
|
||||
`LinRegSlope`) now run their rolling ordinary-least-squares fit
|
||||
**incrementally** in O(1) per update (audit finding R2). Previously every
|
||||
tick refit the line from scratch in O(period). The OLS denominators (`Σx`
|
||||
and `Σxx`) depend only on `period`, so they were already precomputed; this
|
||||
release adds running `Σy` and `Σxy` accumulators and slides them in closed
|
||||
form via the identity
|
||||
`new_Σxy = old_Σxy − old_Σy + popped_y₀` (then `Σxy += (n − 1) · new_value`
|
||||
and `Σy += new_value`). New per-bar equivalence tests compare the O(1)
|
||||
output against a fresh O(n) refit on noisy ramps, step functions, and
|
||||
constants — values agree to within 1e-9.
|
||||
- Fuzz suite expanded from 2 indicators to the full catalogue (audit finding
|
||||
R9). The existing `indicator_update` target now exercises every scalar-input
|
||||
indicator (~33 classes including MACD and Bollinger Bands); a new
|
||||
`indicator_update_candle` target exercises every candle-input indicator (~37
|
||||
classes, including ATR, ADX, Stochastic, PSAR, Keltner, SuperTrend,
|
||||
ChandelierExit, AwesomeOscillator, OBV, MFI, VWAP, RollingVWAP, and the rest
|
||||
of the volume / volatility / trailing-stop / price-statistics families). Each
|
||||
iteration sweeps every indicator through both the streaming `update` loop
|
||||
and a full `batch` call so any state-mutation bug surfaces on either path.
|
||||
CI gains a `fuzz-smoke` job that runs each of the five targets for 30 s on
|
||||
every push and pull-request.
|
||||
- `UlcerIndex::update` now tracks the trailing maximum with a monotonically-
|
||||
decreasing deque of `(index, price)` pairs instead of scanning the whole
|
||||
trailing window on every tick. The indicator now honours the `Indicator`
|
||||
trait's O(1)-per-tick contract; values and warmup semantics are unchanged
|
||||
(verified by a new adversarial-input test that compares the deque output
|
||||
bar-by-bar against a naive O(n) trailing-max scan on strictly increasing,
|
||||
strictly decreasing, constant, and sawtooth inputs). The doc comment on
|
||||
`warmup_period()` is also corrected: the two windows overlap by one bar, so
|
||||
the formula is `2 * period - 1`.
|
||||
|
||||
### Added
|
||||
- `RollingVWAP` is now exposed in Python, Node and WASM under that name
|
||||
(previously the rolling-window VWAP existed only in the Rust core, even
|
||||
though the README's volume-family table already advertised
|
||||
`VWAP (cumulative + rolling)`). All four bindings now ship the same
|
||||
cumulative `VWAP` plus the finite-window `RollingVWAP(period)`. The wiki page
|
||||
`Indicator-Vwap.md` adds Python, Node and WASM examples and drops the
|
||||
"Rust-only" caveat.
|
||||
- WASM binding now exposes the streaming `update()` method on every candle-input
|
||||
indicator: `Adx`, `WilliamsR`, `Cci`, `Mfi`, `Psar`, `Keltner`, `Donchian`,
|
||||
`Vwap`, `AwesomeOscillator`, `Aroon`, `Stochastic`, and `Obv`. Multi-output
|
||||
indicators (`Adx`, `Keltner`, `Donchian`, `Aroon`, `Stochastic`) return a
|
||||
named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`,
|
||||
`{ up, down }`, `{ k, d }`) once warm, or `null` during warmup — matching the
|
||||
existing `SuperTrend` convention. Each class also gains `reset()`, `isReady()`
|
||||
and `warmupPeriod()`, bringing the WASM surface to full parity with Python
|
||||
and Node so browser-side streaming code no longer has to replay `batch()`
|
||||
on every tick. `WasmKama` gains the previously missing `warmupPeriod()`.
|
||||
- New `wasm-bindgen` integration test exercises `update == batch` plus the full
|
||||
lifecycle (`reset` / `isReady` / `warmupPeriod`) for all twelve newly wired
|
||||
classes against a deterministic 40-bar synthetic OHLCV stream.
|
||||
|
||||
### Security
|
||||
- Upgrade `pyo3` (0.22 → 0.28) and `numpy` (0.22 → 0.28) in the Python binding.
|
||||
Fixes [RUSTSEC-2025-0020](https://rustsec.org/advisories/RUSTSEC-2025-0020) —
|
||||
a buffer overflow in `PyString::from_object` that affected the published
|
||||
Python wheels. The `cargo-deny` ignore entry that previously suppressed the
|
||||
advisory has been removed; `cargo deny check` is now clean without
|
||||
suppression. Migrated `into_pyarray_bound` to `into_pyarray`,
|
||||
`downcast::<PyDict>` to `cast::<PyDict>`, and opted every `#[pyclass]` out of
|
||||
the deprecated automatic `FromPyObject` derive via `skip_from_py_object`.
|
||||
|
||||
### Added
|
||||
- 46 new technical indicators, taking the library from 25 to 71 and
|
||||
reorganising the catalogue into **eight families**, each with at least five
|
||||
members. Every indicator is implemented once in the Rust core and wired
|
||||
through the Python, Node and WASM bindings, with reference-value tests and a
|
||||
dedicated wiki page:
|
||||
- Moving Averages: `Smma`, `Trima`, `Zlema`, `T3`, `Vwma`.
|
||||
- Momentum Oscillators: `Mom`, `Cmo`, `Tsi`, `Pmo`, `StochRsi`,
|
||||
`UltimateOscillator`.
|
||||
- Trend & Directional: `AroonOscillator`, `Vortex`, `MassIndex`,
|
||||
`ChoppinessIndex`, `VerticalHorizontalFilter`.
|
||||
- Price Oscillators: `Ppo`, `Dpo`, `Coppock`, `AcceleratorOscillator`,
|
||||
`BalanceOfPower`.
|
||||
- Volatility & Bands: `Natr`, `StdDev`, `UlcerIndex`,
|
||||
`HistoricalVolatility`, `BollingerBandwidth`, `PercentB`, `TrueRange`,
|
||||
`ChaikinVolatility`.
|
||||
- Trailing Stops: `SuperTrend`, `ChandelierExit`, `ChandeKrollStop`,
|
||||
`AtrTrailingStop`.
|
||||
- Volume: `Adl`, `VolumePriceTrend`, `ChaikinMoneyFlow`,
|
||||
`ChaikinOscillator`, `ForceIndex`, `EaseOfMovement`.
|
||||
- Price Statistics: `TypicalPrice`, `MedianPrice`, `WeightedClose`,
|
||||
`LinearRegression`, `LinRegSlope`, `ZScore`, `LinRegAngle`.
|
||||
- `TickAggregator::with_gap_fill` — opt-in mode that emits a flat placeholder
|
||||
candle for every empty bucket between two ticks, keeping the candle series
|
||||
evenly spaced for downstream indicators.
|
||||
- CSV reader: a leading UTF-8 byte-order mark is stripped, fields are trimmed,
|
||||
and the header is validated against the required OHLCV columns.
|
||||
- CI: an `msrv` job that builds and tests the workspace on Rust 1.75 and the
|
||||
node binding on Rust 1.77.
|
||||
- Community health files: `CONTRIBUTING.md`, `SECURITY.md`,
|
||||
`CODE_OF_CONDUCT.md`, issue / pull-request templates, `CODEOWNERS`, and a
|
||||
Dependabot configuration.
|
||||
- Seven example OHLCV datasets under `examples/data/`, one per timeframe
|
||||
(1m / 5m / 15m / 1h / 12h / 1d / 1month), holding real BTCUSDT spot klines,
|
||||
alongside the `fetch_btcusdt` example that regenerates them from the
|
||||
Binance REST API.
|
||||
- `Timeframe::minutes`, `Timeframe::hours` and `Timeframe::days` convenience
|
||||
constructors, each building on seconds with a checked-multiplication
|
||||
overflow guard.
|
||||
|
||||
### Changed
|
||||
- The indicator wiki is reorganised into eight family folders under
|
||||
`docs/wiki/indicators/` (`moving-averages/`, `momentum-oscillators/`,
|
||||
`trend-directional/`, `price-oscillators/`, `volatility-bands/`,
|
||||
`trailing-stops/`, `volume/`, `price-statistics/`); `Indicators-Overview.md`,
|
||||
`Home.md` and the README indicator table follow the same eight families.
|
||||
- `TickAggregator::push` returns `Result<Vec<Candle>>` (was
|
||||
`Result<Option<Candle>>`) so a single tick can yield a closed bar plus gap
|
||||
fillers.
|
||||
- `Resampler::push` returns `Result<Option<Candle>>`: a candle in a bucket
|
||||
earlier than the open bar is now rejected as out of order.
|
||||
- Aggregated candles are finalised through the validating `Candle::new`, so a
|
||||
volume that overflows to a non-finite value is surfaced as an error instead
|
||||
of producing a poisoned candle.
|
||||
- All GitHub Actions are pinned to commit SHAs; the four publish jobs run in a
|
||||
protected `release` environment.
|
||||
- The indicator benchmarks (`crates/wickra/benches/indicators.rs`) now run
|
||||
against the checked-in real BTCUSDT 1-minute dataset instead of a synthetic
|
||||
price series.
|
||||
- Every language's examples now live under a uniform `examples/<lang>/`
|
||||
tree: Rust moved into a new `examples/rust/` workspace member crate
|
||||
(`wickra-examples`, run via `cargo run -p wickra-examples --bin <name>`),
|
||||
Node into `examples/node/` with its own `package.json` linking `wickra` via
|
||||
`file:../../bindings/node`, and the WASM browser demos into
|
||||
`examples/wasm/`. The bundled BTCUSDT datasets move alongside them at
|
||||
`examples/data/`. Six new examples close the cross-language parity matrix:
|
||||
streaming demos for Python and Rust; multi-timeframe and parallel-assets
|
||||
demos for both Rust and Node.
|
||||
- Cross-language data-generator parity: `examples/python/fetch_btcusdt.py`
|
||||
(stdlib only: `urllib` + `json` + `csv`) and `examples/node/fetch_btcusdt.js`
|
||||
(Node 18+ built-in `fetch`) mirror the Rust `fetch_btcusdt` binary —
|
||||
byte-for-byte identical CSV output on the same Binance snapshot.
|
||||
- Four additional WebAssembly browser demos under `examples/wasm/`
|
||||
alongside the original `index.html`: `backtest.html` (fetch + basket of
|
||||
indicators), `live_trading.html` (browser-native `WebSocket` to
|
||||
Binance), `multi_timeframe.html` (in-page resample) and
|
||||
`parallel_assets.html` + `parallel_worker.js` (module-Worker pool with
|
||||
serial-vs-parallel speedup). The cross-language matrix is now closed
|
||||
for every cell where the pattern makes sense.
|
||||
- Three new wiki pages: `TA-Lib-Migration.md` (full mapping table from
|
||||
`talib.X(...)` calls to Wickra), `Cookbook.md` (seven concrete
|
||||
strategy recipes — RSI mean reversion, MACD crossover, Bollinger
|
||||
breakout, ADX-gated trend, multi-timeframe confirmation, SuperTrend,
|
||||
chained indicators) and `FAQ.md`. All three linked from `Home.md`.
|
||||
|
||||
### Fixed
|
||||
- `Timeframe::floor` no longer overflows for timestamps near `i64::MIN`.
|
||||
- The aggregator rejects same-bucket ticks that arrive out of order instead of
|
||||
silently overwriting the bar's close with a stale price.
|
||||
- The Binance live stream reconnects with exponential backoff, skips non-kline
|
||||
frames, applies a read timeout and message-size limits, and tracks a closed
|
||||
flag.
|
||||
- Example scripts: `live_trading.py` skips non-kline frames and validates the
|
||||
symbol/interval; `backtest.py` and `multi_timeframe.py` report clear errors
|
||||
for malformed CSV input.
|
||||
|
||||
## [0.1.4] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- GitHub Release runs now attach every built artefact (wheels, sdist, native
|
||||
Node binaries, npm-pack tarballs, cargo `.crate` files) to the tag's
|
||||
release page.
|
||||
|
||||
## [0.1.3] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- npm package ships the napi-generated loader and is built with `--platform`
|
||||
so the per-platform binary is resolved correctly.
|
||||
|
||||
## [0.1.2] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- Release pipeline: per-platform idempotent npm publishing with a spam-filter
|
||||
retry, and committed `npm/<platform>/` package templates.
|
||||
|
||||
## [0.1.1] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- Node publish step and coordinated version bump across all bindings.
|
||||
|
||||
## [0.1.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- Initial release: a streaming-first technical-analysis library with 25
|
||||
indicators (SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, RSI, MACD, ROC, Stochastic,
|
||||
CCI, Williams %R, ADX, MFI, TRIX, Aroon, Awesome Oscillator, Bollinger Bands,
|
||||
ATR, Keltner Channels, Donchian Channels, Parabolic SAR, OBV, VWAP).
|
||||
- Rust core (`wickra-core`), umbrella crate (`wickra`), and a data layer
|
||||
(`wickra-data`) with a CSV reader, tick aggregator, resampler, and an
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.0...HEAD
|
||||
[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
|
||||
@@ -0,0 +1,47 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in
|
||||
the Wickra project a respectful and welcoming experience for everyone,
|
||||
regardless of background or identity.
|
||||
|
||||
## Our standards
|
||||
|
||||
Behaviour that helps build a positive community includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Respecting differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility for mistakes and learning from them.
|
||||
- Focusing on what is best for the project and the community.
|
||||
|
||||
Behaviour that is not acceptable includes:
|
||||
|
||||
- Personal attacks, insults, or derogatory comments.
|
||||
- Harassment of any kind, public or private.
|
||||
- Publishing others' private information without explicit permission.
|
||||
- Other conduct that would reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies in all project spaces — the repository, issues,
|
||||
pull requests, and discussions — and when an individual is representing the
|
||||
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
|
||||
promptly and fairly, and the maintainer will respect the privacy and security
|
||||
of the reporter.
|
||||
|
||||
Maintainers may take any action they deem appropriate, including warnings,
|
||||
temporary bans, or permanent removal from the project, for behaviour that
|
||||
violates this Code of Conduct.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Contributing to Wickra
|
||||
|
||||
Thanks for your interest in improving Wickra. This document explains how to
|
||||
build the project, the standards a change must meet, and how to get it merged.
|
||||
|
||||
## License of contributions
|
||||
|
||||
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
|
||||
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
|
||||
licensed to the project under those same terms. The Noncommercial license
|
||||
permits use for any purpose **other than** a commercial one; keep that in mind
|
||||
when proposing features or depending on Wickra elsewhere.
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `crates/wickra-core` | The indicator engine — every indicator lives here. |
|
||||
| `crates/wickra` | Thin umbrella crate re-exporting `wickra-core`. |
|
||||
| `crates/wickra-data` | CSV reader, tick aggregator, resampler, Binance feed. |
|
||||
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/wiki/` | Documentation sources. |
|
||||
|
||||
## Building and testing
|
||||
|
||||
### Rust
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo test -p wickra-data --features live-binance
|
||||
```
|
||||
|
||||
The minimum supported Rust version is **1.75** for the workspace crates and
|
||||
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
cd bindings/python
|
||||
python -m maturin build --release --out dist
|
||||
python -m pip install --force-reinstall --no-deps dist/wickra-*.whl
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```bash
|
||||
cd bindings/node
|
||||
npm install
|
||||
npx napi build --platform --release
|
||||
node --test __tests__/
|
||||
```
|
||||
|
||||
### WASM
|
||||
|
||||
```bash
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
wasm-pack test --node bindings/wasm
|
||||
```
|
||||
|
||||
## Standards for a change
|
||||
|
||||
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
|
||||
`cargo clippy ... -D warnings` must be clean. CI gates both.
|
||||
- **Tests.** New behaviour needs tests; bug fixes need a regression test.
|
||||
- **Indicator correctness.** A new or changed indicator must have a
|
||||
reference-value test against a known-good source (TA-Lib, pandas-ta, or a
|
||||
hand-computed value) and a `reset` test.
|
||||
- **Streaming parity.** An indicator's `batch` output must equal the sequence
|
||||
of `update` calls.
|
||||
- **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 under `docs/wiki/` and the `README.md`
|
||||
when behaviour or the public API changes.
|
||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
1. Branch off `main`.
|
||||
2. Keep commits focused — one logical change per commit, with an imperative
|
||||
subject line and a body explaining *why*.
|
||||
3. Open a pull request against `main` and fill in the template.
|
||||
4. CI must be green before review.
|
||||
|
||||
## Reporting bugs and proposing features
|
||||
|
||||
Use the issue templates under
|
||||
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
|
||||
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
|
||||
Generated
+115
-47
@@ -38,6 +38,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -679,15 +690,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
@@ -748,6 +750,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -783,12 +791,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -891,6 +900,15 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -916,13 +934,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "0.22.1"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
|
||||
checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"ndarray",
|
||||
@@ -930,6 +949,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pyo3",
|
||||
"pyo3-build-config",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
@@ -1107,37 +1127,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
|
||||
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
|
||||
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
|
||||
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
@@ -1145,9 +1160,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
|
||||
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
@@ -1157,9 +1172,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
|
||||
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -1320,9 +1335,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
@@ -1531,9 +1546,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
@@ -1707,12 +1722,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1805,6 +1814,16 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
@@ -1837,6 +1856,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
@@ -1883,7 +1941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1894,7 +1952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1904,7 +1962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1916,13 +1974,22 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"wickra",
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1932,7 +1999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1941,13 +2008,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
|
||||
+7
-6
@@ -7,12 +7,13 @@ members = [
|
||||
"bindings/python",
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"examples/rust",
|
||||
]
|
||||
exclude = []
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
authors = ["Wickra Contributors"]
|
||||
version = "0.2.0"
|
||||
authors = ["kingchenc <kingchencp@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
@@ -23,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.1.3" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.2.0" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
@@ -34,8 +35,8 @@ approx = "0.5"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
# Python binding
|
||||
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.22"
|
||||
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.28"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/kingchenc/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -51,33 +52,47 @@ multi-language reach, and active maintenance.
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
Reproduced on this machine with `python -m benchmarks.compare_libraries`.
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 5 000-bar series
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
|---------------------|---------------------|------------------------|------------------------------|
|
||||
| SMA(20) | **26.0 µs ★** | 295.3 µs (11.4× slower) | 1 812.8 µs (69.7× slower) |
|
||||
| EMA(20) | **16.8 µs ★** | 205.5 µs (12.2× slower) | 2 534.4 µs (150.9× slower) |
|
||||
| RSI(14) | **31.2 µs ★** | 714.1 µs (22.9× slower) | 3 751.7 µs (120.2× slower) |
|
||||
| MACD(12, 26, 9) | **30.8 µs ★** | 359.5 µs (11.7× slower) | 11 642.2 µs (378.0× slower) |
|
||||
| Bollinger(20, 2.0) | **26.7 µs ★** | 690.6 µs (25.9× slower) | 27 482.4 µs (1 030.1× slower) |
|
||||
| ATR(14) | **40.6 µs ★** | 1 120.3 µs (27.6× slower) | 3 760.2 µs (92.7× slower) |
|
||||
| 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) |
|
||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 2 000 historical bars
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.07 µs ★** | 1.16 µs (17.5× slower) |
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
||||
> cleanly on Windows without C build tooling — which is precisely the install
|
||||
@@ -92,18 +107,22 @@ pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
|
||||
## Indicators in 0.1.0
|
||||
## Indicators
|
||||
|
||||
25 streaming-first indicators across four families. Every one passes the
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|-------------|-----------|
|
||||
| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA |
|
||||
| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon |
|
||||
| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR |
|
||||
| Volume | OBV, VWAP (cumulative + rolling) |
|
||||
| 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 |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -113,9 +132,12 @@ inherit it automatically.
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `bindings/node/__tests__/smoke.test.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `bindings/wasm/examples/index.html` |
|
||||
| Rust | `cargo add wickra` | `crates/wickra/examples/backtest.rs` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
@@ -173,20 +195,26 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 25 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io)
|
||||
│ ├── wickra-core/ core engine + all 71 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/
|
||||
│ └── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ (Rust examples live inside their crate at crates/<name>/examples/)
|
||||
├── benches/ cargo bench targets
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
|
||||
## Building everything from source
|
||||
|
||||
```bash
|
||||
@@ -207,15 +235,23 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Test counts
|
||||
## Testing
|
||||
|
||||
- `wickra-core`: 171 unit tests + 2 doctests, including textbook-value tests
|
||||
for Wilder RSI, Bollinger Bands, MACD, ATR, and Stochastic.
|
||||
- `wickra-data`: 11 unit tests + 1 doctest, covers CSV decoding, the tick
|
||||
aggregator, the resampler, and the Binance payload parser.
|
||||
- `bindings/python`: 56 pytest tests covering smoke checks, streaming==batch
|
||||
equivalence, reference values, lifecycle, and dict/tuple candle inputs.
|
||||
- `bindings/node`: 7 Node test-runner cases via `node --test`.
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
|
||||
version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| 0.1.x (latest) | :white_check_mark: |
|
||||
| older 0.1.x | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue for a security vulnerability.**
|
||||
|
||||
Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/kingchenc/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
|
||||
`[wickra security]`.
|
||||
|
||||
Please include:
|
||||
|
||||
- the affected version(s) and platform / language binding,
|
||||
- a description of the issue and its impact,
|
||||
- steps to reproduce, ideally a minimal proof of concept.
|
||||
|
||||
## What to expect
|
||||
|
||||
- An acknowledgement within **5 working days**.
|
||||
- An assessment and, if confirmed, a planned fix with a target release.
|
||||
- Coordinated disclosure: we will agree on a disclosure date with you and
|
||||
credit you in the release notes unless you prefer to stay anonymous.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the published crates (`wickra-core`, `wickra-data`, `wickra`), the
|
||||
PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
|
||||
|
||||
Out of scope: vulnerabilities in third-party dependencies (report those
|
||||
upstream; we track them via Dependabot and `cargo-deny`).
|
||||
@@ -1,4 +1,4 @@
|
||||
# @wickra/wickra
|
||||
# wickra
|
||||
|
||||
Node.js bindings for the Wickra streaming-first technical indicators library.
|
||||
|
||||
@@ -7,7 +7,7 @@ Node.js bindings for the Wickra streaming-first technical indicators library.
|
||||
Once published, install per platform via the precompiled native package:
|
||||
|
||||
```bash
|
||||
npm install @wickra/wickra
|
||||
npm install wickra
|
||||
```
|
||||
|
||||
## Build from source
|
||||
@@ -26,7 +26,7 @@ produces a `wickra.<platform>-<arch>.node` binary in the package root that
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { SMA, RSI, MACD, version } from '@wickra/wickra';
|
||||
import { SMA, RSI, MACD, version } from 'wickra';
|
||||
|
||||
console.log('wickra', version());
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch
|
||||
// equivalence, reference values, and lifecycle methods across all 71
|
||||
// indicators. Ported from the Python test_streaming_vs_batch / test_known_values
|
||||
// suites.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// Synthetic OHLCV series long enough to warm up every indicator.
|
||||
const N = 120;
|
||||
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.2) * 10 + i * 0.1);
|
||||
const high = close.map((c) => c + 1.5);
|
||||
const low = close.map((c) => c - 1.5);
|
||||
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 7) * 50);
|
||||
const open = close.map((c) => c - 0.5);
|
||||
|
||||
function eq(a, b) {
|
||||
if (Number.isNaN(a)) return Number.isNaN(b);
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
return v === null || v === undefined ? NaN : v;
|
||||
}
|
||||
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
SMA: () => new wickra.SMA(14),
|
||||
EMA: () => new wickra.EMA(14),
|
||||
WMA: () => new wickra.WMA(14),
|
||||
RSI: () => new wickra.RSI(14),
|
||||
DEMA: () => new wickra.DEMA(10),
|
||||
TEMA: () => new wickra.TEMA(10),
|
||||
HMA: () => new wickra.HMA(9),
|
||||
ROC: () => new wickra.ROC(12),
|
||||
TRIX: () => new wickra.TRIX(9),
|
||||
KAMA: () => new wickra.KAMA(10, 2, 30),
|
||||
SMMA: () => new wickra.SMMA(14),
|
||||
TRIMA: () => new wickra.TRIMA(20),
|
||||
ZLEMA: () => new wickra.ZLEMA(14),
|
||||
T3: () => new wickra.T3(5, 0.7),
|
||||
MOM: () => new wickra.MOM(10),
|
||||
CMO: () => new wickra.CMO(14),
|
||||
TSI: () => new wickra.TSI(25, 13),
|
||||
PMO: () => new wickra.PMO(35, 20),
|
||||
StochRSI: () => new wickra.StochRSI(14, 14),
|
||||
PPO: () => new wickra.PPO(12, 26),
|
||||
DPO: () => new wickra.DPO(20),
|
||||
Coppock: () => new wickra.Coppock(14, 11, 10),
|
||||
StdDev: () => new wickra.StdDev(20),
|
||||
UlcerIndex: () => new wickra.UlcerIndex(14),
|
||||
HistoricalVolatility: () => new wickra.HistoricalVolatility(20, 252),
|
||||
BollingerBandwidth: () => new wickra.BollingerBandwidth(20, 2),
|
||||
PercentB: () => new wickra.PercentB(20, 2),
|
||||
LinearRegression: () => new wickra.LinearRegression(14),
|
||||
LinRegSlope: () => new wickra.LinRegSlope(14),
|
||||
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
|
||||
ZScore: () => new wickra.ZScore(20),
|
||||
LinRegAngle: () => new wickra.LinRegAngle(14),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(scalarFactories)) {
|
||||
test(`${name}: streaming update matches batch`, () => {
|
||||
const batch = make().batch(close);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(close[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
|
||||
|
||||
const candleScalar = {
|
||||
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
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) },
|
||||
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) },
|
||||
MassIndex: { make: () => new wickra.MassIndex(9, 25), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ADL: { make: () => new wickra.ADL(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VolumePriceTrend: { make: () => new wickra.VolumePriceTrend(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
ChaikinMoneyFlow: { make: () => new wickra.ChaikinMoneyFlow(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
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) },
|
||||
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) },
|
||||
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) },
|
||||
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) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
test(`${name}: streaming update matches batch`, () => {
|
||||
const batch = d.batch(d.make());
|
||||
const streaming = d.make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(d.step(streaming, i));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Multi-output indicators: object update vs interleaved batch ---
|
||||
|
||||
const multi = {
|
||||
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], 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) },
|
||||
Keltner: { make: () => new wickra.Keltner(20, 10, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
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) },
|
||||
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) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
test(`${name}: streaming update matches interleaved batch`, () => {
|
||||
const k = d.fields.length;
|
||||
const batch = d.batch(d.make());
|
||||
const streaming = d.make();
|
||||
assert.equal(batch.length, N * k);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const o = d.step(streaming, i);
|
||||
d.fields.forEach((field, j) => {
|
||||
const s = o === null || o === undefined ? NaN : o[field];
|
||||
assert.ok(eq(s, batch[i * k + j]), `${name}.${field} mismatch at ${i}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lifecycle: every indicator exposes reset / isReady / warmupPeriod ---
|
||||
|
||||
test('every indicator exposes reset, isReady and warmupPeriod', () => {
|
||||
const all = [
|
||||
...Object.values(scalarFactories).map((f) => f()),
|
||||
...Object.values(candleScalar).map((d) => d.make()),
|
||||
...Object.values(multi).map((d) => d.make()),
|
||||
];
|
||||
for (const ind of all) {
|
||||
assert.equal(typeof ind.reset, 'function');
|
||||
assert.equal(typeof ind.isReady, 'function');
|
||||
assert.equal(typeof ind.warmupPeriod, 'function');
|
||||
assert.equal(ind.isReady(), false);
|
||||
assert.ok(ind.warmupPeriod() >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('reset returns an indicator to its un-warmed state', () => {
|
||||
const sma = new wickra.SMA(5);
|
||||
sma.batch([1, 2, 3, 4, 5]);
|
||||
assert.equal(sma.isReady(), true);
|
||||
sma.reset();
|
||||
assert.equal(sma.isReady(), false);
|
||||
assert.equal(sma.update(10), null);
|
||||
});
|
||||
|
||||
// --- Reference values ---
|
||||
|
||||
test('SMA(3) reference values', () => {
|
||||
const out = new wickra.SMA(3).batch([2, 4, 6, 8, 10]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.equal(out[2], 4);
|
||||
assert.equal(out[3], 6);
|
||||
assert.equal(out[4], 8);
|
||||
});
|
||||
|
||||
test('MFI(2) reference value equals 1200/23', () => {
|
||||
// Candle 1 seeds; candle 2 (tp 12 > 10) +mf 1200; candle 3 (tp 11 < 12) -mf 1100.
|
||||
const mfi = new wickra.MFI(2);
|
||||
assert.equal(mfi.update(10, 10, 10, 100), null);
|
||||
assert.equal(mfi.update(12, 12, 12, 100), null);
|
||||
const v = mfi.update(11, 11, 11, 100);
|
||||
assert.ok(Math.abs(v - 1200 / 23) < 1e-9);
|
||||
});
|
||||
|
||||
test('RSI pure uptrend yields 100', () => {
|
||||
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
|
||||
const out = new wickra.RSI(14).batch(prices);
|
||||
for (let i = 14; i < out.length; i++) {
|
||||
assert.equal(out[i], 100);
|
||||
}
|
||||
});
|
||||
|
||||
test('MACD histogram equals macd minus signal', () => {
|
||||
const macd = new wickra.MACD(12, 26, 9);
|
||||
let v = null;
|
||||
for (let i = 1; i <= 60; i++) v = macd.update(i);
|
||||
assert.ok(v);
|
||||
assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9);
|
||||
});
|
||||
|
||||
test('TypicalPrice reference value', () => {
|
||||
// (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
|
||||
assert.equal(new wickra.TypicalPrice().update(12, 6, 9), 9);
|
||||
});
|
||||
|
||||
test('ChaikinMoneyFlow(2) reference value equals 0.5', () => {
|
||||
// Bar 1 closes at the high (MFV +100); bar 2 closes mid-range (MFV 0).
|
||||
const cmf = new wickra.ChaikinMoneyFlow(2);
|
||||
assert.equal(cmf.update(10, 8, 10, 100), null);
|
||||
assert.ok(Math.abs(cmf.update(12, 8, 10, 100) - 0.5) < 1e-9);
|
||||
});
|
||||
|
||||
test('LinearRegression(3) reference values', () => {
|
||||
// Least-squares line through [1, 2, 9] is y = 4x; endpoint 4·2 = 8.
|
||||
const out = new wickra.LinearRegression(3).batch([1, 2, 9]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.ok(Math.abs(out[2] - 8) < 1e-9);
|
||||
});
|
||||
|
||||
test('SuperTrend flat market holds the lower band and an uptrend', () => {
|
||||
// Flat candles: ATR 2, hl2 10, lower band 10 - 3·2 = 4.
|
||||
const n = 20;
|
||||
const out = new wickra.SuperTrend(5, 3).batch(
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
Array(n).fill(10),
|
||||
);
|
||||
assert.ok(Math.abs(out[2 * n - 2] - 4) < 1e-9); // value
|
||||
assert.equal(out[2 * n - 1], 1); // direction
|
||||
});
|
||||
|
||||
test('BalanceOfPower reference value', () => {
|
||||
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
||||
assert.ok(Math.abs(new wickra.BalanceOfPower().update(10, 14, 10, 12) - 0.5) < 1e-9);
|
||||
});
|
||||
|
||||
test('TrueRange reference values', () => {
|
||||
const tr = new wickra.TrueRange();
|
||||
assert.equal(tr.update(12, 8, 11), 4); // no prev close -> high - low
|
||||
assert.equal(tr.update(10, 9, 9.5), 2); // prev close 11 -> max(1, 1, 2)
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
+48
-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, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = 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, 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
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -322,6 +322,16 @@ module.exports.TEMA = TEMA
|
||||
module.exports.HMA = HMA
|
||||
module.exports.ROC = ROC
|
||||
module.exports.TRIX = TRIX
|
||||
module.exports.SMMA = SMMA
|
||||
module.exports.TRIMA = TRIMA
|
||||
module.exports.ZLEMA = ZLEMA
|
||||
module.exports.MOM = MOM
|
||||
module.exports.CMO = CMO
|
||||
module.exports.DPO = DPO
|
||||
module.exports.StdDev = StdDev
|
||||
module.exports.UlcerIndex = UlcerIndex
|
||||
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
||||
module.exports.ZScore = ZScore
|
||||
module.exports.MACD = MACD
|
||||
module.exports.BollingerBands = BollingerBands
|
||||
module.exports.ATR = ATR
|
||||
@@ -335,6 +345,43 @@ module.exports.PSAR = PSAR
|
||||
module.exports.Keltner = Keltner
|
||||
module.exports.Donchian = Donchian
|
||||
module.exports.VWAP = VWAP
|
||||
module.exports.RollingVWAP = RollingVWAP
|
||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||
module.exports.Aroon = Aroon
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.T3 = T3
|
||||
module.exports.TSI = TSI
|
||||
module.exports.PMO = PMO
|
||||
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.SuperTrend = SuperTrend
|
||||
module.exports.ChandelierExit = ChandelierExit
|
||||
module.exports.ChandeKrollStop = ChandeKrollStop
|
||||
module.exports.AtrTrailingStop = AtrTrailingStop
|
||||
module.exports.TypicalPrice = TypicalPrice
|
||||
module.exports.MedianPrice = MedianPrice
|
||||
module.exports.WeightedClose = WeightedClose
|
||||
module.exports.LinearRegression = LinearRegression
|
||||
module.exports.LinRegSlope = LinRegSlope
|
||||
module.exports.AcceleratorOscillator = AcceleratorOscillator
|
||||
module.exports.BalanceOfPower = BalanceOfPower
|
||||
module.exports.ChoppinessIndex = ChoppinessIndex
|
||||
module.exports.TrueRange = TrueRange
|
||||
module.exports.ChaikinVolatility = ChaikinVolatility
|
||||
module.exports.LinRegAngle = LinRegAngle
|
||||
module.exports.BollingerBandwidth = BollingerBandwidth
|
||||
module.exports.PercentB = PercentB
|
||||
module.exports.NATR = NATR
|
||||
module.exports.HistoricalVolatility = HistoricalVolatility
|
||||
module.exports.AroonOscillator = AroonOscillator
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.MassIndex = MassIndex
|
||||
module.exports.StochRSI = StochRSI
|
||||
module.exports.UltimateOscillator = UltimateOscillator
|
||||
module.exports.PPO = PPO
|
||||
module.exports.Coppock = Coppock
|
||||
module.exports.VWMA = VWMA
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
"wickra.darwin-arm64.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
"wickra.darwin-x64.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
"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/kingchenc/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <kingchencp@gmail.com>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"keywords": [
|
||||
"trading",
|
||||
"indicators",
|
||||
@@ -35,20 +35,24 @@
|
||||
"defaults": false,
|
||||
"additional": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.1.3",
|
||||
"wickra-darwin-x64": "0.1.3",
|
||||
"wickra-darwin-arm64": "0.1.3",
|
||||
"wickra-win32-x64-msvc": "0.1.3"
|
||||
"wickra-linux-x64-gnu": "0.2.0",
|
||||
"wickra-linux-arm64-gnu": "0.2.0",
|
||||
"wickra-darwin-x64": "0.2.0",
|
||||
"wickra-darwin-arm64": "0.2.0",
|
||||
"wickra-win32-x64-msvc": "0.2.0",
|
||||
"wickra-win32-arm64-msvc": "0.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
+2673
-413
File diff suppressed because it is too large
Load Diff
@@ -26,14 +26,26 @@ for price in live_prices:
|
||||
|
||||
## What's included
|
||||
|
||||
25 streaming-first indicators across four families. Every one passes a
|
||||
71 streaming-first indicators across eight families. Every one passes a
|
||||
`batch == streaming` equivalence test and reference-value tests:
|
||||
|
||||
- **Trend** — SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA
|
||||
- **Momentum** — RSI (Wilder), MACD, Stochastic, CCI, ROC, WilliamsR, ADX,
|
||||
MFI, TRIX, AwesomeOscillator, Aroon
|
||||
- **Volatility** — BollingerBands, ATR, Keltner, Donchian, PSAR
|
||||
- **Volume** — OBV, VWAP
|
||||
- **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
|
||||
|
||||
## Why streaming-first matters
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
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" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
@@ -20,6 +21,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Rust",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
"Topic :: Scientific/Engineering :: Mathematics",
|
||||
|
||||
@@ -25,58 +25,162 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
ADX,
|
||||
ATR,
|
||||
Aroon,
|
||||
AwesomeOscillator,
|
||||
BollingerBands,
|
||||
CCI,
|
||||
DEMA,
|
||||
Donchian,
|
||||
# Trend
|
||||
SMA,
|
||||
EMA,
|
||||
WMA,
|
||||
DEMA,
|
||||
TEMA,
|
||||
HMA,
|
||||
KAMA,
|
||||
Keltner,
|
||||
MACD,
|
||||
MFI,
|
||||
OBV,
|
||||
PSAR,
|
||||
ROC,
|
||||
SMMA,
|
||||
TRIMA,
|
||||
ZLEMA,
|
||||
T3,
|
||||
VWMA,
|
||||
# Momentum
|
||||
RSI,
|
||||
SMA,
|
||||
MACD,
|
||||
Stochastic,
|
||||
TEMA,
|
||||
TRIX,
|
||||
VWAP,
|
||||
CCI,
|
||||
ROC,
|
||||
WilliamsR,
|
||||
WMA,
|
||||
ADX,
|
||||
MFI,
|
||||
TRIX,
|
||||
AwesomeOscillator,
|
||||
Aroon,
|
||||
MOM,
|
||||
CMO,
|
||||
TSI,
|
||||
PMO,
|
||||
StochRSI,
|
||||
UltimateOscillator,
|
||||
PPO,
|
||||
DPO,
|
||||
Coppock,
|
||||
AroonOscillator,
|
||||
Vortex,
|
||||
MassIndex,
|
||||
AcceleratorOscillator,
|
||||
BalanceOfPower,
|
||||
ChoppinessIndex,
|
||||
VerticalHorizontalFilter,
|
||||
# Volatility
|
||||
BollingerBands,
|
||||
ATR,
|
||||
Keltner,
|
||||
Donchian,
|
||||
PSAR,
|
||||
NATR,
|
||||
StdDev,
|
||||
UlcerIndex,
|
||||
HistoricalVolatility,
|
||||
BollingerBandwidth,
|
||||
PercentB,
|
||||
SuperTrend,
|
||||
ChandelierExit,
|
||||
ChandeKrollStop,
|
||||
AtrTrailingStop,
|
||||
TrueRange,
|
||||
ChaikinVolatility,
|
||||
# Volume
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
ADL,
|
||||
VolumePriceTrend,
|
||||
ChaikinMoneyFlow,
|
||||
ChaikinOscillator,
|
||||
ForceIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
TypicalPrice,
|
||||
MedianPrice,
|
||||
WeightedClose,
|
||||
LinearRegression,
|
||||
LinRegSlope,
|
||||
ZScore,
|
||||
LinRegAngle,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
# Trend
|
||||
"SMA",
|
||||
"EMA",
|
||||
"WMA",
|
||||
"RSI",
|
||||
"MACD",
|
||||
"BollingerBands",
|
||||
"ATR",
|
||||
"Stochastic",
|
||||
"OBV",
|
||||
"DEMA",
|
||||
"TEMA",
|
||||
"HMA",
|
||||
"KAMA",
|
||||
"SMMA",
|
||||
"TRIMA",
|
||||
"ZLEMA",
|
||||
"T3",
|
||||
"VWMA",
|
||||
# Momentum
|
||||
"RSI",
|
||||
"MACD",
|
||||
"Stochastic",
|
||||
"CCI",
|
||||
"ROC",
|
||||
"WilliamsR",
|
||||
"ADX",
|
||||
"MFI",
|
||||
"TRIX",
|
||||
"PSAR",
|
||||
"Keltner",
|
||||
"Donchian",
|
||||
"VWAP",
|
||||
"AwesomeOscillator",
|
||||
"Aroon",
|
||||
"MOM",
|
||||
"CMO",
|
||||
"TSI",
|
||||
"PMO",
|
||||
"StochRSI",
|
||||
"UltimateOscillator",
|
||||
"PPO",
|
||||
"DPO",
|
||||
"Coppock",
|
||||
"AroonOscillator",
|
||||
"Vortex",
|
||||
"MassIndex",
|
||||
"AcceleratorOscillator",
|
||||
"BalanceOfPower",
|
||||
"ChoppinessIndex",
|
||||
"VerticalHorizontalFilter",
|
||||
# Volatility
|
||||
"BollingerBands",
|
||||
"ATR",
|
||||
"Keltner",
|
||||
"Donchian",
|
||||
"PSAR",
|
||||
"NATR",
|
||||
"StdDev",
|
||||
"UlcerIndex",
|
||||
"HistoricalVolatility",
|
||||
"BollingerBandwidth",
|
||||
"PercentB",
|
||||
"SuperTrend",
|
||||
"ChandelierExit",
|
||||
"ChandeKrollStop",
|
||||
"AtrTrailingStop",
|
||||
"TrueRange",
|
||||
"ChaikinVolatility",
|
||||
# Volume
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
"ADL",
|
||||
"VolumePriceTrend",
|
||||
"ChaikinMoneyFlow",
|
||||
"ChaikinOscillator",
|
||||
"ForceIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"TypicalPrice",
|
||||
"MedianPrice",
|
||||
"WeightedClose",
|
||||
"LinearRegression",
|
||||
"LinRegSlope",
|
||||
"ZScore",
|
||||
"LinRegAngle",
|
||||
]
|
||||
|
||||
@@ -52,6 +52,642 @@ class WMA:
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class SMMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class TRIMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ADL:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class VolumePriceTrend:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ChaikinMoneyFlow:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class ChaikinOscillator:
|
||||
def __init__(self, fast: int = 3, slow: int = 10) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
|
||||
class ForceIndex:
|
||||
def __init__(self, period: int = 13) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class EaseOfMovement:
|
||||
def __init__(self, period: int = 14, divisor: float = 100000000.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def divisor(self) -> float: ...
|
||||
|
||||
class SuperTrend:
|
||||
def __init__(self, atr_period: int = 10, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[value, direction]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class ChandelierExit:
|
||||
def __init__(self, period: int = 22, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[long_stop, short_stop]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class ChandeKrollStop:
|
||||
def __init__(
|
||||
self, atr_period: int = 10, atr_multiplier: float = 1.0, stop_period: int = 9
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[stop_long, stop_short]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float, int]: ...
|
||||
|
||||
class AtrTrailingStop:
|
||||
def __init__(self, atr_period: int = 14, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class TypicalPrice:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class MedianPrice:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class WeightedClose:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class LinearRegression:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class LinRegSlope:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class AcceleratorOscillator:
|
||||
def __init__(
|
||||
self, ao_fast: int = 5, ao_slow: int = 34, signal_period: int = 5
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, int, int]: ...
|
||||
|
||||
class BalanceOfPower:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
open: NDArray[np.float64],
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ChoppinessIndex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class VerticalHorizontalFilter:
|
||||
def __init__(self, period: int = 28) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class TrueRange:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ChaikinVolatility:
|
||||
def __init__(self, ema_period: int = 10, roc_period: int = 10) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
|
||||
class ZScore:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class LinRegAngle:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class BollingerBandwidth:
|
||||
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def multiplier(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PercentB:
|
||||
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def multiplier(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class NATR:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class StdDev:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class UlcerIndex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class HistoricalVolatility:
|
||||
def __init__(self, period: int = 20, trading_periods: int = 252) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class AroonOscillator:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class Vortex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[plus, minus]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class MassIndex:
|
||||
def __init__(self, ema_period: int = 9, sum_period: int = 25) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PPO:
|
||||
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class DPO:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def shift(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class Coppock:
|
||||
def __init__(
|
||||
self, roc_long: int = 14, roc_short: int = 11, wma_period: int = 10
|
||||
) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class StochRSI:
|
||||
def __init__(self, rsi_period: int = 14, stoch_period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class UltimateOscillator:
|
||||
def __init__(self, short: int = 7, mid: int = 14, long: int = 28) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class MOM:
|
||||
def __init__(self, period: int = 10) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class CMO:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class TSI:
|
||||
def __init__(self, long: int = 25, short: int = 13) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PMO:
|
||||
def __init__(self, smoothing1: int = 35, smoothing2: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ZLEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def lag(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class T3:
|
||||
def __init__(self, period: int, v: float = 0.7) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def volume_factor(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class VWMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class RSI:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
@@ -135,3 +771,218 @@ class OBV:
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class DEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class TEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class HMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class KAMA:
|
||||
def __init__(self, er_period: int = 10, fast: int = 2, slow: int = 30) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class CCI:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class ROC:
|
||||
def __init__(self, period: int = 10) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class WilliamsR:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ADX:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[plus_di, minus_di, adx]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class MFI:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class TRIX:
|
||||
def __init__(self, period: int = 30) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class PSAR:
|
||||
def __init__(
|
||||
self, af_start: float = 0.02, af_step: float = 0.02, af_max: float = 0.20
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Keltner:
|
||||
def __init__(
|
||||
self, ema_period: int = 20, atr_period: int = 10, multiplier: float = 2.0
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Donchian:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class VWAP:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class RollingVWAP:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class AwesomeOscillator:
|
||||
def __init__(self, fast: int = 5, slow: int = 34) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Aroon:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[up, down]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
+3261
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
"""Input-validation tests: malformed NumPy inputs raise ValueError, not panics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wickra as ta
|
||||
|
||||
|
||||
def test_non_contiguous_array_raises_value_error():
|
||||
# A strided view is not C-contiguous; batch() must reject it cleanly.
|
||||
base = np.linspace(1.0, 100.0, 60)
|
||||
non_contiguous = base[::2]
|
||||
assert not non_contiguous.flags["C_CONTIGUOUS"]
|
||||
with pytest.raises(ValueError):
|
||||
ta.SMA(5).batch(non_contiguous)
|
||||
|
||||
|
||||
def test_ascontiguousarray_recovers():
|
||||
base = np.linspace(1.0, 100.0, 60)
|
||||
fixed = np.ascontiguousarray(base[::2])
|
||||
out = ta.SMA(5).batch(fixed)
|
||||
assert out.shape == fixed.shape
|
||||
|
||||
|
||||
def test_unequal_length_candle_batch_raises(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
short = low[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.ATR(14).batch(high, short, close)
|
||||
with pytest.raises(ValueError):
|
||||
ta.WilliamsR(14).batch(high, short, close)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Aroon(14).batch(high, short)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Streaming-vs-batch, shape and reference-value tests for the F1-F12 families.
|
||||
|
||||
Every indicator added since the original 25 is exercised here. The central
|
||||
contract is the same as the rest of the suite: ``batch(...)`` must equal
|
||||
repeated streaming ``update(...)`` across the whole warmup -> steady-state
|
||||
transition, and batch shapes must match the input length.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wickra as ta
|
||||
|
||||
|
||||
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
|
||||
"""Compare two float arrays treating NaN positions as equal."""
|
||||
a = np.asarray(a, dtype=np.float64)
|
||||
b = np.asarray(b, dtype=np.float64)
|
||||
if a.shape != b.shape:
|
||||
return False
|
||||
both_nan = np.isnan(a) & np.isnan(b)
|
||||
return bool(np.all(np.where(both_nan, 0.0, np.abs(a - b)) <= tol))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Synthetic high / low / close / volume series, 200 bars."""
|
||||
t = np.arange(200, dtype=np.float64)
|
||||
close = 100.0 + np.sin(t * 0.15) * 8.0 + np.cos(t * 0.32) * 3.0
|
||||
spread = 0.5 + np.abs(np.sin(t * 0.07))
|
||||
high = close + spread
|
||||
low = close - spread
|
||||
volume = 1000.0 + (t % 7) * 50.0
|
||||
return high, low, close, volume
|
||||
|
||||
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.SMMA, (14,)),
|
||||
(ta.TRIMA, (20,)),
|
||||
(ta.ZLEMA, (14,)),
|
||||
(ta.T3, (5, 0.7)),
|
||||
(ta.MOM, (10,)),
|
||||
(ta.CMO, (14,)),
|
||||
(ta.TSI, (25, 13)),
|
||||
(ta.PMO, (35, 20)),
|
||||
(ta.StochRSI, (14, 14)),
|
||||
(ta.PPO, (12, 26)),
|
||||
(ta.DPO, (20,)),
|
||||
(ta.Coppock, (14, 11, 10)),
|
||||
(ta.StdDev, (20,)),
|
||||
(ta.UlcerIndex, (14,)),
|
||||
(ta.HistoricalVolatility, (20, 252)),
|
||||
(ta.BollingerBandwidth, (20, 2.0)),
|
||||
(ta.PercentB, (20, 2.0)),
|
||||
(ta.LinearRegression, (14,)),
|
||||
(ta.LinRegSlope, (14,)),
|
||||
(ta.VerticalHorizontalFilter, (28,)),
|
||||
(ta.ZScore, (20,)),
|
||||
(ta.LinRegAngle, (14,)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR])
|
||||
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
batch = cls(*args).batch(sine_prices)
|
||||
assert batch.shape == sine_prices.shape
|
||||
assert batch.dtype == np.float64
|
||||
|
||||
streamer = cls(*args)
|
||||
streamed = []
|
||||
for p in sine_prices:
|
||||
v = streamer.update(float(p))
|
||||
streamed.append(math.nan if v is None else float(v))
|
||||
assert _eq_nan(batch, np.array(streamed, dtype=np.float64))
|
||||
|
||||
|
||||
# --- Candle-input, single-output indicators -------------------------------
|
||||
#
|
||||
# Each entry is (factory, batch-call). Streaming always feeds the full
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
|
||||
"UltimateOscillator": (
|
||||
lambda: ta.UltimateOscillator(7, 14, 28),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"AroonOscillator": (
|
||||
lambda: ta.AroonOscillator(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
"NATR": (lambda: ta.NATR(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"MassIndex": (lambda: ta.MassIndex(9, 25), lambda ind, h, l, c, v: ind.batch(h, l)),
|
||||
"ADL": (lambda: ta.ADL(), lambda ind, h, l, c, v: ind.batch(h, l, c, v)),
|
||||
"VolumePriceTrend": (
|
||||
lambda: ta.VolumePriceTrend(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"ChaikinMoneyFlow": (
|
||||
lambda: ta.ChaikinMoneyFlow(20),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"ChaikinOscillator": (
|
||||
lambda: ta.ChaikinOscillator(3, 10),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"ForceIndex": (
|
||||
lambda: ta.ForceIndex(13),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"EaseOfMovement": (
|
||||
lambda: ta.EaseOfMovement(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
),
|
||||
"AtrTrailingStop": (
|
||||
lambda: ta.AtrTrailingStop(14, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TypicalPrice": (
|
||||
lambda: ta.TypicalPrice(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"MedianPrice": (
|
||||
lambda: ta.MedianPrice(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
"WeightedClose": (
|
||||
lambda: ta.WeightedClose(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"AcceleratorOscillator": (
|
||||
lambda: ta.AcceleratorOscillator(5, 34, 5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
"BalanceOfPower": (
|
||||
# The streaming 6-tuple feeds open == close, so batch matches with
|
||||
# the close column standing in for open.
|
||||
lambda: ta.BalanceOfPower(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ChoppinessIndex": (
|
||||
lambda: ta.ChoppinessIndex(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TrueRange": (
|
||||
lambda: ta.TrueRange(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"ChaikinVolatility": (
|
||||
lambda: ta.ChaikinVolatility(10, 10),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CANDLE_SCALAR))
|
||||
def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
high, low, close, volume = ohlcv
|
||||
make, batch_call = CANDLE_SCALAR[name]
|
||||
|
||||
batch = batch_call(make(), high, low, close, volume)
|
||||
assert batch.shape == close.shape
|
||||
|
||||
streamer = make()
|
||||
streamed = []
|
||||
for i in range(close.size):
|
||||
candle = (
|
||||
float(close[i]),
|
||||
float(high[i]),
|
||||
float(low[i]),
|
||||
float(close[i]),
|
||||
float(volume[i]),
|
||||
i,
|
||||
)
|
||||
v = streamer.update(candle)
|
||||
streamed.append(math.nan if v is None else float(v))
|
||||
assert _eq_nan(batch, np.array(streamed, dtype=np.float64)), f"{name} mismatch"
|
||||
|
||||
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"SuperTrend": (
|
||||
lambda: ta.SuperTrend(10, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"ChandelierExit": (
|
||||
lambda: ta.ChandelierExit(22, 3.0),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"ChandeKrollStop": (
|
||||
lambda: ta.ChandeKrollStop(10, 1.0, 9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(MULTI))
|
||||
def test_multi_streaming_matches_batch(name, ohlcv):
|
||||
high, low, close, volume = ohlcv
|
||||
make, batch_call = MULTI[name]
|
||||
|
||||
batch = batch_call(make(), high, low, close, volume)
|
||||
assert batch.shape == (close.size, 2)
|
||||
|
||||
streamer = make()
|
||||
rows = []
|
||||
for i in range(close.size):
|
||||
candle = (
|
||||
float(close[i]),
|
||||
float(high[i]),
|
||||
float(low[i]),
|
||||
float(close[i]),
|
||||
float(volume[i]),
|
||||
i,
|
||||
)
|
||||
v = streamer.update(candle)
|
||||
rows.append([math.nan, math.nan] if v is None else list(v))
|
||||
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
|
||||
|
||||
|
||||
# --- Reference values -----------------------------------------------------
|
||||
|
||||
|
||||
def test_typical_price_reference():
|
||||
# (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
|
||||
assert ta.TypicalPrice().update((9.0, 12.0, 6.0, 9.0, 1.0, 0)) == pytest.approx(9.0)
|
||||
|
||||
|
||||
def test_median_price_reference():
|
||||
# (high + low) / 2 = (12 + 8) / 2 = 10.
|
||||
assert ta.MedianPrice().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_weighted_close_reference():
|
||||
# (high + low + 2*close) / 4 = (12 + 8 + 22) / 4 = 10.5.
|
||||
assert ta.WeightedClose().update((10.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(
|
||||
10.5
|
||||
)
|
||||
|
||||
|
||||
def test_chaikin_money_flow_reference():
|
||||
cmf = ta.ChaikinMoneyFlow(2)
|
||||
assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None
|
||||
assert cmf.update((10.0, 12.0, 8.0, 10.0, 100.0, 1)) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_linear_regression_reference():
|
||||
out = ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_linreg_slope_reference():
|
||||
out = ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(4.0)
|
||||
|
||||
|
||||
def test_balance_of_power_reference():
|
||||
# (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
||||
bop = ta.BalanceOfPower()
|
||||
assert bop.update((10.0, 14.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_true_range_reference():
|
||||
tr = ta.TrueRange()
|
||||
assert tr.update((11.0, 12.0, 8.0, 11.0, 1.0, 0)) == pytest.approx(4.0)
|
||||
assert tr.update((9.5, 10.0, 9.0, 9.5, 1.0, 1)) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_linreg_angle_reference():
|
||||
# A series rising by 1 per step has slope 1, and atan(1) = 45 degrees.
|
||||
out = ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
|
||||
assert out[4] == pytest.approx(45.0)
|
||||
|
||||
|
||||
def test_z_score_reference():
|
||||
# Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1.
|
||||
out = ta.ZScore(2).batch(np.array([1.0, 3.0]))
|
||||
assert math.isnan(out[0])
|
||||
assert out[1] == pytest.approx(1.0)
|
||||
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_new_indicators_expose_lifecycle():
|
||||
instances = [make() for make, _ in CANDLE_SCALAR.values()]
|
||||
instances += [make() for make, _ in MULTI.values()]
|
||||
instances += [cls(*args) for cls, args in SCALAR]
|
||||
for ind in instances:
|
||||
assert ind.is_ready() is False
|
||||
assert ind.warmup_period() >= 1
|
||||
ind.reset()
|
||||
assert ind.is_ready() is False
|
||||
@@ -115,3 +115,23 @@ def test_obv_streaming_matches_batch(ohlc_series):
|
||||
rows.append(streamer.update((float(c), float(c), float(c), float(c), float(v), 0)))
|
||||
streamed = np.array([math.nan if x is None else x for x in rows], 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.
|
||||
high, low, close = ohlc_series
|
||||
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
|
||||
batch = ta.RollingVWAP(20).batch(high, low, close, volume)
|
||||
|
||||
streamer = ta.RollingVWAP(20)
|
||||
rows = []
|
||||
for h, l, c, v in zip(high, low, close, volume):
|
||||
rows.append(streamer.update((float(c), float(h), float(l), float(c), float(v), 0)))
|
||||
streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
assert streamer.period == 20
|
||||
assert streamer.warmup_period() == 20
|
||||
assert streamer.is_ready()
|
||||
streamer.reset()
|
||||
assert not streamer.is_ready()
|
||||
|
||||
@@ -34,6 +34,9 @@ console_error_panic_hook = { version = "0.1", optional = true }
|
||||
default = []
|
||||
panic-hook = ["dep:console_error_panic_hook"]
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release.wasm-bindgen]
|
||||
debug-js-glue = false
|
||||
demangle-name-section = true
|
||||
|
||||
@@ -43,5 +43,7 @@ for (const price of livePrices) {
|
||||
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
|
||||
```
|
||||
|
||||
An interactive demo lives in `bindings/wasm/examples/index.html`. After building
|
||||
the package serve the `bindings/wasm/` directory and open `examples/index.html`.
|
||||
An interactive demo lives in [`examples/wasm/index.html`](../../examples/wasm/index.html)
|
||||
(top-level alongside the other language examples). After building the package
|
||||
with `wasm-pack build`, serve the repository root and open
|
||||
`examples/wasm/index.html` in a browser.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
# Proper nouns that appear in indicator documentation. They are real names,
|
||||
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
|
||||
# `..` keeps clippy's built-in default identifier list in addition to these.
|
||||
doc-valid-idents = ["LeBeau", ".."]
|
||||
@@ -11,6 +11,12 @@ homepage.workspace = true
|
||||
readme.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
documentation = "https://docs.rs/wickra-core"
|
||||
|
||||
# Render the docs on docs.rs with every feature enabled so the parallel
|
||||
# (rayon-backed) batch APIs are documented.
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -21,6 +21,13 @@ pub enum Error {
|
||||
#[error("invalid candle: {message}")]
|
||||
InvalidCandle { message: &'static str },
|
||||
|
||||
/// A tick whose components do not satisfy the tick invariants (e.g. negative
|
||||
/// volume) was provided. Ticks are a different concept from candles and
|
||||
/// surface as their own variant so consumers of a tick-stream pipeline
|
||||
/// can match on a semantically-correct error instead of `InvalidCandle`.
|
||||
#[error("invalid tick: {message}")]
|
||||
InvalidTick { message: &'static str },
|
||||
|
||||
/// A multiplier or factor must be strictly positive.
|
||||
#[error("multiplier must be greater than zero")]
|
||||
NonPositiveMultiplier,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Accelerator Oscillator (Bill Williams).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::awesome_oscillator::AwesomeOscillator;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
|
||||
///
|
||||
/// ```text
|
||||
/// AO = SMA(median, fast) − SMA(median, slow) (the Awesome Oscillator)
|
||||
/// AC = AO − SMA(AO, signal)
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
|
||||
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
|
||||
/// moving average of itself. Because acceleration leads speed, `AC` tends to
|
||||
/// turn before the `AO` does. Bill Williams' classic configuration is the
|
||||
/// `(5, 34)` AO with a `5`-period signal average.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
|
||||
///
|
||||
/// let mut indicator = AcceleratorOscillator::classic();
|
||||
/// 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 AcceleratorOscillator {
|
||||
ao: AwesomeOscillator,
|
||||
signal: Sma,
|
||||
ao_fast: usize,
|
||||
ao_slow: usize,
|
||||
signal_period: usize,
|
||||
}
|
||||
|
||||
impl AcceleratorOscillator {
|
||||
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
|
||||
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
|
||||
/// AO `fast` period is not strictly below `slow`.
|
||||
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
ao_fast,
|
||||
ao_slow,
|
||||
signal_period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ao_fast, ao_slow, signal_period)`.
|
||||
pub const fn params(&self) -> (usize, usize, usize) {
|
||||
(self.ao_fast, self.ao_slow, self.signal_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AcceleratorOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ao = self.ao.update(candle)?;
|
||||
let signal = self.signal.update(ao)?;
|
||||
Some(ao - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ao.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The AO emits at candle `ao_slow`; the signal SMA then needs
|
||||
// `signal_period` AO values.
|
||||
self.ao_slow + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AcceleratorOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// A flat market gives AO = 0, so its signal average and AC are 0 too.
|
||||
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
for v in ac.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ao_and_signal() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.3, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let mut ao = AwesomeOscillator::classic();
|
||||
let mut signal = Sma::new(5).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = ac.update(*candle);
|
||||
match ao.update(*candle) {
|
||||
Some(ao_val) => match signal.update(ao_val) {
|
||||
Some(sig) => {
|
||||
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
|
||||
}
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
},
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let out = ac.batch(&candles);
|
||||
assert_eq!(ac.warmup_period(), 38);
|
||||
for (i, v) in out.iter().enumerate().take(37) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
|
||||
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
|
||||
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
ac.batch(&candles);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
assert_eq!(ac.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AcceleratorOscillator::classic();
|
||||
let mut b = AcceleratorOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Accumulation/Distribution Line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
|
||||
/// indicator.
|
||||
///
|
||||
/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
|
||||
/// where the close fell within the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1)
|
||||
/// MFV_t = MFM_t · volume_t
|
||||
/// ADL_t = ADL_{t−1} + MFV_t
|
||||
/// ```
|
||||
///
|
||||
/// A close near the high makes the multiplier near `+1` (accumulation), near
|
||||
/// the low near `−1` (distribution). The running total is unbounded and drifts
|
||||
/// with cumulative volume — what matters is its slope and its divergence from
|
||||
/// price. A bar with `high == low` contributes `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Adl};
|
||||
///
|
||||
/// let mut indicator = Adl::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 Adl {
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
/// Construct a new Accumulation/Distribution Line starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one candle has been ingested.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adl {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let mfv = if range == 0.0 {
|
||||
// A zero-range bar carries no positional information.
|
||||
0.0
|
||||
} else {
|
||||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||||
mfm * candle.volume
|
||||
};
|
||||
self.total += mfv;
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADL"
|
||||
}
|
||||
}
|
||||
|
||||
#[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, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
|
||||
// bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
|
||||
// ADL = 100 - 100 = 0.
|
||||
let mut adl = Adl::new();
|
||||
let out = adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||||
candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
|
||||
]);
|
||||
assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut adl = Adl::new();
|
||||
assert_eq!(adl.warmup_period(), 1);
|
||||
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_high_accumulates_full_volume() {
|
||||
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
|
||||
let mut adl = Adl::new();
|
||||
let mut expected = 0.0;
|
||||
for i in 0..10 {
|
||||
let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
|
||||
expected += 25.0;
|
||||
assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_contributes_nothing() {
|
||||
let mut adl = Adl::new();
|
||||
adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
|
||||
let before = adl.value().unwrap();
|
||||
// A flat candle (high == low) adds zero.
|
||||
let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
|
||||
assert_relative_eq!(after, before, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adl = Adl::new();
|
||||
adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
|
||||
candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
|
||||
]);
|
||||
assert!(adl.is_ready());
|
||||
adl.reset();
|
||||
assert!(!adl.is_ready());
|
||||
assert_eq!(adl.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Adl::new().batch(&candles);
|
||||
let mut b = Adl::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,22 @@ pub struct AdxOutput {
|
||||
/// movement / true range sums; the next `period` candles produce DX values that
|
||||
/// seed the ADX. The first complete `AdxOutput` is emitted after `2 * period`
|
||||
/// candles.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Adx};
|
||||
///
|
||||
/// let mut indicator = Adx::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());
|
||||
/// ```
|
||||
#[allow(clippy::struct_field_names)] // adx_value pairs with adx (the output line) — renaming hurts clarity
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Adx {
|
||||
|
||||
@@ -17,6 +17,22 @@ pub struct AroonOutput {
|
||||
|
||||
/// Aroon indicator: tracks how many bars since the highest high and lowest low
|
||||
/// inside a `period + 1`-bar window. Returned as a percentage.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Aroon};
|
||||
///
|
||||
/// let mut indicator = Aroon::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 Aroon {
|
||||
period: usize,
|
||||
@@ -153,4 +169,17 @@ mod tests {
|
||||
assert!((0.0..=100.0).contains(&o.down));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (1..=20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut a = Aroon::new(14).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Aroon Oscillator.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Aroon;
|
||||
|
||||
/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
|
||||
///
|
||||
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
|
||||
/// collapses them into one value in `[−100, 100]`:
|
||||
///
|
||||
/// ```text
|
||||
/// AroonOscillator = AroonUp − AroonDown
|
||||
/// ```
|
||||
///
|
||||
/// Strongly positive means the most recent high is much fresher than the most
|
||||
/// recent low (an up-trend); strongly negative is the mirror image. Readings
|
||||
/// near zero mean neither extreme is recent — a range.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AroonOscillator};
|
||||
///
|
||||
/// let mut indicator = AroonOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// 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_eq!(last, Some(100.0)); // pure uptrend
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AroonOscillator {
|
||||
aroon: Aroon,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AroonOscillator {
|
||||
/// Construct a new Aroon Oscillator with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
aroon: Aroon::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.aroon.period()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AroonOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
|
||||
self.last = Some(osc);
|
||||
Some(osc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.aroon.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.aroon.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AroonOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(AroonOscillator::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_plus_100() {
|
||||
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 + i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_yields_minus_100() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 - i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_within_minus_100_and_100() {
|
||||
let mut osc = AroonOscillator::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_matches_aroon() {
|
||||
let osc = AroonOscillator::new(7).unwrap();
|
||||
assert_eq!(osc.warmup_period(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
|
||||
.collect();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
|
||||
let mut b = AroonOscillator::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,22 @@ use crate::traits::Indicator;
|
||||
/// The first emitted value, by convention, appears after `period` candles: the
|
||||
/// first `period − 1` true-range values seed the Wilder average alongside the
|
||||
/// `period`-th, then the smoothed update begins.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Atr};
|
||||
///
|
||||
/// let mut indicator = Atr::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 Atr {
|
||||
period: usize,
|
||||
@@ -100,6 +116,36 @@ mod tests {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Independent reference: Wilder ATR computed straight from the definition.
|
||||
fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec<Option<f64>> {
|
||||
let n = period as f64;
|
||||
let mut out = Vec::with_capacity(hlc.len());
|
||||
let mut trs: Vec<f64> = Vec::new();
|
||||
let mut avg: Option<f64> = None;
|
||||
let mut prev_close: Option<f64> = None;
|
||||
for &(h, l, cl) in hlc {
|
||||
let tr = match prev_close {
|
||||
None => h - l,
|
||||
Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()),
|
||||
};
|
||||
prev_close = Some(cl);
|
||||
if let Some(a) = avg {
|
||||
let na = (a * (n - 1.0) + tr) / n;
|
||||
avg = Some(na);
|
||||
out.push(Some(na));
|
||||
} else {
|
||||
trs.push(tr);
|
||||
if trs.len() == period {
|
||||
avg = Some(trs.iter().sum::<f64>() / n);
|
||||
out.push(avg);
|
||||
} else {
|
||||
out.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
|
||||
@@ -187,4 +233,37 @@ mod tests {
|
||||
assert!(v >= 0.0, "ATR must be non-negative: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
fn atr_matches_naive(
|
||||
period in 1usize..15,
|
||||
bars in proptest::collection::vec(
|
||||
(10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0),
|
||||
0..120,
|
||||
),
|
||||
) {
|
||||
// bars: (low, range, close_fraction) -> a valid OHLC candle.
|
||||
let hlc: Vec<(f64, f64, f64)> = bars
|
||||
.iter()
|
||||
.map(|&(low, range, frac)| (low + range, low, low + range * frac))
|
||||
.collect();
|
||||
let candles: Vec<Candle> = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect();
|
||||
let mut atr = Atr::new(period).unwrap();
|
||||
let got = atr.batch(&candles);
|
||||
let want = atr_naive(&hlc, period);
|
||||
proptest::prop_assert_eq!(got.len(), want.len());
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => proptest::prop_assert!(
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
|
||||
"got={a} want={b}"
|
||||
),
|
||||
_ => proptest::prop_assert!(false, "warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! ATR Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
|
||||
/// and ratchets in the direction of the trend.
|
||||
///
|
||||
/// ```text
|
||||
/// loss = multiplier · ATR
|
||||
///
|
||||
/// stop_t = max(stop_{t−1}, close − loss) while price holds above the stop
|
||||
/// = min(stop_{t−1}, close + loss) while price holds below the stop
|
||||
/// = close − loss on a fresh break above the stop
|
||||
/// = close + loss on a fresh break below the stop
|
||||
/// ```
|
||||
///
|
||||
/// While price stays on one side of the stop the level only ratchets toward
|
||||
/// price — up in an uptrend, down in a downtrend — never away from it. When a
|
||||
/// close crosses the stop the level snaps to the opposite side, `loss` away
|
||||
/// from the new close, flipping the trade. This is the trailing stop used by
|
||||
/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
|
||||
/// price (a long).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
|
||||
///
|
||||
/// let mut indicator = AtrTrailingStop::new(14, 3.0).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 AtrTrailingStop {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
atr_period: usize,
|
||||
prev_close: Option<f64>,
|
||||
prev_stop: Option<f64>,
|
||||
}
|
||||
|
||||
impl AtrTrailingStop {
|
||||
/// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
multiplier,
|
||||
atr_period,
|
||||
prev_close: None,
|
||||
prev_stop: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: `ATR(14)` with a `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrTrailingStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let loss = self.multiplier * atr;
|
||||
let close = candle.close;
|
||||
|
||||
let stop = match (self.prev_stop, self.prev_close) {
|
||||
(Some(prev_stop), Some(prev_close)) => {
|
||||
if close > prev_stop && prev_close > prev_stop {
|
||||
// Holding above the stop — ratchet it up only.
|
||||
(close - loss).max(prev_stop)
|
||||
} else if close < prev_stop && prev_close < prev_stop {
|
||||
// Holding below the stop — ratchet it down only.
|
||||
(close + loss).min(prev_stop)
|
||||
} else if close > prev_stop {
|
||||
// Fresh break above — place the stop below the new close.
|
||||
close - loss
|
||||
} else {
|
||||
// Fresh break below — place the stop above the new close.
|
||||
close + loss
|
||||
}
|
||||
}
|
||||
// First ATR-ready bar: seed the stop below price (a long).
|
||||
_ => close - loss,
|
||||
};
|
||||
|
||||
self.prev_close = Some(close);
|
||||
self.prev_stop = Some(stop);
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.prev_close = None;
|
||||
self.prev_stop = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev_stop.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
|
||||
// Seed stop = close - loss = 10 - 6 = 4, and it holds there.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
|
||||
for v in ts.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_stop_ratchets_up_and_stays_below_price() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let emitted: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
for w in emitted.windows(2) {
|
||||
assert!(
|
||||
w[1].0 >= w[0].0 - 1e-9,
|
||||
"stop must not loosen in an uptrend"
|
||||
);
|
||||
}
|
||||
for &(stop, close) in &emitted {
|
||||
assert!(stop < close, "uptrend stop should sit below the close");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_flips_to_the_other_side_when_price_reverses() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
// A steep decline drags price through the trailing stop.
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - 3.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base, 40 + i)
|
||||
}));
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let paired: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop < close),
|
||||
"expected a long stretch with the stop below price"
|
||||
);
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop > close),
|
||||
"expected the stop to flip above price after the reversal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
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();
|
||||
let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
|
||||
let out = ts.batch(&candles);
|
||||
assert_eq!(ts.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AtrTrailingStop::new(0, 3.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, 0.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, -1.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::classic();
|
||||
ts.batch(&candles);
|
||||
assert!(ts.is_ready());
|
||||
ts.reset();
|
||||
assert!(!ts.is_ready());
|
||||
assert_eq!(ts.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 = AtrTrailingStop::classic();
|
||||
let mut b = AtrTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,22 @@ use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Awesome Oscillator: `SMA(median_price, 5) - SMA(median_price, 34)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AwesomeOscillator};
|
||||
///
|
||||
/// let mut indicator = AwesomeOscillator::new(3, 10).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 AwesomeOscillator {
|
||||
fast: Sma,
|
||||
@@ -114,4 +130,17 @@ mod tests {
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut ao = AwesomeOscillator::classic();
|
||||
ao.batch(&candles);
|
||||
assert!(ao.is_ready());
|
||||
ao.reset();
|
||||
assert!(!ao.is_ready());
|
||||
assert_eq!(ao.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Balance of Power.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Balance of Power — where the close settled within the bar's range relative
|
||||
/// to the open.
|
||||
///
|
||||
/// ```text
|
||||
/// BOP = (close − open) / (high − low)
|
||||
/// ```
|
||||
///
|
||||
/// The result lives in `[−1, +1]`: `+1` is a bar that opened on its low and
|
||||
/// closed on its high (buyers in full control), `−1` the mirror image. It is
|
||||
/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
|
||||
/// zero-range bar carries no information and yields `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BalanceOfPower};
|
||||
///
|
||||
/// let mut indicator = BalanceOfPower::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 BalanceOfPower {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl BalanceOfPower {
|
||||
/// Construct a new Balance of Power transform.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BalanceOfPower {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
let bop = if range == 0.0 {
|
||||
// A zero-range bar carries no directional information.
|
||||
0.0
|
||||
} else {
|
||||
(candle.close - candle.open) / range
|
||||
};
|
||||
Some(bop)
|
||||
}
|
||||
|
||||
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 {
|
||||
"BalanceOfPower"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 reference_value() {
|
||||
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
||||
let mut bop = BalanceOfPower::new();
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
|
||||
0.5,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_high_after_open_on_low_is_plus_one() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
// open == low, close == high -> BOP = +1.
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
|
||||
1.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_unit_range() {
|
||||
let candles: Vec<Candle> = (0..100)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
|
||||
let close = mid + (i as f64 * 0.5).cos() * 2.0;
|
||||
candle(mid, mid + 3.0, mid - 3.0, close, i)
|
||||
})
|
||||
.collect();
|
||||
let mut bop = BalanceOfPower::new();
|
||||
for v in bop.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_yields_zero() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
assert_eq!(bop.warmup_period(), 1);
|
||||
assert!(!bop.is_ready());
|
||||
assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
|
||||
assert!(bop.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(bop.is_ready());
|
||||
bop.reset();
|
||||
assert!(!bop.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = BalanceOfPower::new();
|
||||
let mut b = BalanceOfPower::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,27 @@ pub struct BollingerOutput {
|
||||
/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
|
||||
/// publication uses population (not sample) standard deviation, which matches every
|
||||
/// reference implementation (TA-Lib, pandas-ta, etc.).
|
||||
///
|
||||
/// The running `sum` and `sum_sq` are reseeded from the live window every
|
||||
/// `16 · period` updates to cap floating-point drift on long streams. This is
|
||||
/// amortised O(1), preserves bit-equivalence with the previous behaviour on
|
||||
/// inputs that did not drift, and is particularly important for `sum_sq`,
|
||||
/// where catastrophic cancellation between large add/subtract pairs can drive
|
||||
/// the computed variance negative (the `.max(0.0)` clamp below is the
|
||||
/// safety-net for the rare cases where the reseed has not happened yet).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BollingerBands};
|
||||
///
|
||||
/// let mut indicator = BollingerBands::new(5, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBands {
|
||||
period: usize,
|
||||
@@ -31,8 +52,17 @@ pub struct BollingerBands {
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
/// Number of finite updates since the running sums were last reseeded
|
||||
/// from the live window. See [`RECOMPUTE_EVERY`] below.
|
||||
updates_since_recompute: usize,
|
||||
}
|
||||
|
||||
/// How often (in finite updates) the incremental `sum` / `sum_sq` are reseeded
|
||||
/// from the live window. The multiplier `16` keeps the amortised cost flat and
|
||||
/// caps any cancellation drift to roughly `16 · period · ULP · max(|x|²)` —
|
||||
/// negligible on real-world price scales.
|
||||
const RECOMPUTE_EVERY: usize = 16;
|
||||
|
||||
impl BollingerBands {
|
||||
/// Construct a new Bollinger Bands indicator.
|
||||
///
|
||||
@@ -53,6 +83,7 @@ impl BollingerBands {
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
updates_since_recompute: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +137,12 @@ impl Indicator for BollingerBands {
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
|
||||
self.sum = self.window.iter().copied().sum();
|
||||
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
self.current()
|
||||
}
|
||||
|
||||
@@ -113,6 +150,7 @@ impl Indicator for BollingerBands {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
@@ -240,4 +278,60 @@ mod tests {
|
||||
bb.reset();
|
||||
assert!(!bb.is_ready());
|
||||
}
|
||||
|
||||
/// Long-running stability check. After several recompute cycles the
|
||||
/// reported Bollinger bands must still equal a fresh from-scratch
|
||||
/// computation over the live window — even on inputs designed to cause
|
||||
/// catastrophic cancellation in the `sum_sq` accumulator (alternating
|
||||
/// between two very different magnitudes).
|
||||
#[test]
|
||||
fn long_stream_drift_stays_bounded() {
|
||||
let period = 20;
|
||||
let mult = 2.0;
|
||||
let mut bb = BollingerBands::new(period, mult).unwrap();
|
||||
let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
|
||||
// Forces the periodic reseed to fire 5+ times.
|
||||
let n_updates = 16 * period * 5;
|
||||
let mut last = None;
|
||||
for i in 0..n_updates {
|
||||
let v = if i.is_multiple_of(2) { 1e6 } else { 1.0 };
|
||||
last = bb.update(v);
|
||||
if window.len() == period {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(v);
|
||||
}
|
||||
let scratch =
|
||||
naive(&window.iter().copied().collect::<Vec<_>>(), period, mult).expect("warmed up");
|
||||
let got = last.expect("warmed up");
|
||||
assert!(
|
||||
(got.middle - scratch.middle).abs() < 1e-3,
|
||||
"middle drift: got={}, scratch={}",
|
||||
got.middle,
|
||||
scratch.middle,
|
||||
);
|
||||
assert!(
|
||||
(got.stddev - scratch.stddev).abs() < 1e-3,
|
||||
"stddev drift: got={}, scratch={}",
|
||||
got.stddev,
|
||||
scratch.stddev,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||
let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = ready.last().unwrap().unwrap();
|
||||
// Non-finite inputs return the current bands without mutating the window.
|
||||
assert_eq!(bb.update(f64::NAN).unwrap(), last);
|
||||
assert_eq!(bb.update(f64::INFINITY).unwrap(), last);
|
||||
// The window still holds 1..=5, so a real input slides it to 2..=6.
|
||||
let after = bb.update(6.0).unwrap();
|
||||
assert_relative_eq!(
|
||||
after.middle,
|
||||
(2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Bollinger Bandwidth.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::BollingerBands;
|
||||
|
||||
/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the
|
||||
/// middle band.
|
||||
///
|
||||
/// ```text
|
||||
/// Bandwidth = (upper − lower) / middle
|
||||
/// ```
|
||||
///
|
||||
/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is
|
||||
/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its
|
||||
/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a
|
||||
/// multi-month low, signalling a coiled, low-volatility market about to
|
||||
/// expand) and the **bulge** (bandwidth at an extreme high).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BollingerBandwidth};
|
||||
///
|
||||
/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBandwidth {
|
||||
bands: BollingerBands,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BollingerBandwidth {
|
||||
/// Construct a new Bollinger Bandwidth indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
|
||||
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: BollingerBands::new(period, multiplier)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.bands.multiplier()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BollingerBandwidth {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let o = self.bands.update(input)?;
|
||||
let bandwidth = if o.middle == 0.0 {
|
||||
// Undefined against a zero middle band.
|
||||
0.0
|
||||
} else {
|
||||
(o.upper - o.lower) / o.middle
|
||||
};
|
||||
self.last = Some(bandwidth);
|
||||
Some(bandwidth)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BollingerBandwidth"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_parameters() {
|
||||
assert!(BollingerBandwidth::new(0, 2.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, 0.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Flat prices: the bands collapse onto the middle, so width is 0.
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
let out = bbw.batch(&[100.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_bands_definition() {
|
||||
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (w, b) in bbw_out.iter().zip(bands_out.iter()) {
|
||||
match (w, b) {
|
||||
(Some(wv), Some(bv)) => {
|
||||
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
|
||||
.collect();
|
||||
for v in bbw.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bbw.is_ready());
|
||||
bbw.reset();
|
||||
assert!(!bbw.is_ready());
|
||||
assert_eq!(bbw.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,22 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
|
||||
/// `TP = (high + low + close) / 3`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Cci};
|
||||
///
|
||||
/// let mut indicator = Cci::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 Cci {
|
||||
period: usize,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Chaikin Oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::adl::Adl;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line.
|
||||
///
|
||||
/// ```text
|
||||
/// ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t
|
||||
/// ```
|
||||
///
|
||||
/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a
|
||||
/// zero-centred momentum oscillator: positive when short-term accumulation
|
||||
/// outpaces the longer trend, negative when distribution leads. Because the
|
||||
/// ADL emits from the very first candle, the slow EMA gates the first output —
|
||||
/// the warmup period is exactly `slow`. Chaikin's classic configuration is
|
||||
/// `fast = 3`, `slow = 10`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinOscillator};
|
||||
///
|
||||
/// let mut indicator = ChaikinOscillator::classic();
|
||||
/// 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 ChaikinOscillator {
|
||||
adl: Adl,
|
||||
fast: Ema,
|
||||
slow: Ema,
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
}
|
||||
|
||||
impl ChaikinOscillator {
|
||||
/// Construct a Chaikin Oscillator with explicit fast / slow EMA periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either period is zero, or
|
||||
/// [`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: "Chaikin Oscillator needs fast < slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
adl: Adl::new(),
|
||||
fast: Ema::new(fast)?,
|
||||
slow: Ema::new(slow)?,
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
})
|
||||
}
|
||||
|
||||
/// Chaikin's classic configuration: `EMA(ADL, 3) − EMA(ADL, 10)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(3, 10).expect("classic Chaikin Oscillator params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast, slow)` periods.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.fast_period, self.slow_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
// The ADL emits a value from the very first candle, so both EMAs are
|
||||
// fed on every bar and warm up in parallel.
|
||||
let adl = self.adl.update(candle)?;
|
||||
let fast = self.fast.update(adl);
|
||||
let slow = self.slow.update(adl);
|
||||
Some(fast? - slow?)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.adl.reset();
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ADL is ready at candle 1; the slow EMA gates the first emission.
|
||||
self.slow_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.fast.is_ready() && self.slow.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChaikinOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
fn flat(price: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price, price, price, 100.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_adl_and_emas() {
|
||||
// The oscillator must equal feeding a standalone ADL into two
|
||||
// standalone EMAs and differencing them once both are ready.
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
|
||||
Candle::new(
|
||||
mid,
|
||||
mid + 1.5,
|
||||
mid - 1.5,
|
||||
mid + 0.3,
|
||||
10.0 + (i % 6) as f64,
|
||||
i,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
let mut adl = Adl::new();
|
||||
let mut fast = Ema::new(3).unwrap();
|
||||
let mut slow = Ema::new(10).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = osc.update(*candle);
|
||||
let a = adl.update(*candle).expect("ADL emits from candle 1");
|
||||
let f = fast.update(a);
|
||||
let s = slow.update(a);
|
||||
match (f, s) {
|
||||
(Some(fv), Some(sv)) => {
|
||||
assert_relative_eq!(
|
||||
got.expect("oscillator ready once slow EMA is"),
|
||||
fv - sv,
|
||||
epsilon = 1e-9
|
||||
);
|
||||
}
|
||||
_ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_yields_zero() {
|
||||
// A flat candle has zero money-flow volume, so the ADL never moves and
|
||||
// both EMAs of a constant-zero series stay at zero.
|
||||
let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
let out = osc.batch(&candles);
|
||||
assert_eq!(osc.warmup_period(), 10);
|
||||
for (i, v) in out.iter().enumerate().take(9) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChaikinOscillator::new(0, 10).is_err());
|
||||
assert!(ChaikinOscillator::new(3, 0).is_err());
|
||||
assert!(ChaikinOscillator::new(10, 3).is_err());
|
||||
assert!(ChaikinOscillator::new(5, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.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;
|
||||
Candle::new(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinOscillator::classic();
|
||||
let mut b = ChaikinOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Chaikin Volatility.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::roc::Roc;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Volatility — the rate of change of a smoothed high-low spread.
|
||||
///
|
||||
/// ```text
|
||||
/// spread_t = high_t − low_t
|
||||
/// smoothed_t = EMA(spread, ema_period)_t
|
||||
/// ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period}
|
||||
/// ```
|
||||
///
|
||||
/// Marc Chaikin's volatility measure tracks not the *level* of the trading
|
||||
/// range but how fast it is *widening or narrowing*. A rising value means
|
||||
/// ranges are expanding (often near a top, as fear spikes); a falling value
|
||||
/// means they are contracting (often a quiet, complacent market). The classic
|
||||
/// configuration smooths the spread with a `10`-period EMA and takes its
|
||||
/// `10`-period rate of change.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinVolatility};
|
||||
///
|
||||
/// let mut indicator = ChaikinVolatility::new(10, 10).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 ChaikinVolatility {
|
||||
ema: Ema,
|
||||
roc: Roc,
|
||||
ema_period: usize,
|
||||
roc_period: usize,
|
||||
}
|
||||
|
||||
impl ChaikinVolatility {
|
||||
/// Construct a Chaikin Volatility with explicit EMA and rate-of-change
|
||||
/// periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
|
||||
/// is zero.
|
||||
pub fn new(ema_period: usize, roc_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ema: Ema::new(ema_period)?,
|
||||
roc: Roc::new(roc_period)?,
|
||||
ema_period,
|
||||
roc_period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 10).expect("classic Chaikin Volatility params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ema_period, roc_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.ema_period, self.roc_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinVolatility {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let spread = candle.high - candle.low;
|
||||
let smoothed = self.ema.update(spread)?;
|
||||
self.roc.update(smoothed)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
self.roc.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The EMA emits at candle `ema_period`; the ROC then needs
|
||||
// `roc_period` more smoothed values to span its lookback.
|
||||
self.ema_period + self.roc_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.roc.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChaikinVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_range_yields_zero() {
|
||||
// A constant high-low spread smooths to a constant EMA, whose rate of
|
||||
// change is zero.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
for v in cv.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_range_reads_positive() {
|
||||
// Each bar's range is strictly wider than the last -> expanding
|
||||
// volatility -> positive Chaikin Volatility.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let half = 1.0 + i as f64 * 0.1;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
for v in cv.batch(&candles).into_iter().flatten() {
|
||||
assert!(v > 0.0, "an expanding range should read positive, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ema_and_roc() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
let mut ema = Ema::new(10).unwrap();
|
||||
let mut roc = Roc::new(10).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = cv.update(*candle);
|
||||
match ema.update(candle.high - candle.low) {
|
||||
Some(e) => {
|
||||
let want = roc.update(e);
|
||||
assert_eq!(got, want, "i={i}");
|
||||
}
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(5, 5).unwrap();
|
||||
let out = cv.batch(&candles);
|
||||
assert_eq!(cv.warmup_period(), 10);
|
||||
for (i, v) in out.iter().enumerate().take(9) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ChaikinVolatility::new(0, 10).is_err());
|
||||
assert!(ChaikinVolatility::new(10, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::classic();
|
||||
cv.batch(&candles);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinVolatility::classic();
|
||||
let mut b = ChaikinVolatility::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Chande Kroll Stop.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chande Kroll Stop output: the long-side and short-side stop levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChandeKrollStopOutput {
|
||||
/// Long-position stop — the lowest preliminary low-stop over `stop_period`.
|
||||
pub stop_long: f64,
|
||||
/// Short-position stop — the highest preliminary high-stop over `stop_period`.
|
||||
pub stop_short: f64,
|
||||
}
|
||||
|
||||
/// Chande Kroll Stop — Tushar Chande and Stanley Kroll's two-stage ATR stop.
|
||||
///
|
||||
/// ```text
|
||||
/// preliminary (window p = atr_period, x = atr_multiplier):
|
||||
/// high_stop = highest_high(p) − x · ATR(p)
|
||||
/// low_stop = lowest_low(p) + x · ATR(p)
|
||||
///
|
||||
/// final (window q = stop_period):
|
||||
/// stop_short = highest(high_stop, q)
|
||||
/// stop_long = lowest(low_stop, q)
|
||||
/// ```
|
||||
///
|
||||
/// The first stage builds an ATR stop off the recent extreme, exactly like a
|
||||
/// [`ChandelierExit`](crate::ChandelierExit); the second stage smooths it by
|
||||
/// taking the most extreme preliminary stop over a shorter window, which keeps
|
||||
/// the stop from whipsawing on a single wide bar. The classic configuration
|
||||
/// from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, smoothing
|
||||
/// window `9`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChandeKrollStop};
|
||||
///
|
||||
/// let mut indicator = ChandeKrollStop::new(10, 1.0, 9).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 ChandeKrollStop {
|
||||
atr_period: usize,
|
||||
atr_multiplier: f64,
|
||||
stop_period: usize,
|
||||
atr: Atr,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
high_stops: VecDeque<f64>,
|
||||
low_stops: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChandeKrollStop {
|
||||
/// Construct a Chande Kroll Stop with explicit ATR and smoothing windows.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period` or `stop_period` is zero,
|
||||
/// and [`Error::NonPositiveMultiplier`] if `atr_multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> Result<Self> {
|
||||
if !atr_multiplier.is_finite() || atr_multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
if stop_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
atr_period,
|
||||
atr_multiplier,
|
||||
stop_period,
|
||||
atr: Atr::new(atr_period)?,
|
||||
highs: VecDeque::with_capacity(atr_period),
|
||||
lows: VecDeque::with_capacity(atr_period),
|
||||
high_stops: VecDeque::with_capacity(stop_period),
|
||||
low_stops: VecDeque::with_capacity(stop_period),
|
||||
})
|
||||
}
|
||||
|
||||
/// The classic configuration: `ATR(10)`, multiplier `1.0`, window `9`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 1.0, 9).expect("classic Chande Kroll Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, atr_multiplier, stop_period)`.
|
||||
pub const fn params(&self) -> (usize, f64, usize) {
|
||||
(self.atr_period, self.atr_multiplier, self.stop_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChandeKrollStop {
|
||||
type Input = Candle;
|
||||
type Output = ChandeKrollStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ChandeKrollStopOutput> {
|
||||
let atr = self.atr.update(candle);
|
||||
if self.highs.len() == self.atr_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.atr_period {
|
||||
return None;
|
||||
}
|
||||
// ATR(atr_period) becomes ready on exactly the candle that fills the
|
||||
// preliminary window, so this never discards a value.
|
||||
let atr = atr?;
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let high_stop = highest - self.atr_multiplier * atr;
|
||||
let low_stop = lowest + self.atr_multiplier * atr;
|
||||
|
||||
if self.high_stops.len() == self.stop_period {
|
||||
self.high_stops.pop_front();
|
||||
self.low_stops.pop_front();
|
||||
}
|
||||
self.high_stops.push_back(high_stop);
|
||||
self.low_stops.push_back(low_stop);
|
||||
if self.high_stops.len() < self.stop_period {
|
||||
return None;
|
||||
}
|
||||
let stop_short = self
|
||||
.high_stops
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let stop_long = self.low_stops.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Some(ChandeKrollStopOutput {
|
||||
stop_long,
|
||||
stop_short,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
self.high_stops.clear();
|
||||
self.low_stops.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The preliminary stop first appears on candle `atr_period`; the
|
||||
// smoothing window then needs `stop_period` of them.
|
||||
self.atr_period + self.stop_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.high_stops.len() == self.stop_period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChandeKrollStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
|
||||
// high_stop = 11 - 1·2 = 9; low_stop = 9 + 1·2 = 11.
|
||||
// stop_short = highest(high_stop, q) = 9; stop_long = lowest(low_stop, q) = 11.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut cks = ChandeKrollStop::new(5, 1.0, 3).unwrap();
|
||||
let last = cks.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.stop_short, 9.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.stop_long, 11.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..16)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cks = ChandeKrollStop::new(4, 1.0, 3).unwrap();
|
||||
let out = cks.batch(&candles);
|
||||
assert_eq!(cks.warmup_period(), 6);
|
||||
for (i, v) in out.iter().enumerate().take(5) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[5].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChandeKrollStop::new(0, 1.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, 1.0, 0).is_err());
|
||||
assert!(ChandeKrollStop::new(10, 0.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, -1.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cks = ChandeKrollStop::classic();
|
||||
cks.batch(&candles);
|
||||
assert!(cks.is_ready());
|
||||
cks.reset();
|
||||
assert!(!cks.is_ready());
|
||||
assert_eq!(cks.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 = ChandeKrollStop::classic();
|
||||
let mut b = ChandeKrollStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Chandelier Exit.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chandelier Exit output: the long-side and short-side trailing stops.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChandelierExitOutput {
|
||||
/// Long-position stop: `highest_high − multiplier · ATR`.
|
||||
pub long_stop: f64,
|
||||
/// Short-position stop: `lowest_low + multiplier · ATR`.
|
||||
pub short_stop: f64,
|
||||
}
|
||||
|
||||
/// Chandelier Exit — Chuck LeBeau's ATR trailing stop, hung from the highest
|
||||
/// high (for longs) or the lowest low (for shorts) of the lookback window.
|
||||
///
|
||||
/// ```text
|
||||
/// long_stop = highest_high(period) − multiplier · ATR(period)
|
||||
/// short_stop = lowest_low(period) + multiplier · ATR(period)
|
||||
/// ```
|
||||
///
|
||||
/// A long position is exited when price closes below `long_stop`; a short
|
||||
/// when it closes above `short_stop`. Because the stop hangs a fixed number
|
||||
/// of ATRs off the extreme of the window — like a chandelier off a ceiling —
|
||||
/// it follows price up but never loosens. LeBeau's classic configuration is a
|
||||
/// `22`-bar window with a `3.0` multiplier.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChandelierExit};
|
||||
///
|
||||
/// let mut indicator = ChandelierExit::new(22, 3.0).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 ChandelierExit {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
atr: Atr,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChandelierExit {
|
||||
/// Construct a Chandelier Exit with an explicit window and band multiplier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
atr: Atr::new(period)?,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// LeBeau's classic configuration: a `22`-bar window, `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(22, 3.0).expect("classic Chandelier Exit params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChandelierExit {
|
||||
type Input = Candle;
|
||||
type Output = ChandelierExitOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ChandelierExitOutput> {
|
||||
let atr = self.atr.update(candle);
|
||||
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;
|
||||
}
|
||||
// ATR(period) becomes ready on exactly the candle that fills the
|
||||
// highest-high / lowest-low window, so this never discards a value.
|
||||
let atr = atr?;
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Some(ChandelierExitOutput {
|
||||
long_stop: highest - self.multiplier * atr,
|
||||
short_stop: lowest + self.multiplier * atr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
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 {
|
||||
"ChandelierExit"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
|
||||
// long_stop = 11 - 3·2 = 5; short_stop = 9 + 3·2 = 15.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ce = ChandelierExit::new(5, 3.0).unwrap();
|
||||
let last = ce.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.long_stop, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.short_stop, 15.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_below_highest_short_stop_above_lowest() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 9.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.4, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ce = ChandelierExit::classic();
|
||||
for (i, o) in ce.batch(&candles).into_iter().enumerate() {
|
||||
if let Some(o) = o {
|
||||
// The window's extremes bound the stops from one side.
|
||||
let win = &candles[i + 1 - 22..=i];
|
||||
let hh = win.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max);
|
||||
let ll = win.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
|
||||
assert!(o.long_stop <= hh + 1e-9);
|
||||
assert!(o.short_stop >= ll - 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
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();
|
||||
let mut ce = ChandelierExit::new(8, 3.0).unwrap();
|
||||
let out = ce.batch(&candles);
|
||||
assert_eq!(ce.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChandelierExit::new(0, 3.0).is_err());
|
||||
assert!(ChandelierExit::new(22, 0.0).is_err());
|
||||
assert!(ChandelierExit::new(22, -1.0).is_err());
|
||||
assert!(ChandelierExit::new(22, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ce = ChandelierExit::classic();
|
||||
ce.batch(&candles);
|
||||
assert!(ce.is_ready());
|
||||
ce.reset();
|
||||
assert!(!ce.is_ready());
|
||||
assert_eq!(ce.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 = ChandelierExit::classic();
|
||||
let mut b = ChandelierExit::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Choppiness Index.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Choppiness Index — is the market trending or just chopping sideways?
|
||||
///
|
||||
/// ```text
|
||||
/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n)
|
||||
/// ```
|
||||
///
|
||||
/// The ratio compares the *distance price actually travelled* (the summed true
|
||||
/// range) with the *net ground it covered* (the high-low span of the window).
|
||||
/// A clean trend travels almost exactly its span, so the ratio is near `1` and
|
||||
/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
|
||||
/// ratio is large and `CI` climbs toward `100`. The conventional reading is
|
||||
/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
|
||||
/// `100` by convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
|
||||
///
|
||||
/// let mut indicator = ChoppinessIndex::new(14).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 ChoppinessIndex {
|
||||
period: usize,
|
||||
log_n: f64,
|
||||
prev_close: Option<f64>,
|
||||
tr_window: VecDeque<f64>,
|
||||
tr_sum: f64,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChoppinessIndex {
|
||||
/// Construct a new Choppiness Index over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
|
||||
/// denominator is zero for `period == 1` and undefined for `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "choppiness index needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
log_n: (period as f64).log10(),
|
||||
prev_close: None,
|
||||
tr_window: VecDeque::with_capacity(period),
|
||||
tr_sum: 0.0,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChoppinessIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tr = candle.true_range(self.prev_close);
|
||||
self.prev_close = Some(candle.close);
|
||||
|
||||
if self.tr_window.len() == self.period {
|
||||
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.tr_window.push_back(tr);
|
||||
self.tr_sum += tr;
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
|
||||
if self.tr_window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let span = highest - lowest;
|
||||
if span == 0.0 {
|
||||
// A perfectly flat window: maximal choppiness by convention.
|
||||
return Some(100.0);
|
||||
}
|
||||
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.tr_window.clear();
|
||||
self.tr_sum = 0.0;
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.tr_window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChoppinessIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_equal_range_bars() {
|
||||
// Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
|
||||
// CI = 100 · log10(4 / 2) / log10(2) = 100.
|
||||
let mut ci = ChoppinessIndex::new(2).unwrap();
|
||||
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_yields_hundred() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
for v in ci.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_trend_reads_low() {
|
||||
// A clean one-directional march travels close to its span -> low CI.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
for v in ci.batch(&candles).into_iter().flatten() {
|
||||
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
|
||||
assert!(v >= 0.0, "CI must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ci = ChoppinessIndex::new(8).unwrap();
|
||||
let out = ci.batch(&candles);
|
||||
assert_eq!(ci.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(ChoppinessIndex::new(0).is_err());
|
||||
assert!(ChoppinessIndex::new(1).is_err());
|
||||
assert!(ChoppinessIndex::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
ci.batch(&candles);
|
||||
assert!(ci.is_ready());
|
||||
ci.reset();
|
||||
assert!(!ci.is_ready());
|
||||
assert_eq!(ci.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 = ChoppinessIndex::new(14).unwrap();
|
||||
let mut b = ChoppinessIndex::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Chaikin Money Flow (CMF).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator.
|
||||
///
|
||||
/// Each bar produces a *money-flow volume*: the bar's volume weighted by where
|
||||
/// the close fell within its range (the same money-flow multiplier the
|
||||
/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to
|
||||
/// summed volume over the lookback window:
|
||||
///
|
||||
/// ```text
|
||||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (−1..+1)
|
||||
/// MFV_t = MFM_t · volume_t
|
||||
/// CMF_t = Σ(MFV, period) / Σ(volume, period)
|
||||
/// ```
|
||||
///
|
||||
/// The result lives in `[−1, +1]`: sustained closes near the high push CMF
|
||||
/// toward `+1` (accumulation), near the low toward `−1` (distribution). A bar
|
||||
/// with `high == low` carries no positional information and contributes a
|
||||
/// money-flow volume of `0`; a window whose total volume is zero yields `0.0`
|
||||
/// by convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow};
|
||||
///
|
||||
/// let mut indicator = ChaikinMoneyFlow::new(20).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 ChaikinMoneyFlow {
|
||||
period: usize,
|
||||
mfv_window: VecDeque<f64>,
|
||||
vol_window: VecDeque<f64>,
|
||||
mfv_sum: f64,
|
||||
vol_sum: f64,
|
||||
}
|
||||
|
||||
impl ChaikinMoneyFlow {
|
||||
/// Construct a new Chaikin Money Flow over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
mfv_window: VecDeque::with_capacity(period),
|
||||
vol_window: VecDeque::with_capacity(period),
|
||||
mfv_sum: 0.0,
|
||||
vol_sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinMoneyFlow {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let mfv = if range == 0.0 {
|
||||
// A zero-range bar carries no positional information.
|
||||
0.0
|
||||
} else {
|
||||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||||
mfm * candle.volume
|
||||
};
|
||||
|
||||
if self.mfv_window.len() == self.period {
|
||||
self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
|
||||
self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
|
||||
}
|
||||
self.mfv_window.push_back(mfv);
|
||||
self.vol_window.push_back(candle.volume);
|
||||
self.mfv_sum += mfv;
|
||||
self.vol_sum += candle.volume;
|
||||
|
||||
if self.mfv_window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
if self.vol_sum == 0.0 {
|
||||
// No volume traded across the whole window — no flow to report.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(self.mfv_sum / self.vol_sum)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.mfv_window.clear();
|
||||
self.vol_window.clear();
|
||||
self.mfv_sum = 0.0;
|
||||
self.vol_sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.mfv_window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CMF"
|
||||
}
|
||||
}
|
||||
|
||||
#[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, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
|
||||
// bar 2 closes mid-range -> MFM = 0, MFV = 0.
|
||||
// CMF = (100 + 0) / (100 + 100) = 0.5.
|
||||
let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
|
||||
let out = cmf.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||||
candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
|
||||
]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_unit_range() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 3.0,
|
||||
mid - 3.0,
|
||||
mid + (i as f64 * 0.5).cos() * 2.0,
|
||||
10.0 + (i % 7) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_high_yield_cmf_one() {
|
||||
// Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_yields_zero() {
|
||||
// A window with no traded volume divides 0/0 — defined as 0.0.
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_th_candle() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
|
||||
let out = cmf.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(), "first CMF lands at index period - 1");
|
||||
assert_eq!(cmf.warmup_period(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
|
||||
cmf.batch(&candles);
|
||||
assert!(cmf.is_ready());
|
||||
cmf.reset();
|
||||
assert!(!cmf.is_ready());
|
||||
assert_eq!(cmf.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;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinMoneyFlow::new(20).unwrap();
|
||||
let mut b = ChaikinMoneyFlow::new(20).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Chande Momentum Oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chande Momentum Oscillator — Tushar Chande's bounded momentum gauge.
|
||||
///
|
||||
/// Over the last `period` price *changes* it sums the gains and the losses
|
||||
/// separately and reports:
|
||||
///
|
||||
/// ```text
|
||||
/// CMO = 100 · (Σ gains − Σ losses) / (Σ gains + Σ losses)
|
||||
/// ```
|
||||
///
|
||||
/// The result is bounded in `[−100, 100]`: `+100` is a window of pure gains,
|
||||
/// `−100` a window of pure losses, `0` a perfect balance. Unlike RSI the sums
|
||||
/// are *unsmoothed* — every change in the window carries equal weight — so CMO
|
||||
/// reacts faster and swings wider.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Cmo};
|
||||
///
|
||||
/// let mut indicator = Cmo::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cmo {
|
||||
period: usize,
|
||||
prev_price: Option<f64>,
|
||||
/// Rolling window of `(gain, loss)` pairs, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Cmo {
|
||||
/// Construct a new CMO 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,
|
||||
prev_price: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 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 Cmo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; state is left untouched.
|
||||
return self.current;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
|
||||
let change = input - prev;
|
||||
let gain = change.max(0.0);
|
||||
let loss = (-change).max(0.0);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_gain, old_loss) = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum_gain -= old_gain;
|
||||
self.sum_loss -= old_loss;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
let cmo = if denom == 0.0 {
|
||||
// A flat window (no gains and no losses): momentum is exactly zero.
|
||||
0.0
|
||||
} else {
|
||||
100.0 * (self.sum_gain - self.sum_loss) / denom
|
||||
};
|
||||
self.current = Some(cmo);
|
||||
Some(cmo)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CMO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// CMO(3) over [10, 11, 10, 12]: changes +1, −1, +2.
|
||||
// Σgain = 3, Σloss = 1 -> 100·(3−1)/(3+1) = 50.
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
|
||||
assert_eq!(cmo.warmup_period(), 4);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[2], None);
|
||||
assert_relative_eq!(out[3].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_at_plus_100() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_saturates_at_minus_100() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&(1..=20).rev().map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&[42.0; 20]);
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
|
||||
let ready = out[3].expect("CMO(3) ready after four inputs");
|
||||
assert_eq!(cmo.update(f64::NAN), Some(ready));
|
||||
assert_eq!(cmo.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
cmo.batch(&[10.0, 11.0, 12.0, 13.0, 14.0]);
|
||||
assert!(cmo.is_ready());
|
||||
cmo.reset();
|
||||
assert!(!cmo.is_ready());
|
||||
assert_eq!(cmo.update(10.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
|
||||
.collect();
|
||||
let batch = Cmo::new(9).unwrap().batch(&prices);
|
||||
let mut b = Cmo::new(9).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Coppock Curve.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::{Roc, Wma};
|
||||
|
||||
/// Coppock Curve — Edwin Coppock's long-term momentum indicator.
|
||||
///
|
||||
/// The Coppock Curve is a weighted moving average of the sum of two rates of
|
||||
/// change:
|
||||
///
|
||||
/// ```text
|
||||
/// Coppock = WMA( ROC(long) + ROC(short), wma_period )
|
||||
/// ```
|
||||
///
|
||||
/// Coppock designed it (1962) as a long-horizon buy signal for stock indices:
|
||||
/// on a monthly chart with the conventional `(long = 14, short = 11,
|
||||
/// wma_period = 10)`, a turn upward from below zero has historically marked
|
||||
/// the start of a new bull phase. The two ROCs blend a slightly longer and a
|
||||
/// slightly shorter momentum horizon; the WMA smooths the result.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Coppock};
|
||||
///
|
||||
/// let mut indicator = Coppock::new(14, 11, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Coppock {
|
||||
roc_long_period: usize,
|
||||
roc_short_period: usize,
|
||||
wma_period: usize,
|
||||
roc_long: Roc,
|
||||
roc_short: Roc,
|
||||
wma: Wma,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Coppock {
|
||||
/// Construct a new Coppock Curve with the two ROC periods and the WMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`.
|
||||
pub fn new(roc_long_period: usize, roc_short_period: usize, wma_period: usize) -> Result<Self> {
|
||||
if roc_long_period == 0 || roc_short_period == 0 || wma_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
roc_long_period,
|
||||
roc_short_period,
|
||||
wma_period,
|
||||
roc_long: Roc::new(roc_long_period)?,
|
||||
roc_short: Roc::new(roc_short_period)?,
|
||||
wma: Wma::new(wma_period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(roc_long, roc_short, wma)` periods.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.roc_long_period, self.roc_short_period, self.wma_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Coppock {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; no component is advanced.
|
||||
return self.current;
|
||||
}
|
||||
let long = self.roc_long.update(input);
|
||||
let short = self.roc_short.update(input);
|
||||
let result = match (long, short) {
|
||||
(Some(l), Some(s)) => self.wma.update(l + s),
|
||||
_ => None,
|
||||
};
|
||||
if result.is_some() {
|
||||
self.current = result;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roc_long.reset();
|
||||
self.roc_short.reset();
|
||||
self.wma.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Let `L = max(roc_long_period, roc_short_period)` and `W = wma_period`.
|
||||
// Both ROCs need `period + 1` inputs to emit; the slower one therefore
|
||||
// first emits at **0-based index L** (= the `(L + 1)`-th input). From
|
||||
// that bar onward both ROCs feed the WMA in lock-step, so the WMA
|
||||
// sees its `W`-th input at 0-based index `L + W − 1` — the first bar
|
||||
// it emits. `warmup_period` is the 1-based count of inputs needed for
|
||||
// the first `Some` value, which is `(L + W − 1) + 1 = L + W`.
|
||||
//
|
||||
// Worked example for `Coppock::new(6, 4, 3)`:
|
||||
// - ROC(6).first_some at index 6 (the 7th input).
|
||||
// - ROC(4).first_some at index 4 (the 5th input). Both available
|
||||
// from index 6 onward.
|
||||
// - WMA(3) consumes 3 inputs at indices 6, 7, 8 → first WMA `Some`
|
||||
// at index 8 (the 9th input). `warmup_period() == 9`.
|
||||
self.roc_long_period.max(self.roc_short_period) + self.wma_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Coppock"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||
assert_eq!(c.warmup_period(), 9);
|
||||
let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().take(8) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[8].is_some());
|
||||
}
|
||||
|
||||
/// `warmup_period()` equals the 1-based index of the first emitted
|
||||
/// `Some` for every legal parameter combination — including the
|
||||
/// parameter set `(roc_long=4, roc_short=2, wma=3)` that an external
|
||||
/// audit claimed would prove the formula off by one. It does not: the
|
||||
/// slower ROC first emits at 0-based index 4, the WMA needs 3 such inputs
|
||||
/// and emits at 0-based index 6 (the 7th input), which is what
|
||||
/// `roc_long.max(roc_short) + wma = max(4, 2) + 3 = 7` reports.
|
||||
#[test]
|
||||
fn warmup_period_matches_first_some_for_every_parameter_set() {
|
||||
let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
|
||||
for &(long, short, wma) in &[(6, 4, 3), (14, 11, 10), (4, 2, 3), (10, 3, 5), (3, 3, 3)] {
|
||||
let mut c = Coppock::new(long, short, wma).unwrap();
|
||||
let warmup = c.warmup_period();
|
||||
let out = c.batch(&prices);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(
|
||||
v.is_none(),
|
||||
"Coppock({long}, {short}, {wma}): index {i} expected None during warmup, got {v:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but index {} is None",
|
||||
warmup - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Both ROCs are 0 on a flat series, so the WMA of zeros is 0.
|
||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||
let out = c.batch(&[100.0; 40]);
|
||||
for v in out.iter().skip(c.warmup_period() - 1).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
// A steady uptrend has positive ROCs, so the Coppock Curve is positive.
|
||||
let mut c = Coppock::new(14, 11, 10).unwrap();
|
||||
let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
|
||||
let out = c.batch(&prices);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(
|
||||
*last > 0.0,
|
||||
"uptrend Coppock should be positive, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||
let out = c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(c.update(f64::NAN), last);
|
||||
assert_eq!(c.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||
c.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0)
|
||||
.collect();
|
||||
let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices);
|
||||
let mut b = Coppock::new(14, 11, 10).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// Designed by Patrick Mulloy to reduce the lag of a single EMA while keeping
|
||||
/// the smoothing benefit.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Dema};
|
||||
///
|
||||
/// let mut indicator = Dema::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dema {
|
||||
ema1: Ema,
|
||||
|
||||
@@ -18,6 +18,22 @@ pub struct DonchianOutput {
|
||||
}
|
||||
|
||||
/// Donchian Channels: rolling highest high / lowest low envelopes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Donchian};
|
||||
///
|
||||
/// let mut indicator = Donchian::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 Donchian {
|
||||
period: usize,
|
||||
@@ -138,4 +154,17 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
assert!(Donchian::new(0).is_err());
|
||||
}
|
||||
|
||||
#[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 d = Donchian::new(5).unwrap();
|
||||
d.batch(&candles);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Detrended Price Oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Detrended Price Oscillator — strips the trend out of price to expose its
|
||||
/// shorter cycles.
|
||||
///
|
||||
/// Instead of comparing price to a *current* moving average, DPO compares a
|
||||
/// **past** price — shifted back by `period / 2 + 1` bars — to the moving
|
||||
/// average of the window:
|
||||
///
|
||||
/// ```text
|
||||
/// shift = period / 2 + 1
|
||||
/// DPO_t = price_{t − shift} − SMA(period)_t
|
||||
/// ```
|
||||
///
|
||||
/// Because the price is taken from roughly half a cycle back, the dominant
|
||||
/// trend cancels out and what remains oscillates around zero — making the
|
||||
/// peak-to-peak cycle length easy to read. DPO is **not** a momentum
|
||||
/// indicator and is not meant to track the latest bar.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Dpo};
|
||||
///
|
||||
/// let mut indicator = Dpo::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 10.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dpo {
|
||||
period: usize,
|
||||
shift: usize,
|
||||
/// Window of the most recent `capacity` prices, oldest at the front.
|
||||
capacity: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Dpo {
|
||||
/// Construct a new DPO 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);
|
||||
}
|
||||
let shift = period / 2 + 1;
|
||||
// The window must cover both the SMA (`period` prices) and the
|
||||
// look-back (`shift + 1` prices: the current bar plus `shift` history).
|
||||
let capacity = period.max(shift + 1);
|
||||
Ok(Self {
|
||||
period,
|
||||
shift,
|
||||
capacity,
|
||||
window: VecDeque::with_capacity(capacity),
|
||||
sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// The look-back shift `period / 2 + 1`.
|
||||
pub const fn shift(&self) -> usize {
|
||||
self.shift
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Dpo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; the window is left untouched.
|
||||
return self.last;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
let len = self.window.len();
|
||||
if len > self.period {
|
||||
// The price that just left the SMA window.
|
||||
self.sum -= self.window[len - 1 - self.period];
|
||||
}
|
||||
if self.window.len() > self.capacity {
|
||||
self.window.pop_front();
|
||||
}
|
||||
if self.window.len() < self.capacity {
|
||||
return None;
|
||||
}
|
||||
let sma = self.sum / self.period as f64;
|
||||
// `price_{t - shift}` — index counts back from the newest bar.
|
||||
let shifted = self.window[self.window.len() - 1 - self.shift];
|
||||
let dpo = shifted - sma;
|
||||
self.last = Some(dpo);
|
||||
Some(dpo)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.capacity
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DPO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Dpo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_is_half_period_plus_one() {
|
||||
assert_eq!(Dpo::new(20).unwrap().shift(), 11);
|
||||
assert_eq!(Dpo::new(4).unwrap().shift(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// DPO(4): shift = 3, capacity = max(4, 4) = 4.
|
||||
// At input 4: window [1,2,3,4], SMA = 2.5, price[t-3] = 1 -> 1 - 2.5 = -1.5.
|
||||
let mut dpo = Dpo::new(4).unwrap();
|
||||
let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
assert_eq!(dpo.warmup_period(), 4);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[2], None);
|
||||
assert_relative_eq!(out[3].unwrap(), -1.5, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[4].unwrap(), -1.5, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[5].unwrap(), -1.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// A flat series: the shifted price equals the SMA, so DPO is 0.
|
||||
let mut dpo = Dpo::new(10).unwrap();
|
||||
let out = dpo.batch(&[50.0; 40]);
|
||||
for v in out.iter().skip(dpo.warmup_period() - 1).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dpo = Dpo::new(4).unwrap();
|
||||
let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(dpo.update(f64::NAN), last);
|
||||
assert_eq!(dpo.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dpo = Dpo::new(4).unwrap();
|
||||
dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
assert!(dpo.is_ready());
|
||||
dpo.reset();
|
||||
assert!(!dpo.is_ready());
|
||||
assert_eq!(dpo.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 7.0)
|
||||
.collect();
|
||||
let batch = Dpo::new(20).unwrap().batch(&prices);
|
||||
let mut b = Dpo::new(20).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Ease of Movement (Arms).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Richard Arms' Ease of Movement — how far price travels per unit of volume.
|
||||
///
|
||||
/// ```text
|
||||
/// distance_t = (high_t + low_t)/2 − (high_{t−1} + low_{t−1})/2
|
||||
/// EMV_t = distance_t · (high_t − low_t) · divisor / volume_t
|
||||
/// EOM_t = SMA(EMV, period)_t
|
||||
/// ```
|
||||
///
|
||||
/// A large positive EMV means price climbed a long way on light volume — it
|
||||
/// moved "easily"; a value near zero means heavy volume was needed to shift
|
||||
/// price at all. The `divisor` only rescales the output: the conventional
|
||||
/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with
|
||||
/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a
|
||||
/// zero-range bar. The first candle only seeds the previous midpoint, so the
|
||||
/// first value appears on candle `period + 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, EaseOfMovement};
|
||||
///
|
||||
/// let mut indicator = EaseOfMovement::new(14).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 EaseOfMovement {
|
||||
period: usize,
|
||||
divisor: f64,
|
||||
prev_mid: Option<f64>,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
}
|
||||
|
||||
impl EaseOfMovement {
|
||||
/// Construct an Ease of Movement with the conventional `1e8` volume divisor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Self::with_divisor(period, 100_000_000.0)
|
||||
}
|
||||
|
||||
/// Construct an Ease of Movement with an explicit volume divisor. The
|
||||
/// divisor is a pure output-scaling constant; pick whatever keeps `EMV`
|
||||
/// readable for your instrument's volume magnitude.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive
|
||||
/// and finite.
|
||||
pub fn with_divisor(period: usize, divisor: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !divisor.is_finite() || divisor <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
divisor,
|
||||
prev_mid: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured volume divisor.
|
||||
pub const fn divisor(&self) -> f64 {
|
||||
self.divisor
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EaseOfMovement {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let mid = (candle.high + candle.low) / 2.0;
|
||||
let Some(prev_mid) = self.prev_mid else {
|
||||
// The first candle only establishes the previous midpoint.
|
||||
self.prev_mid = Some(mid);
|
||||
return None;
|
||||
};
|
||||
let distance = mid - prev_mid;
|
||||
let range = candle.high - candle.low;
|
||||
let emv = if candle.volume == 0.0 {
|
||||
// No volume traded — the move carries no ease-of-movement signal.
|
||||
0.0
|
||||
} else {
|
||||
distance * range * self.divisor / candle.volume
|
||||
};
|
||||
self.prev_mid = Some(mid);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
self.sum -= self.window.pop_front().expect("non-empty");
|
||||
}
|
||||
self.window.push_back(emv);
|
||||
self.sum += emv;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.sum / self.period as f64)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_mid = None;
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed candle establishes the first previous midpoint, then
|
||||
// `period` EMV values fill the averaging window.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EaseOfMovement"
|
||||
}
|
||||
}
|
||||
|
||||
#[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, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// EOM(period = 1, divisor = 1): one EMV value is its own average.
|
||||
// candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid.
|
||||
// candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4,
|
||||
// EMV = 3 * 4 * 1 / 100 = 0.12.
|
||||
let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap();
|
||||
let out = eom.batch(&[
|
||||
candle(9.0, 10.0, 8.0, 9.0, 50.0, 0),
|
||||
candle(12.0, 14.0, 10.0, 12.0, 100.0, 1),
|
||||
]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_midpoints_yield_positive_eom() {
|
||||
// Strictly rising midpoints on constant volume -> every EMV is
|
||||
// positive, so the averaged EOM is positive.
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 1.0, base - 1.0, base, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut eom = EaseOfMovement::new(14).unwrap();
|
||||
for v in eom.batch(&candles).into_iter().flatten() {
|
||||
assert!(v > 0.0, "EOM {v} should be positive on a rising series");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Unchanging candles -> zero distance -> EMV is zero throughout.
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i))
|
||||
.collect();
|
||||
let mut eom = EaseOfMovement::new(10).unwrap();
|
||||
for v in eom.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_contributes_zero() {
|
||||
// A zero-volume bar yields EMV = 0 instead of dividing by zero.
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 1.0, base - 1.0, base, 0.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut eom = EaseOfMovement::new(10).unwrap();
|
||||
for v in eom.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_plus_one_candle() {
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut eom = EaseOfMovement::new(5).unwrap();
|
||||
let out = eom.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(5) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[5].is_some(), "first EOM lands at index period");
|
||||
assert_eq!(eom.warmup_period(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_input() {
|
||||
assert!(EaseOfMovement::new(0).is_err());
|
||||
assert!(EaseOfMovement::with_divisor(14, 0.0).is_err());
|
||||
assert!(EaseOfMovement::with_divisor(14, -1.0).is_err());
|
||||
assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut eom = EaseOfMovement::new(10).unwrap();
|
||||
eom.batch(&candles);
|
||||
assert!(eom.is_ready());
|
||||
eom.reset();
|
||||
assert!(!eom.is_ready());
|
||||
assert_eq!(eom.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;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = EaseOfMovement::new(14).unwrap();
|
||||
let mut b = EaseOfMovement::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
|
||||
/// The first value is seeded with the simple mean of the first `period` inputs
|
||||
/// (the classical TA-Lib convention). From then on each new input contributes
|
||||
/// `alpha * input + (1 - alpha) * previous`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Ema};
|
||||
///
|
||||
/// let mut indicator = Ema::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ema {
|
||||
period: usize,
|
||||
@@ -126,6 +139,27 @@ mod tests {
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference: SMA-seeded EMA computed straight from the definition.
|
||||
fn ema_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
|
||||
let alpha = 2.0 / (period as f64 + 1.0);
|
||||
let mut out = Vec::with_capacity(prices.len());
|
||||
let mut state: Option<f64> = None;
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
if let Some(prev) = state {
|
||||
let v = alpha * p + (1.0 - alpha) * prev;
|
||||
state = Some(v);
|
||||
out.push(Some(v));
|
||||
} else if i + 1 == period {
|
||||
let seed = prices[..period].iter().sum::<f64>() / period as f64;
|
||||
state = Some(seed);
|
||||
out.push(Some(seed));
|
||||
} else {
|
||||
out.push(None);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
|
||||
@@ -221,4 +255,28 @@ mod tests {
|
||||
assert_eq!(ema.update(f64::NAN), before);
|
||||
assert_eq!(ema.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
fn ema_matches_naive(
|
||||
period in 1usize..20,
|
||||
prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..150),
|
||||
) {
|
||||
let mut ema = Ema::new(period).unwrap();
|
||||
let got = ema.batch(&prices);
|
||||
let want = ema_naive(&prices, period);
|
||||
proptest::prop_assert_eq!(got.len(), want.len());
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => proptest::prop_assert!(
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
|
||||
"got={a} want={b}"
|
||||
),
|
||||
_ => proptest::prop_assert!(false, "warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Force Index (Elder).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed.
|
||||
///
|
||||
/// ```text
|
||||
/// raw_t = (close_t − close_{t−1}) · volume_t
|
||||
/// Force_t = EMA(raw, period)_t
|
||||
/// ```
|
||||
///
|
||||
/// The raw force is positive on an up-close and negative on a down-close, and
|
||||
/// its magnitude grows with the volume that backed the move — a big move on
|
||||
/// heavy volume registers a large force. Smoothing the raw series with an EMA
|
||||
/// gives a tradeable line; Elder's classic period is `13`. The first candle
|
||||
/// only establishes the previous close, so the first raw value appears on
|
||||
/// candle 2 and the first smoothed value on candle `period + 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ForceIndex};
|
||||
///
|
||||
/// let mut indicator = ForceIndex::new(13).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 ForceIndex {
|
||||
period: usize,
|
||||
prev_close: Option<f64>,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl ForceIndex {
|
||||
/// Construct a new Force Index with the given EMA smoothing period.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_close: None,
|
||||
ema: Ema::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ForceIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev_close else {
|
||||
// The first candle only establishes the previous close.
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let raw = (candle.close - prev) * candle.volume;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.ema.update(raw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed candle establishes the first previous close, then the EMA
|
||||
// needs `period` raw values.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ForceIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, close, close, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through.
|
||||
// candle 1 (close 10) only seeds the previous close -> None.
|
||||
// candle 2: raw = (12 - 10) * 100 = +200.
|
||||
// candle 3: raw = (11 - 12) * 200 = -200.
|
||||
let mut fi = ForceIndex::new(1).unwrap();
|
||||
let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
// Strictly rising closes on constant volume -> every raw force is
|
||||
// positive, so the smoothed force is positive too.
|
||||
let candles: Vec<Candle> = (1..40)
|
||||
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
|
||||
.collect();
|
||||
let mut fi = ForceIndex::new(13).unwrap();
|
||||
for v in fi.batch(&candles).into_iter().flatten() {
|
||||
assert!(v > 0.0, "force {v} should be positive in an uptrend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_is_negative() {
|
||||
let candles: Vec<Candle> = (1..40)
|
||||
.rev()
|
||||
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
|
||||
.collect();
|
||||
let mut fi = ForceIndex::new(13).unwrap();
|
||||
for v in fi.batch(&candles).into_iter().flatten() {
|
||||
assert!(v < 0.0, "force {v} should be negative in a downtrend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_plus_one_candle() {
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
|
||||
let mut fi = ForceIndex::new(5).unwrap();
|
||||
let out = fi.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(5) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[5].is_some(), "first force lands at index period");
|
||||
assert_eq!(fi.warmup_period(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ForceIndex::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
|
||||
let mut fi = ForceIndex::new(13).unwrap();
|
||||
fi.batch(&candles);
|
||||
assert!(fi.is_ready());
|
||||
fi.reset();
|
||||
assert!(!fi.is_ready());
|
||||
assert_eq!(fi.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let close = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(close, 10.0 + (i % 5) as f64, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ForceIndex::new(13).unwrap();
|
||||
let mut b = ForceIndex::new(13).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Historical Volatility.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Historical Volatility — the annualised standard deviation of log returns.
|
||||
///
|
||||
/// This is the realised (backward-looking) volatility used to price options
|
||||
/// and size risk:
|
||||
///
|
||||
/// ```text
|
||||
/// r_t = ln(price_t / price_{t−1})
|
||||
/// HV = stddev_sample(r over period) · √trading_periods · 100
|
||||
/// ```
|
||||
///
|
||||
/// The log returns over the window are measured with the **sample** standard
|
||||
/// deviation (divisor `n − 1`, the unbiased estimator), then scaled to an
|
||||
/// annual figure by `√trading_periods` — `252` for daily bars, `52` for
|
||||
/// weekly, `12` for monthly — and expressed as a percentage.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HistoricalVolatility};
|
||||
///
|
||||
/// // 20-bar window, 252 trading days per year.
|
||||
/// let mut indicator = HistoricalVolatility::new(20, 252).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoricalVolatility {
|
||||
period: usize,
|
||||
trading_periods: usize,
|
||||
prev_price: Option<f64>,
|
||||
/// Rolling window of the last `period` log returns.
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HistoricalVolatility {
|
||||
/// Construct a new Historical Volatility indicator.
|
||||
///
|
||||
/// `period` is the number of log returns in the rolling window;
|
||||
/// `trading_periods` is the annualisation factor (`252` daily, `52`
|
||||
/// weekly, `12` monthly).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `trading_periods` is `0`,
|
||||
/// or [`Error::InvalidPeriod`] if `period == 1` (the sample standard
|
||||
/// deviation needs at least two returns).
|
||||
pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
|
||||
if period == 0 || trading_periods == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "historical volatility period must be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
trading_periods,
|
||||
prev_price: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 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 HistoricalVolatility {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite *and* non-positive prices are both ignored: state is left
|
||||
// untouched and `self.last` is returned. The log-return `ln(input /
|
||||
// prev)` is undefined for non-positive prices, and silently
|
||||
// substituting `0.0` (the previous behaviour, audit finding R13) would
|
||||
// underreport realised volatility by treating bad ticks as "no
|
||||
// movement". Skipping them entirely is consistent with how the rest
|
||||
// of the library handles invalid inputs (see SMA / EMA / ROC).
|
||||
if !input.is_finite() || input <= 0.0 {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
// `prev` was assigned from `self.prev_price`, which only ever holds
|
||||
// valid (finite, positive) inputs because the guard above gates every
|
||||
// assignment to it — so `(input / prev).ln()` is always well-defined.
|
||||
self.prev_price = Some(input);
|
||||
|
||||
let log_return = (input / prev).ln();
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(log_return);
|
||||
self.sum += log_return;
|
||||
self.sum_sq += log_return * log_return;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
// Sample variance (Bessel's correction): Σ(x−mean)² / (n−1).
|
||||
let variance = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
|
||||
let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0;
|
||||
self.last = Some(hv);
|
||||
Some(hv)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first log return needs a previous price, then the window fills.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HistoricalVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
HistoricalVolatility::new(0, 252),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
HistoricalVolatility::new(20, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_period_one() {
|
||||
assert!(matches!(
|
||||
HistoricalVolatility::new(1, 252),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
|
||||
assert_eq!(hv.warmup_period(), 6);
|
||||
let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Flat prices -> all log returns are 0 -> zero volatility.
|
||||
let mut hv = HistoricalVolatility::new(10, 252).unwrap();
|
||||
let out = hv.batch(&[100.0; 40]);
|
||||
for v in out.iter().skip(10).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_series_yields_zero() {
|
||||
// A constant growth factor gives a constant log return -> zero stddev.
|
||||
let mut hv = HistoricalVolatility::new(10, 252).unwrap();
|
||||
let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
|
||||
let out = hv.batch(&prices);
|
||||
for v in out.iter().skip(10).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut hv = HistoricalVolatility::new(20, 252).unwrap();
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
|
||||
.collect();
|
||||
for v in hv.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "volatility must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
|
||||
let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(hv.update(f64::NAN), last);
|
||||
assert_eq!(hv.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
/// Audit finding R13. Non-positive prices are now skipped (state left
|
||||
/// untouched) instead of silently treated as a `0.0` log-return — the old
|
||||
/// behaviour underreported realised volatility by treating bad ticks as
|
||||
/// "no movement".
|
||||
#[test]
|
||||
fn skips_non_positive_prices() {
|
||||
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
|
||||
// Warm up with positive prices.
|
||||
let warmup_prices = (1..=20).map(f64::from).collect::<Vec<_>>();
|
||||
let warmup = hv.batch(&warmup_prices);
|
||||
let baseline = warmup
|
||||
.last()
|
||||
.copied()
|
||||
.flatten()
|
||||
.expect("warmed up by index 5");
|
||||
|
||||
// A negative tick must be ignored: returned value equals the previous
|
||||
// baseline, and the next real positive tick must use the previous
|
||||
// valid price as `prev` (not the bad one), so the next log return is
|
||||
// exactly `ln(21 / 20)`, not `ln(21 / -5)` or anything else.
|
||||
assert_eq!(hv.update(-5.0), Some(baseline));
|
||||
assert_eq!(hv.update(0.0), Some(baseline));
|
||||
|
||||
// Snapshot the indicator's state, then advance with a real positive
|
||||
// tick on a clone. The clone must agree with a from-scratch run that
|
||||
// simply skipped the bad ticks — proving the state was untouched.
|
||||
let mut control = hv.clone();
|
||||
let after_real = hv.update(21.0).expect("ready");
|
||||
assert_eq!(control.update(21.0).expect("ready"), after_real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hv = HistoricalVolatility::new(5, 252).unwrap();
|
||||
hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(hv.is_ready());
|
||||
hv.reset();
|
||||
assert!(!hv.is_ready());
|
||||
assert_eq!(hv.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices);
|
||||
let mut b = HistoricalVolatility::new(20, 252).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// Designed by Alan Hull as a lag-free moving average that is also responsive.
|
||||
/// The square root of the period is rounded to the nearest integer (minimum 1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Hma};
|
||||
///
|
||||
/// let mut indicator = Hma::new(9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hma {
|
||||
period: usize,
|
||||
@@ -45,8 +58,13 @@ impl Indicator for Hma {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let h = self.half_wma.update(input)?;
|
||||
let f = self.full_wma.update(input)?;
|
||||
// Feed both windowed WMAs on every input so they warm up in parallel.
|
||||
// Gating `full_wma.update` behind `half_wma.update(...)?` would starve
|
||||
// the longer WMA during the shorter one's warmup, delaying the first
|
||||
// emission past `warmup_period()`.
|
||||
let h = self.half_wma.update(input);
|
||||
let f = self.full_wma.update(input);
|
||||
let (h, f) = (h?, f?);
|
||||
let diff = 2.0 * h - f;
|
||||
self.smooth_wma.update(diff)
|
||||
}
|
||||
@@ -109,4 +127,45 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
assert!(Hma::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut hma = Hma::new(9).unwrap();
|
||||
let out = hma.batch(&prices);
|
||||
let warmup = hma.warmup_period();
|
||||
assert_eq!(warmup, 11);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first HMA value must land at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_wmas() {
|
||||
// The two inner WMAs run as independent siblings on the price stream;
|
||||
// HMA must equal feeding three standalone WMAs and combining them.
|
||||
let prices: Vec<f64> = (1..=50)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
|
||||
.collect();
|
||||
let mut hma = Hma::new(9).unwrap();
|
||||
let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
|
||||
let mut full = Wma::new(9).unwrap();
|
||||
let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
|
||||
for &p in &prices {
|
||||
let got = hma.update(p);
|
||||
let want = match (half.update(p), full.update(p)) {
|
||||
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
|
||||
_ => None,
|
||||
};
|
||||
match (got, want) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9),
|
||||
_ => panic!("HMA and the independent-WMA reference disagree on readiness"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ use crate::traits::Indicator;
|
||||
/// get a fast smoothing constant, choppy markets get a slow one. Parameters are
|
||||
/// the efficiency-ratio lookback (`er_period`, default 10), the fast EMA period
|
||||
/// (`fast`, default 2) and the slow EMA period (`slow`, default 30).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Kama};
|
||||
///
|
||||
/// let mut indicator = Kama::new(10, 2, 30).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Kama {
|
||||
er_period: usize,
|
||||
@@ -154,4 +167,15 @@ mod tests {
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut k = Kama::classic();
|
||||
k.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = k.update(41.0);
|
||||
assert!(before.is_some());
|
||||
// Non-finite inputs return the last state without sliding the window.
|
||||
assert_eq!(k.update(f64::NAN), before);
|
||||
assert_eq!(k.update(f64::INFINITY), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,22 @@ pub struct KeltnerOutput {
|
||||
}
|
||||
|
||||
/// Keltner Channels: an EMA centerline with bands sized by ATR.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Keltner};
|
||||
///
|
||||
/// let mut indicator = Keltner::new(5, 5, 2.0).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 Keltner {
|
||||
ema: Ema,
|
||||
@@ -59,8 +75,14 @@ impl Indicator for Keltner {
|
||||
type Output = KeltnerOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<KeltnerOutput> {
|
||||
let mid = self.ema.update(candle.typical_price())?;
|
||||
let atr = self.atr.update(candle)?;
|
||||
// Feed both sub-indicators on every candle so they warm up in parallel.
|
||||
// Gating `atr.update` behind `ema.update(...)?` would starve the ATR of
|
||||
// every candle consumed during the EMA's warmup, delaying the first
|
||||
// emission past `warmup_period()` and seeding the ATR over the wrong
|
||||
// window.
|
||||
let mid = self.ema.update(candle.typical_price());
|
||||
let atr = self.atr.update(candle);
|
||||
let (mid, atr) = (mid?, atr?);
|
||||
Some(KeltnerOutput {
|
||||
upper: mid + self.multiplier * atr,
|
||||
middle: mid,
|
||||
@@ -139,4 +161,71 @@ mod tests {
|
||||
assert!(Keltner::new(20, 10, 0.0).is_err());
|
||||
assert!(Keltner::new(20, 10, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut k = Keltner::classic();
|
||||
k.batch(&candles);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut k = Keltner::classic();
|
||||
let out = k.batch(&candles);
|
||||
let warmup = k.warmup_period();
|
||||
assert_eq!(warmup, 20);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first KeltnerOutput must land at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ema_and_atr() {
|
||||
// The EMA (on typical price) and the ATR (on the candle) run as
|
||||
// independent siblings; Keltner must equal feeding two standalone
|
||||
// instances and combining them once both are ready.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.5, m - 1.5, m)
|
||||
})
|
||||
.collect();
|
||||
let mut k = Keltner::classic();
|
||||
let mut ema = Ema::new(20).unwrap();
|
||||
let mut atr = Atr::new(10).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = k.update(*candle);
|
||||
let mid = ema.update(candle.typical_price());
|
||||
let a = atr.update(*candle);
|
||||
match (mid, a) {
|
||||
(Some(m), Some(av)) => {
|
||||
let o = got.expect("Keltner emits once EMA and ATR are both ready");
|
||||
assert_relative_eq!(o.middle, m, epsilon = 1e-9);
|
||||
assert_relative_eq!(o.upper, m + 2.0 * av, epsilon = 1e-9);
|
||||
assert_relative_eq!(o.lower, m - 2.0 * av, epsilon = 1e-9);
|
||||
}
|
||||
_ => assert!(
|
||||
got.is_none(),
|
||||
"Keltner must be None until both ready (i={i})"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Linear Regression (rolling least-squares endpoint).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Linear Regression — the endpoint of a rolling least-squares fit.
|
||||
///
|
||||
/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits
|
||||
/// the line `y = a + b·x` by ordinary least squares and reports the line's
|
||||
/// value at the most recent point:
|
||||
///
|
||||
/// ```text
|
||||
/// b (slope) = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
|
||||
/// a (intercept) = (Σy − b·Σx) / n
|
||||
/// LinearReg = a + b·(period − 1)
|
||||
/// ```
|
||||
///
|
||||
/// This is TA-Lib's `LINEARREG`: a smoothed price that lags less than an SMA
|
||||
/// because it extrapolates the *local trend* forward to the current bar
|
||||
/// instead of averaging it away.
|
||||
///
|
||||
/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period` and
|
||||
/// are precomputed once, while `Σy` and `Σxy` are maintained incrementally as
|
||||
/// the window slides. The closed-form sliding-window identity for
|
||||
/// `x = 0, 1, …, period − 1` is
|
||||
///
|
||||
/// ```text
|
||||
/// new_sum_xy = old_sum_xy − old_sum_y + popped_y0 // index shift by −1
|
||||
/// new_sum_y = old_sum_y − popped_y0
|
||||
/// // then push the new value at index n−1:
|
||||
/// sum_xy += (n − 1) · new_value
|
||||
/// sum_y += new_value
|
||||
/// ```
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, LinearRegression};
|
||||
///
|
||||
/// let mut indicator = LinearRegression::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinearRegression {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
/// Closed form of `Σx` over `x = 0, 1, …, period − 1` — constant in `period`.
|
||||
sum_x: f64,
|
||||
/// Closed form of `n · Σxx − (Σx)²` — constant in `period`, the OLS
|
||||
/// denominator.
|
||||
denom: f64,
|
||||
/// Running sum of the values currently in the window.
|
||||
sum_y: f64,
|
||||
/// Running `Σ(x · y)` where `x` is the position of each value within the
|
||||
/// trailing window (`0` for the oldest, `period − 1` for the newest).
|
||||
sum_xy: f64,
|
||||
}
|
||||
|
||||
impl LinearRegression {
|
||||
/// Construct a new rolling linear regression over `period` inputs.
|
||||
///
|
||||
/// # 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: "linear regression needs period >= 2",
|
||||
});
|
||||
}
|
||||
let n = period as f64;
|
||||
// Closed forms for x = 0, 1, …, period − 1.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for LinearRegression {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
// Sliding phase: pop the oldest, then shift every remaining index
|
||||
// down by 1 in the running `sum_xy`. The identity
|
||||
// Σ((i − 1) · y_i for i = 1..n−1) = Σ(i · y_i) − Σ(y_i) + y_0
|
||||
// gives the closed-form update below.
|
||||
let y0 = self.window.pop_front().expect("non-empty");
|
||||
self.sum_xy = self.sum_xy - self.sum_y + y0;
|
||||
self.sum_y -= y0;
|
||||
}
|
||||
// Append at position `k = current length` before the push. During
|
||||
// warmup `k` ranges over `0..period − 1`; once the window is full it
|
||||
// is always `period − 1`.
|
||||
let k = self.window.len() as f64;
|
||||
self.window.push_back(value);
|
||||
self.sum_y += value;
|
||||
self.sum_xy += k * 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 intercept = (self.sum_y - slope * self.sum_x) / n;
|
||||
Some(intercept + slope * (n - 1.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"LinearRegression"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// period 3 over [1, 2, 9]: fit y = 0 + 4x, endpoint = 0 + 4·2 = 8.
|
||||
let mut lr = LinearRegression::new(3).unwrap();
|
||||
let out = lr.batch(&[1.0, 2.0, 9.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 8.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_line_returns_current_value() {
|
||||
// The regression of a perfectly linear series is that line itself, so
|
||||
// its endpoint equals the current value.
|
||||
let prices: Vec<f64> = (0..40).map(|i| 2.0 * f64::from(i) + 5.0).collect();
|
||||
let mut lr = LinearRegression::new(10).unwrap();
|
||||
for (i, v) in lr.batch(&prices).into_iter().enumerate() {
|
||||
if let Some(v) = v {
|
||||
assert_relative_eq!(v, 2.0 * i as f64 + 5.0, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_returns_the_constant() {
|
||||
let mut lr = LinearRegression::new(8).unwrap();
|
||||
for v in lr.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_th_input() {
|
||||
let mut lr = LinearRegression::new(5).unwrap();
|
||||
let out = lr.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]);
|
||||
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(), "first value lands at index period - 1");
|
||||
assert_eq!(lr.warmup_period(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(LinearRegression::new(0).is_err());
|
||||
assert!(LinearRegression::new(1).is_err());
|
||||
assert!(LinearRegression::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut lr = LinearRegression::new(5).unwrap();
|
||||
lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(lr.is_ready());
|
||||
lr.reset();
|
||||
assert!(!lr.is_ready());
|
||||
assert_eq!(lr.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = LinearRegression::new(14).unwrap();
|
||||
let mut b = LinearRegression::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Incremental OLS equivalence: the O(1) implementation must agree to
|
||||
/// `1e-9` with a fresh-from-scratch O(n) refit on every bar, on inputs
|
||||
/// chosen to stress every code path: a noisy ramp (sliding phase
|
||||
/// dominates), a step function (the new value differs sharply from the
|
||||
/// popped one), and constants (the floating-point accumulators must not
|
||||
/// drift).
|
||||
#[test]
|
||||
fn incremental_matches_naive_fit_bar_by_bar() {
|
||||
fn naive_endpoint(window: &[f64]) -> f64 {
|
||||
let n = window.len() as f64;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
for (i, &y) in window.iter().enumerate() {
|
||||
let x = i as f64;
|
||||
sum_y += y;
|
||||
sum_xy += x * y;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
}
|
||||
let denom = n * sum_xx - sum_x * sum_x;
|
||||
let slope = (n * sum_xy - sum_x * sum_y) / denom;
|
||||
let intercept = (sum_y - slope * sum_x) / n;
|
||||
intercept + slope * (n - 1.0)
|
||||
}
|
||||
|
||||
fn check(prices: &[f64], period: usize) {
|
||||
let mut lr = LinearRegression::new(period).unwrap();
|
||||
for (t, p) in prices.iter().enumerate() {
|
||||
let streaming = lr.update(*p);
|
||||
if t + 1 >= period {
|
||||
let lo = t + 1 - period;
|
||||
let expected = naive_endpoint(&prices[lo..=t]);
|
||||
let got = streaming.expect("warmed up");
|
||||
assert!(
|
||||
(got - expected).abs() < 1e-9,
|
||||
"endpoint diverges at t={t}, period={period}: got={got}, expected={expected}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let noisy_ramp: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
|
||||
.collect();
|
||||
check(&noisy_ramp, 5);
|
||||
check(&noisy_ramp, 14);
|
||||
check(&noisy_ramp, 30);
|
||||
|
||||
let mut step = vec![1.0; 30];
|
||||
step.extend(std::iter::repeat_n(100.0, 30));
|
||||
step.extend(std::iter::repeat_n(0.001, 30));
|
||||
check(&step, 5);
|
||||
check(&step, 14);
|
||||
|
||||
let constant = vec![42.0; 50];
|
||||
check(&constant, 8);
|
||||
check(&constant, 25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Linear Regression Angle.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::linreg_slope::LinRegSlope;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Linear Regression Angle — the slope of the rolling least-squares fit,
|
||||
/// expressed as an angle in degrees.
|
||||
///
|
||||
/// ```text
|
||||
/// LinRegAngle = atan(LinRegSlope) · 180 / π
|
||||
/// ```
|
||||
///
|
||||
/// It carries exactly the same information as [`LinRegSlope`](crate::LinRegSlope)
|
||||
/// — positive while price trends up, negative while it trends down — but maps
|
||||
/// the unbounded slope through `atan` onto `(−90°, +90°)`. That bounded,
|
||||
/// price-unit-free scale makes "how steep is the trend" comparable at a glance
|
||||
/// and across instruments. This is TA-Lib's `LINEARREG_ANGLE`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, LinRegAngle};
|
||||
///
|
||||
/// let mut indicator = LinRegAngle::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinRegAngle {
|
||||
slope: LinRegSlope,
|
||||
}
|
||||
|
||||
impl LinRegAngle {
|
||||
/// Construct a new rolling linear-regression angle over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
|
||||
/// `period < 2` — a regression line is undefined for fewer than two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
slope: LinRegSlope::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.slope.period()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for LinRegAngle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.slope.update(value).map(|s| s.atan().to_degrees())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.slope.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.slope.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.slope.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"LinRegAngle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn unit_slope_is_forty_five_degrees() {
|
||||
// A series rising by exactly 1 per step has slope 1, and atan(1) = 45°.
|
||||
let mut angle = LinRegAngle::new(5).unwrap();
|
||||
let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for (i, v) in out.iter().enumerate().take(4) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_steep_slope() {
|
||||
// period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees.
|
||||
let mut angle = LinRegAngle::new(3).unwrap();
|
||||
let out = angle.batch(&[1.0, 2.0, 9.0]);
|
||||
assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_has_zero_angle() {
|
||||
let mut angle = LinRegAngle::new(8).unwrap();
|
||||
for v in angle.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_series_has_negative_angle() {
|
||||
let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
|
||||
let mut angle = LinRegAngle::new(10).unwrap();
|
||||
for v in angle.batch(&prices).into_iter().flatten() {
|
||||
assert!(v < 0.0, "a falling series must have a negative angle");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_ninety_degrees() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0)
|
||||
.collect();
|
||||
let mut angle = LinRegAngle::new(14).unwrap();
|
||||
for v in angle.batch(&prices).into_iter().flatten() {
|
||||
assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(LinRegAngle::new(0).is_err());
|
||||
assert!(LinRegAngle::new(1).is_err());
|
||||
assert!(LinRegAngle::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut angle = LinRegAngle::new(5).unwrap();
|
||||
angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(angle.is_ready());
|
||||
angle.reset();
|
||||
assert!(!angle.is_ready());
|
||||
assert_eq!(angle.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = LinRegAngle::new(14).unwrap();
|
||||
let mut b = LinRegAngle::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Linear Regression Slope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Linear Regression Slope — the slope of a rolling least-squares fit.
|
||||
///
|
||||
/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits
|
||||
/// the line `y = a + b·x` by ordinary least squares and reports the slope:
|
||||
///
|
||||
/// ```text
|
||||
/// b = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
|
||||
/// ```
|
||||
///
|
||||
/// This is TA-Lib's `LINEARREG_SLOPE`: a momentum-like reading of how steeply
|
||||
/// price is trending over the window — positive while it rises, negative
|
||||
/// while it falls, near zero when it is flat — without the band-pass quirks
|
||||
/// of a difference-based oscillator.
|
||||
///
|
||||
/// Each `update` is O(1): the same incremental OLS state as
|
||||
/// [`LinearRegression`](crate::LinearRegression) is maintained — `Σx` and
|
||||
/// `Σxx` are precomputed once from `period`, while `Σy` and `Σxy` are slid
|
||||
/// forward in closed form on every push.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, LinRegSlope};
|
||||
///
|
||||
/// let mut indicator = LinRegSlope::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinRegSlope {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
/// Closed form of `Σx` over `x = 0, 1, …, period − 1` — constant in `period`.
|
||||
sum_x: f64,
|
||||
/// Closed form of `n · Σxx − (Σx)²` — constant in `period`.
|
||||
denom: f64,
|
||||
/// Running sum of the values currently in the window.
|
||||
sum_y: f64,
|
||||
/// Running `Σ(x · y)` where `x` is the position within the trailing window.
|
||||
sum_xy: f64,
|
||||
}
|
||||
|
||||
impl LinRegSlope {
|
||||
/// Construct a new rolling linear-regression slope over `period` inputs.
|
||||
///
|
||||
/// # 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: "linear regression slope needs period >= 2",
|
||||
});
|
||||
}
|
||||
let n = period as f64;
|
||||
// Closed forms for x = 0, 1, …, period − 1.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for LinRegSlope {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
// Sliding-window identity: when the window slides one step forward
|
||||
// the indices `x` for every kept entry shift down by 1, so
|
||||
// new_sum_xy = old_sum_xy − old_sum_y + y0
|
||||
// (`y0` is the popped front value).
|
||||
let y0 = self.window.pop_front().expect("non-empty");
|
||||
self.sum_xy = self.sum_xy - self.sum_y + y0;
|
||||
self.sum_y -= y0;
|
||||
}
|
||||
let k = self.window.len() as f64;
|
||||
self.window.push_back(value);
|
||||
self.sum_y += value;
|
||||
self.sum_xy += k * value;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
Some((n * self.sum_xy - self.sum_x * self.sum_y) / self.denom)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"LinRegSlope"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// period 3 over [1, 2, 9]: fit y = 0 + 4x, so the slope is 4.
|
||||
let mut ls = LinRegSlope::new(3).unwrap();
|
||||
let out = ls.batch(&[1.0, 2.0, 9.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 4.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_line_returns_its_step() {
|
||||
// A series rising by a fixed step has exactly that slope.
|
||||
let prices: Vec<f64> = (0..40).map(|i| 2.5 * f64::from(i) + 7.0).collect();
|
||||
let mut ls = LinRegSlope::new(10).unwrap();
|
||||
for v in ls.batch(&prices).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 2.5, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_has_zero_slope() {
|
||||
let mut ls = LinRegSlope::new(8).unwrap();
|
||||
for v in ls.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_series_has_negative_slope() {
|
||||
let prices: Vec<f64> = (0..30).map(|i| 100.0 - f64::from(i)).collect();
|
||||
let mut ls = LinRegSlope::new(10).unwrap();
|
||||
for v in ls.batch(&prices).into_iter().flatten() {
|
||||
assert!(v < 0.0, "a falling series must have a negative slope");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_th_input() {
|
||||
let mut ls = LinRegSlope::new(5).unwrap();
|
||||
let out = ls.batch(&[1.0, 3.0, 2.0, 5.0, 4.0, 6.0]);
|
||||
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(), "first value lands at index period - 1");
|
||||
assert_eq!(ls.warmup_period(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(LinRegSlope::new(0).is_err());
|
||||
assert!(LinRegSlope::new(1).is_err());
|
||||
assert!(LinRegSlope::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ls = LinRegSlope::new(5).unwrap();
|
||||
ls.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(ls.is_ready());
|
||||
ls.reset();
|
||||
assert!(!ls.is_ready());
|
||||
assert_eq!(ls.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = LinRegSlope::new(14).unwrap();
|
||||
let mut b = LinRegSlope::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Incremental OLS equivalence for the slope: the O(1) implementation must
|
||||
/// agree bar-by-bar with a fresh-from-scratch O(n) refit, on a noisy ramp
|
||||
/// (sliding-phase dominated) and a step function (large pop/push deltas).
|
||||
#[test]
|
||||
fn incremental_matches_naive_slope_bar_by_bar() {
|
||||
fn naive_slope(window: &[f64]) -> f64 {
|
||||
let n = window.len() as f64;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
for (i, &y) in window.iter().enumerate() {
|
||||
let x = i as f64;
|
||||
sum_y += y;
|
||||
sum_xy += x * y;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
}
|
||||
(n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)
|
||||
}
|
||||
|
||||
fn check(prices: &[f64], period: usize) {
|
||||
let mut ls = LinRegSlope::new(period).unwrap();
|
||||
for (t, p) in prices.iter().enumerate() {
|
||||
let streaming = ls.update(*p);
|
||||
if t + 1 >= period {
|
||||
let lo = t + 1 - period;
|
||||
let expected = naive_slope(&prices[lo..=t]);
|
||||
let got = streaming.expect("warmed up");
|
||||
assert!(
|
||||
(got - expected).abs() < 1e-9,
|
||||
"slope diverges at t={t}, period={period}: got={got}, expected={expected}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let noisy_ramp: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
|
||||
.collect();
|
||||
check(&noisy_ramp, 5);
|
||||
check(&noisy_ramp, 14);
|
||||
|
||||
let mut step = vec![1.0; 30];
|
||||
step.extend(std::iter::repeat_n(100.0, 30));
|
||||
check(&step, 7);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,19 @@ pub struct MacdOutput {
|
||||
/// is seeded from the first `signal` raw MACD values, so the first full
|
||||
/// [`MacdOutput`] is emitted after `slow + signal − 1` inputs (assuming the
|
||||
/// slow EMA seeded by then).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MacdIndicator};
|
||||
///
|
||||
/// let mut indicator = MacdIndicator::new(3, 6, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MacdIndicator {
|
||||
fast: Ema,
|
||||
@@ -227,4 +240,16 @@ mod tests {
|
||||
assert!(!macd.is_ready());
|
||||
assert_eq!(macd.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut macd = MacdIndicator::classic();
|
||||
macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
let before = macd.value();
|
||||
assert!(before.is_some());
|
||||
// Non-finite inputs return the last value without advancing any EMA.
|
||||
assert_eq!(macd.update(f64::NAN), before);
|
||||
assert_eq!(macd.update(f64::INFINITY), before);
|
||||
assert_eq!(macd.value(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Mass Index.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Ema;
|
||||
|
||||
/// Mass Index — Donald Dorsey's range-expansion indicator.
|
||||
///
|
||||
/// The Mass Index watches the high–low range, not direction. It smooths the
|
||||
/// range with an EMA, smooths that again, takes the ratio of the two, and sums
|
||||
/// the ratio over a window:
|
||||
///
|
||||
/// ```text
|
||||
/// range_t = high_t − low_t
|
||||
/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period)
|
||||
/// MassIndex = Σ ratio over sum_period
|
||||
/// ```
|
||||
///
|
||||
/// When the range widens, the single EMA pulls ahead of the double EMA, the
|
||||
/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the
|
||||
/// Mass Index rising above `27` and then falling back below `26.5` — a sign
|
||||
/// that a range expansion is about to resolve into a trend reversal. With the
|
||||
/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at
|
||||
/// `25`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, MassIndex};
|
||||
///
|
||||
/// let mut indicator = MassIndex::new(9, 25).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MassIndex {
|
||||
ema_period: usize,
|
||||
sum_period: usize,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
/// Rolling window of the last `sum_period` EMA ratios.
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl MassIndex {
|
||||
/// Construct a new Mass Index with the EMA smoothing period and the sum
|
||||
/// window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`.
|
||||
pub fn new(ema_period: usize, sum_period: usize) -> Result<Self> {
|
||||
if ema_period == 0 || sum_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
ema_period,
|
||||
sum_period,
|
||||
ema1: Ema::new(ema_period)?,
|
||||
ema2: Ema::new(ema_period)?,
|
||||
window: VecDeque::with_capacity(sum_period),
|
||||
sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(ema_period, sum_period)` pair.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.ema_period, self.sum_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MassIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let single = self.ema1.update(range)?;
|
||||
let double = self.ema2.update(single)?;
|
||||
let ratio = if double == 0.0 {
|
||||
// A zero-range market: no expansion, neutral ratio.
|
||||
1.0
|
||||
} else {
|
||||
single / double
|
||||
};
|
||||
if self.window.len() == self.sum_period {
|
||||
self.sum -= self.window.pop_front().expect("window is non-empty");
|
||||
}
|
||||
self.window.push_back(ratio);
|
||||
self.sum += ratio;
|
||||
if self.window.len() < self.sum_period {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(self.sum);
|
||||
Some(self.sum)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum
|
||||
// window needs `sum_period` ratios.
|
||||
2 * self.ema_period + self.sum_period - 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MassIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// A candle with a fixed high–low range `span` centred on `mid`.
|
||||
fn candle(mid: f64, span: f64, ts: i64) -> Candle {
|
||||
Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
|
||||
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_formula() {
|
||||
let mi = MassIndex::new(9, 25).unwrap();
|
||||
assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
|
||||
assert_eq!(warmup, 8);
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
let out = mi.batch(&candles);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_range_sums_to_sum_period() {
|
||||
// A constant high–low range makes both EMAs converge to the same
|
||||
// value, so every ratio is 1 and the Mass Index equals `sum_period`.
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
for v in mi.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_market_sums_to_sum_period() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
|
||||
for v in mi.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
mi.batch(&candles);
|
||||
assert!(mi.is_ready());
|
||||
mi.reset();
|
||||
assert!(!mi.is_ready());
|
||||
assert_eq!(mi.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
|
||||
candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
|
||||
let mut b = MassIndex::new(9, 25).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Median Price.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Median Price — the bar's `(high + low) / 2`.
|
||||
///
|
||||
/// The midpoint of the bar's range, ignoring where it opened or closed. It is
|
||||
/// the price series Bill Williams' [`AwesomeOscillator`](crate::AwesomeOscillator)
|
||||
/// is built on, and a smoother stand-in for the close when feeding other
|
||||
/// indicators. As a stateless per-bar transform it emits a value from the
|
||||
/// very first candle.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, MedianPrice};
|
||||
///
|
||||
/// let mut indicator = MedianPrice::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 MedianPrice {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl MedianPrice {
|
||||
/// Construct a new Median Price transform.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MedianPrice {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
Some(candle.median_price())
|
||||
}
|
||||
|
||||
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 {
|
||||
"MedianPrice"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 reference_value() {
|
||||
// (high + low) / 2 = (12 + 8) / 2 = 10.
|
||||
let mut mp = MedianPrice::new();
|
||||
assert_relative_eq!(
|
||||
mp.update(candle(10.0, 12.0, 8.0, 11.0, 0)).unwrap(),
|
||||
10.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut mp = MedianPrice::new();
|
||||
assert_eq!(mp.warmup_period(), 1);
|
||||
assert!(!mp.is_ready());
|
||||
assert!(mp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
|
||||
assert!(mp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mp = MedianPrice::new();
|
||||
mp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(mp.is_ready());
|
||||
mp.reset();
|
||||
assert!(!mp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = MedianPrice::new();
|
||||
let mut b = MedianPrice::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,22 @@ use crate::traits::Indicator;
|
||||
/// `MFI = 100 - 100 / (1 + positive_money_flow / negative_money_flow)` where
|
||||
/// money flow is `typical_price * volume`, classified positive when TP increases
|
||||
/// and negative when it decreases.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Mfi};
|
||||
///
|
||||
/// let mut indicator = Mfi::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 Mfi {
|
||||
period: usize,
|
||||
@@ -50,18 +66,23 @@ impl Indicator for Mfi {
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tp = candle.typical_price();
|
||||
|
||||
// The very first candle only establishes the previous typical price.
|
||||
// It carries no money-flow direction, so it is not pushed into the
|
||||
// window. This matches TA-Lib / pandas-ta, which need `period + 1`
|
||||
// candles before the first MFI value.
|
||||
let Some(prev) = self.prev_tp else {
|
||||
self.prev_tp = Some(tp);
|
||||
return None;
|
||||
};
|
||||
|
||||
let mf = tp * candle.volume;
|
||||
let (pos_flow, neg_flow) = match self.prev_tp {
|
||||
None => (0.0, 0.0),
|
||||
Some(prev) => {
|
||||
if tp > prev {
|
||||
(mf, 0.0)
|
||||
} else if tp < prev {
|
||||
(0.0, mf)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
}
|
||||
let (pos_flow, neg_flow) = if tp > prev {
|
||||
(mf, 0.0)
|
||||
} else if tp < prev {
|
||||
(0.0, mf)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
|
||||
if self.pos_window.len() == self.period {
|
||||
@@ -75,12 +96,11 @@ impl Indicator for Mfi {
|
||||
|
||||
self.prev_tp = Some(tp);
|
||||
|
||||
// Need period+1 candles total (the first one only gives prev_tp).
|
||||
if self.prev_tp.is_none() || self.pos_window.len() < self.period {
|
||||
if self.pos_window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// Need at least one comparison-based flow inside the window, otherwise we
|
||||
// are still on the very first candle.
|
||||
// A fully flat window (every typical price equal) has zero flow on
|
||||
// both sides; by convention MFI is then 50.
|
||||
if self.pos_sum == 0.0 && self.neg_sum == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
@@ -100,7 +120,9 @@ impl Indicator for Mfi {
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
// One seed candle establishes the first previous typical price, then
|
||||
// `period` flow comparisons fill the window.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
@@ -163,4 +185,35 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
assert!(Mfi::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_emitted_on_period_plus_one_candle() {
|
||||
// The seed candle plus `period` flow comparisons -> first MFI on the
|
||||
// (period + 1)-th candle (index `period`).
|
||||
let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), 100.0)).collect();
|
||||
let mut mfi = Mfi::new(5).unwrap();
|
||||
let out = mfi.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(5) {
|
||||
assert!(v.is_none(), "candle index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[5].is_some(),
|
||||
"first MFI value lands at index period (5)"
|
||||
);
|
||||
assert_eq!(mfi.warmup_period(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_period_2() {
|
||||
// Three candles, MFI(2). Candle 1 (tp=10) only seeds the previous TP.
|
||||
// Candle 2 (tp=12 > 10): positive money flow 12 * 100 = 1200.
|
||||
// Candle 3 (tp=11 < 12): negative money flow 11 * 100 = 1100.
|
||||
// money ratio = 1200 / 1100; MFI = 100 - 100 / (1 + 1200/1100) = 1200/23.
|
||||
let candles = vec![c(10.0, 100.0), c(12.0, 100.0), c(11.0, 100.0)];
|
||||
let mut mfi = Mfi::new(2).unwrap();
|
||||
let out = mfi.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 1200.0 / 23.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,54 +4,146 @@
|
||||
//! volume) but every public name is also re-exported flat from this module and
|
||||
//! from the crate root for convenience.
|
||||
|
||||
mod accelerator_oscillator;
|
||||
mod adl;
|
||||
mod adx;
|
||||
mod aroon;
|
||||
mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod atr_trailing_stop;
|
||||
mod awesome_oscillator;
|
||||
mod balance_of_power;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod cci;
|
||||
mod chaikin_oscillator;
|
||||
mod chaikin_volatility;
|
||||
mod chande_kroll_stop;
|
||||
mod chandelier_exit;
|
||||
mod choppiness_index;
|
||||
mod cmf;
|
||||
mod cmo;
|
||||
mod coppock;
|
||||
mod dema;
|
||||
mod donchian;
|
||||
mod dpo;
|
||||
mod ease_of_movement;
|
||||
mod ema;
|
||||
mod force_index;
|
||||
mod historical_volatility;
|
||||
mod hma;
|
||||
mod kama;
|
||||
mod keltner;
|
||||
mod linreg;
|
||||
mod linreg_angle;
|
||||
mod linreg_slope;
|
||||
mod macd;
|
||||
mod mass_index;
|
||||
mod median_price;
|
||||
mod mfi;
|
||||
mod mom;
|
||||
mod natr;
|
||||
mod obv;
|
||||
mod percent_b;
|
||||
mod pmo;
|
||||
mod ppo;
|
||||
mod psar;
|
||||
mod roc;
|
||||
mod rsi;
|
||||
mod sma;
|
||||
mod smma;
|
||||
mod std_dev;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
mod tema;
|
||||
mod trima;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsi;
|
||||
mod typical_price;
|
||||
mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
mod vertical_horizontal_filter;
|
||||
mod vortex;
|
||||
mod vpt;
|
||||
mod vwap;
|
||||
mod vwma;
|
||||
mod weighted_close;
|
||||
mod williams_r;
|
||||
mod wma;
|
||||
mod z_score;
|
||||
mod zlema;
|
||||
|
||||
pub use accelerator_oscillator::AcceleratorOscillator;
|
||||
pub use adl::Adl;
|
||||
pub use adx::{Adx, AdxOutput};
|
||||
pub use aroon::{Aroon, AroonOutput};
|
||||
pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use balance_of_power::BalanceOfPower;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
pub use bollinger_bandwidth::BollingerBandwidth;
|
||||
pub use cci::Cci;
|
||||
pub use chaikin_oscillator::ChaikinOscillator;
|
||||
pub use chaikin_volatility::ChaikinVolatility;
|
||||
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
|
||||
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
|
||||
pub use choppiness_index::ChoppinessIndex;
|
||||
pub use cmf::ChaikinMoneyFlow;
|
||||
pub use cmo::Cmo;
|
||||
pub use coppock::Coppock;
|
||||
pub use dema::Dema;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
pub use dpo::Dpo;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
pub use ema::Ema;
|
||||
pub use force_index::ForceIndex;
|
||||
pub use historical_volatility::HistoricalVolatility;
|
||||
pub use hma::Hma;
|
||||
pub use kama::Kama;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use linreg::LinearRegression;
|
||||
pub use linreg_angle::LinRegAngle;
|
||||
pub use linreg_slope::LinRegSlope;
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use mass_index::MassIndex;
|
||||
pub use median_price::MedianPrice;
|
||||
pub use mfi::Mfi;
|
||||
pub use mom::Mom;
|
||||
pub use natr::Natr;
|
||||
pub use obv::Obv;
|
||||
pub use percent_b::PercentB;
|
||||
pub use pmo::Pmo;
|
||||
pub use ppo::Ppo;
|
||||
pub use psar::Psar;
|
||||
pub use roc::Roc;
|
||||
pub use rsi::Rsi;
|
||||
pub use sma::Sma;
|
||||
pub use smma::Smma;
|
||||
pub use std_dev::StdDev;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
pub use tema::Tema;
|
||||
pub use trima::Trima;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsi::Tsi;
|
||||
pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vpt::VolumePriceTrend;
|
||||
pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwma::Vwma;
|
||||
pub use weighted_close::WeightedClose;
|
||||
pub use williams_r::WilliamsR;
|
||||
pub use wma::Wma;
|
||||
pub use z_score::ZScore;
|
||||
pub use zlema::Zlema;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Momentum (absolute price change over a fixed lookback).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Momentum: the raw price change over `period` bars, `price_t − price_{t−period}`.
|
||||
///
|
||||
/// Unlike [`Roc`](crate::Roc), which divides by the old price to give a
|
||||
/// percentage, `Mom` reports the change in absolute price units. It is the
|
||||
/// simplest momentum primitive: positive values mean price is higher than it
|
||||
/// was `period` bars ago, negative values mean lower.
|
||||
///
|
||||
/// Non-finite inputs are ignored and leave the window untouched; the last
|
||||
/// computed value is returned instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Mom};
|
||||
///
|
||||
/// let mut indicator = Mom::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mom {
|
||||
period: usize,
|
||||
/// Rolling buffer of the last `period + 1` inputs, oldest at the front.
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Mom {
|
||||
/// Construct a new momentum indicator with the given lookback 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 + 1),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback 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 Mom {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; the window is left untouched.
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period + 1 {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
let prev = *self.window.front().expect("window is non-empty");
|
||||
let mom = input - prev;
|
||||
self.last = Some(mom);
|
||||
Some(mom)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period + 1
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MOM"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// MOM(3): price_t − price_{t-3}.
|
||||
let mut mom = Mom::new(3).unwrap();
|
||||
let out = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]);
|
||||
assert_eq!(mom.warmup_period(), 4);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[2], None);
|
||||
assert_relative_eq!(out[3].unwrap(), 4.0 - 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[4].unwrap(), 7.0 - 2.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut mom = Mom::new(5).unwrap();
|
||||
let out = mom.batch(&[10.0; 20]);
|
||||
for v in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut mom = Mom::new(3).unwrap();
|
||||
let out = mom.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let ready = out[3].expect("MOM(3) ready after four inputs");
|
||||
assert_eq!(mom.update(f64::NAN), Some(ready));
|
||||
assert_eq!(mom.update(f64::INFINITY), Some(ready));
|
||||
// Window untouched: the next finite input still references price 2.
|
||||
assert_relative_eq!(mom.update(10.0).unwrap(), 10.0 - 2.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mom = Mom::new(3).unwrap();
|
||||
mom.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(mom.is_ready());
|
||||
mom.reset();
|
||||
assert!(!mom.is_ready());
|
||||
assert_eq!(mom.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40).map(|i| f64::from(i) * 1.5).collect();
|
||||
let batch = Mom::new(7).unwrap().batch(&prices);
|
||||
let mut b = Mom::new(7).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Normalized Average True Range.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Atr;
|
||||
|
||||
/// Normalized Average True Range — [`Atr`] expressed as a percentage of price.
|
||||
///
|
||||
/// `Atr` reports volatility in raw price units, which makes its readings
|
||||
/// impossible to compare across instruments at different price levels. NATR
|
||||
/// fixes that by dividing by the current close:
|
||||
///
|
||||
/// ```text
|
||||
/// NATR = 100 · ATR / close
|
||||
/// ```
|
||||
///
|
||||
/// A NATR of `2.0` always means "the average true range is 2 % of price",
|
||||
/// whether the instrument trades at $10 or $10 000 — so NATR values are
|
||||
/// directly comparable, and stop distances or position sizes expressed as a
|
||||
/// NATR multiple behave consistently across a portfolio.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Natr};
|
||||
///
|
||||
/// let mut indicator = Natr::new(14).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, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Natr {
|
||||
atr: Atr,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Natr {
|
||||
/// Construct a new NATR with the given ATR period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atr: Atr::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.atr.period()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Natr {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let natr = if candle.close == 0.0 {
|
||||
// NATR is undefined against a zero close.
|
||||
0.0
|
||||
} else {
|
||||
100.0 * atr / candle.close
|
||||
};
|
||||
self.last = Some(natr);
|
||||
Some(natr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"NATR"
|
||||
}
|
||||
}
|
||||
|
||||
#[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 new_rejects_zero_period() {
|
||||
assert!(Natr::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_matches_atr() {
|
||||
let natr = Natr::new(14).unwrap();
|
||||
assert_eq!(natr.warmup_period(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natr_is_atr_over_close_as_percent() {
|
||||
// NATR must equal 100 * ATR / close, bar for bar.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
|
||||
candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let natr_out = Natr::new(14).unwrap().batch(&candles);
|
||||
let atr_out = Atr::new(14).unwrap().batch(&candles);
|
||||
for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
|
||||
match (n, a) {
|
||||
(Some(nv), Some(av)) => {
|
||||
let want = 100.0 * av / candles[i].close;
|
||||
assert_relative_eq!(*nv, want, epsilon = 1e-9);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch at {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_yields_zero() {
|
||||
// No range -> ATR is 0 -> NATR is 0.
|
||||
let mut natr = Natr::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| candle(100.0, 100.0, 100.0, 100.0, i))
|
||||
.collect();
|
||||
for v in natr.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut natr = Natr::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
|
||||
.collect();
|
||||
natr.batch(&candles);
|
||||
assert!(natr.is_ready());
|
||||
natr.reset();
|
||||
assert!(!natr.is_ready());
|
||||
assert_eq!(natr.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.35).sin() * 9.0;
|
||||
candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Natr::new(14).unwrap().batch(&candles);
|
||||
let mut b = Natr::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,22 @@ use crate::traits::Indicator;
|
||||
/// Each candle adds `+volume`, `-volume`, or `0` depending on whether its close
|
||||
/// is above, below, or equal to the previous close. The first value (after the
|
||||
/// first candle) is conventionally `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Obv};
|
||||
///
|
||||
/// let mut indicator = Obv::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 Obv {
|
||||
prev_close: Option<f64>,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Bollinger %b.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::BollingerBands;
|
||||
|
||||
/// Bollinger %b — where price sits within the Bollinger Bands.
|
||||
///
|
||||
/// ```text
|
||||
/// %b = (price − lower) / (upper − lower)
|
||||
/// ```
|
||||
///
|
||||
/// `%b = 1` means price is exactly on the upper band, `%b = 0` on the lower
|
||||
/// band, `%b = 0.5` on the middle band. The value is **not** clamped: price
|
||||
/// breaking above the upper band gives `%b > 1`, breaking below the lower band
|
||||
/// gives `%b < 0`. That makes %b a clean, scale-free way to compare a price's
|
||||
/// band position across instruments and to spot band overshoots.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PercentB};
|
||||
///
|
||||
/// let mut indicator = PercentB::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PercentB {
|
||||
bands: BollingerBands,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl PercentB {
|
||||
/// Construct a new %b indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
|
||||
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: BollingerBands::new(period, multiplier)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.bands.multiplier()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PercentB {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let o = self.bands.update(input)?;
|
||||
let width = o.upper - o.lower;
|
||||
let percent_b = if width == 0.0 {
|
||||
// Bands collapsed onto the middle: price is exactly mid-band.
|
||||
0.5
|
||||
} else {
|
||||
(input - o.lower) / width
|
||||
};
|
||||
self.last = Some(percent_b);
|
||||
Some(percent_b)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PercentB"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_parameters() {
|
||||
assert!(PercentB::new(0, 2.0).is_err());
|
||||
assert!(PercentB::new(20, 0.0).is_err());
|
||||
assert!(PercentB::new(20, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_midpoint() {
|
||||
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
|
||||
let mut pb = PercentB::new(5, 2.0).unwrap();
|
||||
let out = pb.batch(&[100.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_bands_definition() {
|
||||
// %b must equal (price - lower) / (upper - lower) from BollingerBands.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
match (p, b) {
|
||||
(Some(pv), Some(bv)) => {
|
||||
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
|
||||
assert_relative_eq!(*pv, want, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch at {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_at_middle_is_half() {
|
||||
// A symmetric oscillation keeps the SMA centred; when price crosses
|
||||
// the SMA, %b passes through 0.5. Verified via the bands definition.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
if let (Some(pv), Some(bv)) = (p, b) {
|
||||
if (prices[i] - bv.middle).abs() < 1e-9 {
|
||||
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pb = PercentB::new(5, 2.0).unwrap();
|
||||
pb.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(pb.is_ready());
|
||||
pb.reset();
|
||||
assert!(!pb.is_ready());
|
||||
assert_eq!(pb.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let mut b = PercentB::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Price Momentum Oscillator (`DecisionPoint`).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Ema;
|
||||
|
||||
/// Price Momentum Oscillator — Carl Swenlin's `DecisionPoint` PMO line.
|
||||
///
|
||||
/// PMO is a doubly-smoothed rate of change. The 1-bar percentage change is
|
||||
/// smoothed once, scaled by `10`, then smoothed again:
|
||||
///
|
||||
/// ```text
|
||||
/// roc_t = (price_t / price_{t−1} − 1) · 100
|
||||
/// smoothed_t = customEMA(roc, smoothing1)_t
|
||||
/// PMO_t = customEMA(10 · smoothed, smoothing2)_t
|
||||
/// ```
|
||||
///
|
||||
/// `customEMA` is the `DecisionPoint` smoothing: an exponential average whose
|
||||
/// smoothing constant is `2 / period` (not the textbook `2 / (period + 1)`),
|
||||
/// seeded from the very first value. The conventional periods are `35` and
|
||||
/// `20`. The classic PMO **signal line** is simply a 10-period EMA of this
|
||||
/// PMO line — compose it with [`Chain`](crate::Chain) and an [`Ema`] if you
|
||||
/// need it.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Pmo};
|
||||
///
|
||||
/// let mut indicator = Pmo::new(35, 20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pmo {
|
||||
smoothing1: usize,
|
||||
smoothing2: usize,
|
||||
prev_price: Option<f64>,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Pmo {
|
||||
/// Construct a new PMO with the two smoothing periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if either is `1` (the smoothing constant
|
||||
/// `2 / period` must not exceed `1`).
|
||||
pub fn new(smoothing1: usize, smoothing2: usize) -> Result<Self> {
|
||||
if smoothing1 == 0 || smoothing2 == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if smoothing1 < 2 || smoothing2 < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "PMO smoothing periods must be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
smoothing1,
|
||||
smoothing2,
|
||||
prev_price: None,
|
||||
ema1: Ema::with_alpha(2.0 / smoothing1 as f64)?,
|
||||
ema2: Ema::with_alpha(2.0 / smoothing2 as f64)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(smoothing1, smoothing2)` periods.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.smoothing1, self.smoothing2)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Pmo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; state is left untouched.
|
||||
return self.current;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
|
||||
let roc = if prev == 0.0 {
|
||||
// Undefined ratio against a zero price: treat momentum as flat.
|
||||
0.0
|
||||
} else {
|
||||
(input / prev - 1.0) * 100.0
|
||||
};
|
||||
let smoothed = self.ema1.update(roc)?;
|
||||
let pmo = self.ema2.update(10.0 * smoothed)?;
|
||||
self.current = Some(pmo);
|
||||
Some(pmo)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first ROC needs a previous price; both customEMAs seed from
|
||||
// their first input, so the first PMO lands on the second update.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PMO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Pmo::new(0, 20), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Pmo::new(35, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_period_one() {
|
||||
assert!(matches!(Pmo::new(1, 20), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Pmo::new(35, 1), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_second_update() {
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
assert_eq!(pmo.warmup_period(), 2);
|
||||
assert_eq!(pmo.update(100.0), None);
|
||||
assert!(pmo.update(101.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Flat prices -> ROC is always 0 -> both smoothings stay at 0.
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
let out = pmo.batch(&[100.0; 60]);
|
||||
for v in out.iter().skip(2).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_uptrend_is_positive() {
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
|
||||
let out = pmo.batch(&prices);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(
|
||||
*last > 0.0,
|
||||
"steady uptrend PMO should be positive, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
let out = pmo.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(pmo.update(f64::NAN), last);
|
||||
assert_eq!(pmo.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
pmo.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(pmo.is_ready());
|
||||
pmo.reset();
|
||||
assert!(!pmo.is_ready());
|
||||
assert_eq!(pmo.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
|
||||
.collect();
|
||||
let batch = Pmo::new(35, 20).unwrap().batch(&prices);
|
||||
let mut b = Pmo::new(35, 20).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Percentage Price Oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Ema;
|
||||
|
||||
/// Percentage Price Oscillator — MACD expressed as a percentage.
|
||||
///
|
||||
/// PPO is the gap between a fast and a slow EMA, divided by the slow EMA and
|
||||
/// scaled to a percentage:
|
||||
///
|
||||
/// ```text
|
||||
/// PPO = 100 · (EMA_fast − EMA_slow) / EMA_slow
|
||||
/// ```
|
||||
///
|
||||
/// Dividing by the slow EMA makes PPO **scale-free**: a `PPO` of `1.5` means
|
||||
/// "the fast EMA is 1.5 % above the slow EMA" on any instrument, so PPO
|
||||
/// readings *are* comparable across assets — unlike the raw price-unit
|
||||
/// [`MacdIndicator`](crate::MacdIndicator). The classic PPO **signal line** is
|
||||
/// a 9-period EMA of this PPO line; compose it with [`Chain`](crate::Chain)
|
||||
/// and an [`Ema`] if you need it.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Ppo};
|
||||
///
|
||||
/// let mut indicator = Ppo::new(12, 26).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ppo {
|
||||
fast: usize,
|
||||
slow: usize,
|
||||
ema_fast: Ema,
|
||||
ema_slow: Ema,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Ppo {
|
||||
/// Construct a new PPO with the `fast` and `slow` EMA periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`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: "PPO fast period must be < slow period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast,
|
||||
slow,
|
||||
ema_fast: Ema::new(fast)?,
|
||||
ema_slow: Ema::new(slow)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(fast, slow)` periods.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.fast, self.slow)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Ppo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; the EMAs are not advanced.
|
||||
return self.current;
|
||||
}
|
||||
let fast = self.ema_fast.update(input);
|
||||
let slow = self.ema_slow.update(input);
|
||||
match (fast, slow) {
|
||||
(Some(f), Some(s)) => {
|
||||
let ppo = if s == 0.0 {
|
||||
// Undefined ratio against a zero slow EMA: report flat.
|
||||
0.0
|
||||
} else {
|
||||
100.0 * (f - s) / s
|
||||
};
|
||||
self.current = Some(ppo);
|
||||
Some(ppo)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema_fast.reset();
|
||||
self.ema_slow.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The slow EMA is the last to seed.
|
||||
self.slow
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PPO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_fast_not_less_than_slow() {
|
||||
assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||
assert_eq!(ppo.warmup_period(), 6);
|
||||
let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Both EMAs converge to the constant, so their gap is zero.
|
||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||
let out = ppo.batch(&[100.0; 60]);
|
||||
for v in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
// In a rising series the fast EMA leads the slow EMA, so PPO > 0.
|
||||
let mut ppo = Ppo::new(5, 12).unwrap();
|
||||
let out = ppo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(*last > 0.0, "uptrend PPO should be positive, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||
let out = ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(ppo.update(f64::NAN), last);
|
||||
assert_eq!(ppo.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||
ppo.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(ppo.is_ready());
|
||||
ppo.reset();
|
||||
assert!(!ppo.is_ready());
|
||||
assert_eq!(ppo.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Ppo::new(12, 26).unwrap().batch(&prices);
|
||||
let mut b = Ppo::new(12, 26).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,40 @@ enum Trend {
|
||||
/// Implementation follows Wilder's original recursion: each step computes a new
|
||||
/// SAR from the previous SAR, extreme point (EP) and acceleration factor (AF);
|
||||
/// the trend flips when price crosses the SAR.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Psar};
|
||||
///
|
||||
/// let mut indicator = Psar::new(0.02, 0.02, 0.2).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 Psar {
|
||||
af_start: f64,
|
||||
af_step: f64,
|
||||
af_max: f64,
|
||||
|
||||
/// `true` once the first candle has been observed and the seed values
|
||||
/// (`prev_high`, `prev_low`, `sar`, `ep`) are valid. `false` is the
|
||||
/// constructor / `reset()` state in which the compute-fields hold
|
||||
/// `f64::NAN` sentinels.
|
||||
initialised: bool,
|
||||
/// `true` once `update` has returned the first `Some(sar)`. Drives
|
||||
/// [`Indicator::is_ready`] so it matches the convention of every other
|
||||
/// indicator: `is_ready() == true` ↔ the most recent `update` produced
|
||||
/// (or could produce) a real value. PSAR's seed candle returns `None`
|
||||
/// while `initialised` flips to `true`, which is why `is_ready` cannot
|
||||
/// just mirror `initialised`.
|
||||
has_emitted: bool,
|
||||
prev_high: f64,
|
||||
prev_low: f64,
|
||||
trend: Trend,
|
||||
@@ -53,11 +80,17 @@ impl Psar {
|
||||
af_step,
|
||||
af_max,
|
||||
initialised: false,
|
||||
prev_high: 0.0,
|
||||
prev_low: 0.0,
|
||||
has_emitted: false,
|
||||
// NaN sentinels: any read of these fields before the seed candle
|
||||
// overwrites them is a logic bug. The `initialised` flag gates
|
||||
// every read, and the `debug_assert!` in `update` makes the
|
||||
// invariant explicit so a future refactor cannot silently treat a
|
||||
// sentinel as a real price.
|
||||
prev_high: f64::NAN,
|
||||
prev_low: f64::NAN,
|
||||
trend: Trend::Up,
|
||||
sar: 0.0,
|
||||
ep: 0.0,
|
||||
sar: f64::NAN,
|
||||
ep: f64::NAN,
|
||||
af: af_start,
|
||||
})
|
||||
}
|
||||
@@ -74,8 +107,9 @@ impl Indicator for Psar {
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if !self.initialised {
|
||||
// Seed: the first emitted SAR comes on the second candle. Initial trend
|
||||
// is chosen by whether the second close is above or below the first.
|
||||
// Seed on the first candle; the first SAR is emitted on the second.
|
||||
// The initial trend is assumed Up — PSAR's reversal logic flips it
|
||||
// within the first few bars if the market is actually falling.
|
||||
self.prev_high = candle.high;
|
||||
self.prev_low = candle.low;
|
||||
self.sar = candle.low;
|
||||
@@ -83,9 +117,22 @@ impl Indicator for Psar {
|
||||
self.trend = Trend::Up;
|
||||
self.af = self.af_start;
|
||||
self.initialised = true;
|
||||
// `has_emitted` stays false — this is the seed bar; the first
|
||||
// `Some` lands on the next call.
|
||||
return None;
|
||||
}
|
||||
|
||||
// After `initialised` flips to `true`, every compute field is guaranteed
|
||||
// finite. This guards against a future refactor that changes the seed
|
||||
// gate but leaves a NaN sentinel reachable.
|
||||
debug_assert!(
|
||||
self.prev_high.is_finite()
|
||||
&& self.prev_low.is_finite()
|
||||
&& self.sar.is_finite()
|
||||
&& self.ep.is_finite(),
|
||||
"PSAR seed state must be finite once initialised"
|
||||
);
|
||||
|
||||
// Predicted SAR for this period (before clamping to prior two extremes).
|
||||
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
|
||||
|
||||
@@ -138,14 +185,22 @@ impl Indicator for Psar {
|
||||
self.sar = output_sar;
|
||||
self.prev_high = candle.high;
|
||||
self.prev_low = candle.low;
|
||||
self.has_emitted = true;
|
||||
Some(output_sar)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
// Restore every field to its constructor state. The compute fields
|
||||
// return to `f64::NAN` sentinels so a future refactor that reads them
|
||||
// before re-seeding cannot silently treat `0.0` as a real price.
|
||||
self.initialised = false;
|
||||
self.has_emitted = false;
|
||||
self.prev_high = f64::NAN;
|
||||
self.prev_low = f64::NAN;
|
||||
self.trend = Trend::Up;
|
||||
self.sar = f64::NAN;
|
||||
self.ep = f64::NAN;
|
||||
self.af = self.af_start;
|
||||
self.sar = 0.0;
|
||||
self.ep = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
@@ -153,7 +208,13 @@ impl Indicator for Psar {
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.initialised
|
||||
// Match the convention of every other indicator: `is_ready` flips to
|
||||
// `true` only once a real value has been returned. The previous
|
||||
// implementation returned `self.initialised`, which is `true` *after*
|
||||
// the seed candle (which itself returns `None`) — so a streaming
|
||||
// consumer that wrote `if ind.is_ready() { use(ind.update(c)?) }`
|
||||
// would hit a `None` it didn't expect. (Audit finding R6.)
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -238,4 +299,44 @@ mod tests {
|
||||
assert!(Psar::new(0.30, 0.02, 0.20).is_err());
|
||||
assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_ready_only_after_first_some_value() {
|
||||
// Audit R6: the previous implementation flipped `is_ready` to true on
|
||||
// the seed candle (which returns `None`), making the convention
|
||||
// `is_ready == last_value.is_some()` a lie. The new gate is
|
||||
// `has_emitted`, set when `update` returns its first `Some`.
|
||||
let mut psar = Psar::classic();
|
||||
assert!(!psar.is_ready(), "fresh PSAR must not be ready");
|
||||
let first = psar.update(c(11.0, 9.0, 10.0));
|
||||
assert!(first.is_none(), "seed candle returns None by design");
|
||||
assert!(
|
||||
!psar.is_ready(),
|
||||
"is_ready must stay false until a Some value is produced"
|
||||
);
|
||||
let second = psar.update(c(12.0, 10.0, 11.0));
|
||||
assert!(second.is_some(), "second candle must emit");
|
||||
assert!(
|
||||
psar.is_ready(),
|
||||
"is_ready must flip to true once a real value has been returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_allows_clean_reuse() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
c(base + 0.5, base - 0.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut psar = Psar::classic();
|
||||
let first = psar.batch(&candles);
|
||||
assert!(psar.is_ready());
|
||||
psar.reset();
|
||||
assert!(!psar.is_ready());
|
||||
// A reset instance must reproduce a pristine run bit for bit.
|
||||
let second = psar.batch(&candles);
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,27 @@ use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rate of Change as a percentage: `(close - close[period]) / close[period] * 100`.
|
||||
///
|
||||
/// Non-finite inputs are ignored and leave the window untouched; the last
|
||||
/// computed value is returned instead, matching the SMA / EMA convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Roc};
|
||||
///
|
||||
/// let mut indicator = Roc::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Roc {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Roc {
|
||||
@@ -22,6 +39,7 @@ impl Roc {
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period + 1),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,8 +54,9 @@ impl Indicator for Roc {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Non-finite inputs are ignored: return the last value, leave state as is.
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period + 1 {
|
||||
self.window.pop_front();
|
||||
@@ -47,14 +66,18 @@ impl Indicator for Roc {
|
||||
return None;
|
||||
}
|
||||
let prev = *self.window.front().expect("non-empty");
|
||||
if prev == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((input - prev) / prev * 100.0)
|
||||
let roc = if prev == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(input - prev) / prev * 100.0
|
||||
};
|
||||
self.last = Some(roc);
|
||||
Some(roc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
@@ -117,4 +140,20 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
assert!(Roc::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut roc = Roc::new(3).unwrap();
|
||||
let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
|
||||
let ready = out[3].expect("ROC(3) ready after four inputs");
|
||||
// Non-finite inputs return the last value without sliding the window.
|
||||
assert_eq!(roc.update(f64::NAN), Some(ready));
|
||||
assert_eq!(roc.update(f64::INFINITY), Some(ready));
|
||||
// Window untouched: the next finite input still references prev = 105.
|
||||
assert_relative_eq!(
|
||||
roc.update(115.0).unwrap(),
|
||||
(115.0 - 105.0) / 105.0 * 100.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ use crate::traits::Indicator;
|
||||
/// is produced after `period + 1` inputs: the seed averages the first `period`
|
||||
/// gains and losses, and the first emitted RSI corresponds to the input at
|
||||
/// index `period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rsi};
|
||||
///
|
||||
/// let mut indicator = Rsi::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rsi {
|
||||
period: usize,
|
||||
@@ -140,6 +153,50 @@ mod tests {
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference: Wilder RSI computed straight from the definition.
|
||||
fn rsi_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
|
||||
let n = period as f64;
|
||||
let mut out = vec![None; prices.len()];
|
||||
let mut gains: Vec<f64> = Vec::new();
|
||||
let mut losses: Vec<f64> = Vec::new();
|
||||
let mut avg_gain: Option<f64> = None;
|
||||
let mut avg_loss: Option<f64> = None;
|
||||
let rsi_val = |ag: f64, al: f64| -> f64 {
|
||||
if al == 0.0 {
|
||||
if ag == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0
|
||||
}
|
||||
} else {
|
||||
100.0 - 100.0 / (1.0 + ag / al)
|
||||
}
|
||||
};
|
||||
for i in 1..prices.len() {
|
||||
let diff = prices[i] - prices[i - 1];
|
||||
let gain = if diff > 0.0 { diff } else { 0.0 };
|
||||
let loss = if diff < 0.0 { -diff } else { 0.0 };
|
||||
if let (Some(ag), Some(al)) = (avg_gain, avg_loss) {
|
||||
let nag = (ag * (n - 1.0) + gain) / n;
|
||||
let nal = (al * (n - 1.0) + loss) / n;
|
||||
avg_gain = Some(nag);
|
||||
avg_loss = Some(nal);
|
||||
out[i] = Some(rsi_val(nag, nal));
|
||||
} else {
|
||||
gains.push(gain);
|
||||
losses.push(loss);
|
||||
if gains.len() == period {
|
||||
let ag = gains.iter().sum::<f64>() / n;
|
||||
let al = losses.iter().sum::<f64>() / n;
|
||||
avg_gain = Some(ag);
|
||||
avg_loss = Some(al);
|
||||
out[i] = Some(rsi_val(ag, al));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
|
||||
@@ -244,4 +301,39 @@ mod tests {
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rsi = Rsi::new(3).unwrap();
|
||||
rsi.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let before = rsi.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(rsi.update(f64::NAN), before);
|
||||
assert_eq!(rsi.update(f64::INFINITY), before);
|
||||
assert_eq!(rsi.value(), before);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
fn rsi_matches_naive(
|
||||
period in 1usize..20,
|
||||
prices in proptest::collection::vec(1.0_f64..1000.0, 0..150),
|
||||
) {
|
||||
let mut rsi = Rsi::new(period).unwrap();
|
||||
let got = rsi.batch(&prices);
|
||||
let want = rsi_naive(&prices, period);
|
||||
proptest::prop_assert_eq!(got.len(), want.len());
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => proptest::prop_assert!(
|
||||
(a - b).abs() < 1e-7,
|
||||
"got={a} want={b}"
|
||||
),
|
||||
_ => proptest::prop_assert!(false, "warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,45 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// Maintains a rolling sum so each update is O(1). Output equals
|
||||
/// `sum(last `period` prices) / period` once the window is full; `None` before.
|
||||
///
|
||||
/// On long-running streams a single-subtract incremental sum can accumulate
|
||||
/// rounding error (catastrophic cancellation when values of very different
|
||||
/// magnitudes are alternately added and removed). To keep drift bounded, the
|
||||
/// running sum is reseeded from the live window every `16 · period` updates —
|
||||
/// O(1) amortised cost (`O(period)` work amortised over `O(period)` updates),
|
||||
/// zero observable behaviour change on inputs that did not drift to begin
|
||||
/// with, and a strict cap on accumulated rounding for streams that did.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Sma};
|
||||
///
|
||||
/// let mut indicator = Sma::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sma {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
/// Number of finite updates since the running `sum` was last reseeded from
|
||||
/// the live window. Caps accumulated floating-point drift on long streams.
|
||||
/// See [`RECOMPUTE_EVERY`] below.
|
||||
updates_since_recompute: usize,
|
||||
}
|
||||
|
||||
/// How often (in finite updates) the incremental sum is reseeded from the live
|
||||
/// window. The multiplier `16` is the smallest power of two that keeps the
|
||||
/// amortised cost flat under any `period` while still bounding any drift to
|
||||
/// roughly `16 · period · ULP · max(|x|)` — sub-picodollar on real-world price
|
||||
/// scales.
|
||||
const RECOMPUTE_EVERY: usize = 16;
|
||||
|
||||
impl Sma {
|
||||
/// Construct a new SMA with the given window length.
|
||||
///
|
||||
@@ -30,6 +62,7 @@ impl Sma {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
updates_since_recompute: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,20 +90,26 @@ impl Indicator for Sma {
|
||||
return self.value();
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
// Drop the oldest from the sum to keep numerical drift bounded by recomputing
|
||||
// the sum after each pop; a single subtract works in O(1) and is acceptable
|
||||
// here because we use f64 throughout.
|
||||
// Slide: drop the oldest, then add the new. Each step is a single
|
||||
// f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise.
|
||||
// The periodic reseed below caps the accumulated drift.
|
||||
let old = self.window.pop_front().expect("window non-empty");
|
||||
self.sum -= old;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
|
||||
self.sum = self.window.iter().copied().sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
@@ -197,4 +236,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-running stability check. Runs more updates than `RECOMPUTE_EVERY *
|
||||
/// period` so the periodic reseed must fire several times, then asserts
|
||||
/// that the reported SMA still equals a fresh from-scratch mean over the
|
||||
/// live window to within tight floating-point tolerance. Inputs swing
|
||||
/// between two magnitudes (`1e9` and `1.0`) — a pattern designed to
|
||||
/// expose catastrophic cancellation in a naive single-subtract sum.
|
||||
#[test]
|
||||
fn long_stream_drift_stays_bounded() {
|
||||
let period = 20;
|
||||
let mut sma = Sma::new(period).unwrap();
|
||||
let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
|
||||
// `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times.
|
||||
let n_updates = 16 * period * 5;
|
||||
for i in 0..n_updates {
|
||||
let v = if i.is_multiple_of(2) { 1e9 } else { 1.0 };
|
||||
sma.update(v);
|
||||
if window.len() == period {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(v);
|
||||
}
|
||||
let from_scratch: f64 = window.iter().sum::<f64>() / period as f64;
|
||||
let got = sma.value().expect("warmed up");
|
||||
assert!(
|
||||
(got - from_scratch).abs() < 1e-6,
|
||||
"SMA drift exceeds 1e-6 over {n_updates} updates: got={got}, scratch={from_scratch}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Smoothed Moving Average (Wilder's RMA).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Smoothed Moving Average — Wilder's running moving average, also known as
|
||||
/// RMA.
|
||||
///
|
||||
/// Seeded with the simple average of the first `period` inputs, then advanced
|
||||
/// by `SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period`. This is an
|
||||
/// exponential average with a slow `1 / period` smoothing factor and is the
|
||||
/// average underlying Wilder's RSI and ATR. The first output lands after
|
||||
/// exactly `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Smma};
|
||||
///
|
||||
/// let mut indicator = Smma::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Smma {
|
||||
period: usize,
|
||||
/// Inputs collected while seeding (before the first value is produced).
|
||||
seed: VecDeque<f64>,
|
||||
seed_sum: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Smma {
|
||||
/// Construct a new SMMA 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,
|
||||
seed: VecDeque::with_capacity(period),
|
||||
seed_sum: 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 Smma {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored, leaving state untouched.
|
||||
return self.current;
|
||||
}
|
||||
if let Some(prev) = self.current {
|
||||
let period = self.period as f64;
|
||||
self.current = Some((prev * (period - 1.0) + input) / period);
|
||||
} else {
|
||||
self.seed.push_back(input);
|
||||
self.seed_sum += input;
|
||||
if self.seed.len() == self.period {
|
||||
self.current = Some(self.seed_sum / self.period as f64);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.seed.clear();
|
||||
self.seed_sum = 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 {
|
||||
"SMMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_recurrence() {
|
||||
// SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
|
||||
let mut smma = Smma::new(3).unwrap();
|
||||
assert_eq!(smma.update(1.0), None);
|
||||
assert_eq!(smma.update(2.0), None);
|
||||
assert_eq!(smma.update(3.0), Some(2.0));
|
||||
assert_relative_eq!(
|
||||
smma.update(4.0).unwrap(),
|
||||
(2.0 * 2.0 + 4.0) / 3.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
assert_relative_eq!(
|
||||
smma.update(5.0).unwrap(),
|
||||
((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_one_is_pass_through() {
|
||||
let mut smma = Smma::new(1).unwrap();
|
||||
assert_eq!(smma.update(5.0), Some(5.0));
|
||||
assert_eq!(smma.update(10.0), Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
let mut smma = Smma::new(5).unwrap();
|
||||
let out = smma.batch(&[7.0; 20]);
|
||||
for x in out.iter().skip(4) {
|
||||
assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut smma = Smma::new(3).unwrap();
|
||||
smma.batch(&[1.0, 2.0, 3.0]);
|
||||
assert_eq!(smma.update(f64::NAN), Some(2.0));
|
||||
assert_eq!(smma.update(f64::INFINITY), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut smma = Smma::new(3).unwrap();
|
||||
smma.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(smma.is_ready());
|
||||
smma.reset();
|
||||
assert!(!smma.is_ready());
|
||||
assert_eq!(smma.update(10.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
|
||||
let batch = Smma::new(7).unwrap().batch(&prices);
|
||||
let mut b = Smma::new(7).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Rolling population standard deviation.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling population standard deviation over the last `period` values.
|
||||
///
|
||||
/// ```text
|
||||
/// mean = (1/n) · Σ price
|
||||
/// variance = (1/n) · Σ price² − mean²
|
||||
/// StdDev = √variance
|
||||
/// ```
|
||||
///
|
||||
/// This is the **population** standard deviation (divisor `n`, not `n − 1`) —
|
||||
/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands).
|
||||
/// It is maintained as an O(1) rolling state machine: a running sum and a
|
||||
/// running sum-of-squares, updated by one add and one subtract per bar. Tiny
|
||||
/// negative variances from floating-point cancellation are clamped to zero
|
||||
/// before the square root.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, StdDev};
|
||||
///
|
||||
/// let mut indicator = StdDev::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StdDev {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl StdDev {
|
||||
/// Construct a new rolling standard deviation 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,
|
||||
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 StdDev {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; the window is left untouched.
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
// Clamp floating-point cancellation noise: variance is never negative.
|
||||
let variance = (self.sum_sq / n - mean * mean).max(0.0);
|
||||
let sd = variance.sqrt();
|
||||
self.last = Some(sd);
|
||||
Some(sd)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 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 {
|
||||
"StdDev"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
|
||||
let mut sd = StdDev::new(3).unwrap();
|
||||
let out = sd.batch(&[2.0, 4.0, 6.0]);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[1], None);
|
||||
assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut sd = StdDev::new(5).unwrap();
|
||||
let out = sd.batch(&[42.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_naive_definition() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let period = 10;
|
||||
let got = StdDev::new(period).unwrap().batch(&prices);
|
||||
for (i, g) in got.iter().enumerate() {
|
||||
if let Some(value) = g {
|
||||
let window = &prices[i + 1 - period..=i];
|
||||
let mean = window.iter().sum::<f64>() / period as f64;
|
||||
let var = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||
assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut sd = StdDev::new(3).unwrap();
|
||||
let out = sd.batch(&[2.0, 4.0, 6.0]);
|
||||
let last = out[2];
|
||||
assert!(last.is_some());
|
||||
assert_eq!(sd.update(f64::NAN), last);
|
||||
assert_eq!(sd.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sd = StdDev::new(3).unwrap();
|
||||
sd.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(sd.is_ready());
|
||||
sd.reset();
|
||||
assert!(!sd.is_ready());
|
||||
assert_eq!(sd.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = StdDev::new(14).unwrap().batch(&prices);
|
||||
let mut b = StdDev::new(14).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Stochastic RSI.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Rsi;
|
||||
|
||||
/// Stochastic RSI — the Stochastic Oscillator formula applied to the RSI series
|
||||
/// instead of to price.
|
||||
///
|
||||
/// RSI itself rarely reaches its `[0, 100]` extremes, so it spends most of its
|
||||
/// life bunched in the middle of the range. `StochRSI` re-scales it: it reports
|
||||
/// where the *current* RSI sits within its own high/low range over the last
|
||||
/// `stoch_period` bars, which makes overbought/oversold turns far easier to
|
||||
/// see.
|
||||
///
|
||||
/// ```text
|
||||
/// StochRSI = 100 · (RSI − min(RSI, stoch_period)) / (max(RSI, …) − min(RSI, …))
|
||||
/// ```
|
||||
///
|
||||
/// The output is bounded in `[0, 100]`. A flat RSI window (zero range) is
|
||||
/// reported as the neutral `50.0`, matching the [`Stochastic`](crate::Stochastic)
|
||||
/// convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, StochRsi};
|
||||
///
|
||||
/// let mut indicator = StochRsi::new(14, 14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin() * 10.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StochRsi {
|
||||
rsi_period: usize,
|
||||
stoch_period: usize,
|
||||
rsi: Rsi,
|
||||
/// Rolling window of the last `stoch_period` RSI values.
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl StochRsi {
|
||||
/// Construct a new `StochRSI` with the RSI period and the stochastic lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`.
|
||||
pub fn new(rsi_period: usize, stoch_period: usize) -> Result<Self> {
|
||||
if rsi_period == 0 || stoch_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rsi_period,
|
||||
stoch_period,
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
window: VecDeque::with_capacity(stoch_period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(rsi_period, stoch_period)` pair.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.rsi_period, self.stoch_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for StochRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; state is left untouched.
|
||||
return self.last;
|
||||
}
|
||||
let rsi_value = self.rsi.update(input)?;
|
||||
|
||||
if self.window.len() == self.stoch_period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(rsi_value);
|
||||
if self.window.len() < self.stoch_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;
|
||||
let stoch = if range == 0.0 {
|
||||
// Flat RSI window: report the neutral midpoint.
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (rsi_value - min) / range
|
||||
};
|
||||
self.last = Some(stoch);
|
||||
Some(stoch)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RSI emits its first value at input `rsi_period + 1`; the stochastic
|
||||
// window then needs `stoch_period` RSI values.
|
||||
self.rsi_period + self.stoch_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"StochRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(StochRsi::new(0, 14), Err(Error::PeriodZero)));
|
||||
assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
assert_eq!(sr.warmup_period(), 9);
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
|
||||
.collect();
|
||||
let out = sr.batch(&prices);
|
||||
for v in out.iter().take(8) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[8].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_rsi_window_yields_50() {
|
||||
// A constant price series gives a constant RSI (50.0), so the StochRSI
|
||||
// window has zero range and reports the neutral midpoint.
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
let out = sr.batch(&[100.0; 40]);
|
||||
for v in out.iter().skip(9).flatten() {
|
||||
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_50() {
|
||||
// A pure uptrend pins RSI at 100, so its window is again flat.
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
let out = sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().skip(9).flatten() {
|
||||
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_within_0_100() {
|
||||
let mut sr = StochRsi::new(14, 14).unwrap();
|
||||
let prices: Vec<f64> = (1..=200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 15.0 + (f64::from(i) * 0.07).cos() * 6.0)
|
||||
.collect();
|
||||
for v in sr.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "StochRSI out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.6).sin() * 8.0)
|
||||
.collect();
|
||||
let out = sr.batch(&prices);
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(sr.update(f64::NAN), last);
|
||||
assert_eq!(sr.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
sr.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(sr.is_ready());
|
||||
sr.reset();
|
||||
assert!(!sr.is_ready());
|
||||
assert_eq!(sr.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
|
||||
.collect();
|
||||
let batch = StochRsi::new(14, 14).unwrap().batch(&prices);
|
||||
let mut b = StochRsi::new(14, 14).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,22 @@ pub struct StochasticOutput {
|
||||
///
|
||||
/// Maintains rolling highest-high and lowest-low over the lookback period via a
|
||||
/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Stochastic};
|
||||
///
|
||||
/// let mut indicator = Stochastic::new(5, 3).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 Stochastic {
|
||||
k_period: usize,
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! `SuperTrend`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `SuperTrend` output: the trailing-stop level and the trend direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SuperTrendOutput {
|
||||
/// The `SuperTrend` line — the active trailing-stop level for this bar.
|
||||
pub value: f64,
|
||||
/// Trend direction: `+1.0` in an uptrend (the line sits below price),
|
||||
/// `-1.0` in a downtrend (the line sits above price).
|
||||
pub direction: f64,
|
||||
}
|
||||
|
||||
/// Previous-bar state carried forward by the `SuperTrend` recurrence.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PrevState {
|
||||
final_upper: f64,
|
||||
final_lower: f64,
|
||||
close: f64,
|
||||
direction: f64,
|
||||
}
|
||||
|
||||
/// `SuperTrend` — an ATR-banded trailing stop that flips sides on a close
|
||||
/// through the band.
|
||||
///
|
||||
/// ```text
|
||||
/// hl2 = (high + low) / 2
|
||||
/// basic_upper = hl2 + multiplier · ATR
|
||||
/// basic_lower = hl2 − multiplier · ATR
|
||||
///
|
||||
/// final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper
|
||||
/// else prev_final_upper
|
||||
/// final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower
|
||||
/// else prev_final_lower
|
||||
///
|
||||
/// in a downtrend: stay down while close <= final_upper, else flip up
|
||||
/// in an uptrend: stay up while close >= final_lower, else flip down
|
||||
/// SuperTrend = final_lower in an uptrend, final_upper in a downtrend
|
||||
/// ```
|
||||
///
|
||||
/// The final bands ratchet — the upper band only moves down (and the lower
|
||||
/// band only moves up) until price closes through it, which flips the trend
|
||||
/// and hands the role of trailing stop to the opposite band. The first
|
||||
/// ATR-ready bar seeds the trend as up. Wilder's classic configuration is
|
||||
/// `ATR(10)` with a `3.0` multiplier.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SuperTrend};
|
||||
///
|
||||
/// let mut indicator = SuperTrend::classic();
|
||||
/// 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 SuperTrend {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
atr_period: usize,
|
||||
prev: Option<PrevState>,
|
||||
}
|
||||
|
||||
impl SuperTrend {
|
||||
/// Construct a `SuperTrend` with an explicit ATR period and band multiplier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
multiplier,
|
||||
atr_period,
|
||||
prev: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wilder's classic configuration: `ATR(10)` with a `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 3.0).expect("classic SuperTrend params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SuperTrend {
|
||||
type Input = Candle;
|
||||
type Output = SuperTrendOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let hl2 = (candle.high + candle.low) / 2.0;
|
||||
let basic_upper = hl2 + self.multiplier * atr;
|
||||
let basic_lower = hl2 - self.multiplier * atr;
|
||||
|
||||
let (final_upper, final_lower, direction) = match self.prev {
|
||||
None => {
|
||||
// First ATR-ready bar: no prior bands, seed the trend as up.
|
||||
(basic_upper, basic_lower, 1.0)
|
||||
}
|
||||
Some(p) => {
|
||||
let final_upper = if basic_upper < p.final_upper || p.close > p.final_upper {
|
||||
basic_upper
|
||||
} else {
|
||||
p.final_upper
|
||||
};
|
||||
let final_lower = if basic_lower > p.final_lower || p.close < p.final_lower {
|
||||
basic_lower
|
||||
} else {
|
||||
p.final_lower
|
||||
};
|
||||
let direction = if p.direction < 0.0 {
|
||||
// Previous downtrend — the line was the upper band.
|
||||
if candle.close <= final_upper {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
} else {
|
||||
// Previous uptrend — the line was the lower band.
|
||||
if candle.close >= final_lower {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
};
|
||||
(final_upper, final_lower, direction)
|
||||
}
|
||||
};
|
||||
|
||||
let value = if direction > 0.0 {
|
||||
final_lower
|
||||
} else {
|
||||
final_upper
|
||||
};
|
||||
self.prev = Some(PrevState {
|
||||
final_upper,
|
||||
final_lower,
|
||||
close: candle.close,
|
||||
direction,
|
||||
});
|
||||
Some(SuperTrendOutput { value, direction })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.prev = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SuperTrend"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_keeps_line_below_price_and_direction_up() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + 2.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut st = SuperTrend::classic();
|
||||
for (o, candle) in st.batch(&candles).into_iter().zip(candles.iter()) {
|
||||
if let Some(o) = o {
|
||||
assert_eq!(o.direction, 1.0, "a pure uptrend stays in direction +1");
|
||||
assert!(o.value < candle.close, "the stop line sits below price");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_keeps_line_above_price_and_direction_down() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 220.0 - 2.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base - 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut st = SuperTrend::classic();
|
||||
let emitted: Vec<(SuperTrendOutput, f64)> = st
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
// The seed bar starts the trend up; a steep decline flips it within a
|
||||
// few bars. The settled tail must be a clean downtrend.
|
||||
for &(o, close) in emitted.iter().skip(10) {
|
||||
assert_eq!(
|
||||
o.direction, -1.0,
|
||||
"a steep downtrend settles to direction -1"
|
||||
);
|
||||
assert!(o.value > close, "the stop line sits above price");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_flips_when_price_reverses() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - i as f64;
|
||||
c(base + 1.0, base - 1.0, base - 0.5, 40 + i)
|
||||
}));
|
||||
let mut st = SuperTrend::classic();
|
||||
let dirs: Vec<f64> = st
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|o| o.direction)
|
||||
.collect();
|
||||
assert!(dirs.iter().any(|&d| d > 0.0), "expected an uptrend stretch");
|
||||
assert!(
|
||||
dirs.iter().any(|&d| d < 0.0),
|
||||
"expected a downtrend stretch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
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 st = SuperTrend::classic();
|
||||
let out = st.batch(&candles);
|
||||
assert_eq!(st.warmup_period(), 10);
|
||||
for (i, v) in out.iter().enumerate().take(9) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(SuperTrend::new(0, 3.0).is_err());
|
||||
assert!(SuperTrend::new(10, 0.0).is_err());
|
||||
assert!(SuperTrend::new(10, -1.0).is_err());
|
||||
assert!(SuperTrend::new(10, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut st = SuperTrend::classic();
|
||||
st.batch(&candles);
|
||||
assert!(st.is_ready());
|
||||
st.reset();
|
||||
assert!(!st.is_ready());
|
||||
assert_eq!(st.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 = SuperTrend::classic();
|
||||
let mut b = SuperTrend::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Tillson T3 Moving Average.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Ema;
|
||||
|
||||
/// Tillson's T3 — a six-fold cascaded EMA recombined with a *volume factor* `v`.
|
||||
///
|
||||
/// T3 is the generalised DEMA applied three times. Tim Tillson's expansion of
|
||||
/// that triple application over six chained EMAs (`e1 … e6`, each of the same
|
||||
/// `period`) gives the closed form used here:
|
||||
///
|
||||
/// ```text
|
||||
/// c1 = −v³
|
||||
/// c2 = 3v² + 3v³
|
||||
/// c3 = −6v² − 3v − 3v³
|
||||
/// c4 = 1 + 3v + v³ + 3v²
|
||||
/// T3 = c1·e6 + c2·e5 + c3·e4 + c4·e3
|
||||
/// ```
|
||||
///
|
||||
/// The volume factor `v ∈ [0, 1]` controls the lag/smoothness trade-off:
|
||||
/// `v = 0` collapses T3 to the plain triple-cascaded EMA `e3`, while the
|
||||
/// conventional `v = 0.7` adds a hump that sharpens the response to turns.
|
||||
/// The coefficients always sum to `1`, so a constant series maps to itself.
|
||||
///
|
||||
/// The first output lands after `6·period − 5` inputs — the index at which the
|
||||
/// sixth cascaded EMA seeds.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, T3};
|
||||
///
|
||||
/// let mut indicator = T3::new(5, 0.7).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct T3 {
|
||||
period: usize,
|
||||
v: f64,
|
||||
c1: f64,
|
||||
c2: f64,
|
||||
c3: f64,
|
||||
c4: f64,
|
||||
e1: Ema,
|
||||
e2: Ema,
|
||||
e3: Ema,
|
||||
e4: Ema,
|
||||
e5: Ema,
|
||||
e6: Ema,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl T3 {
|
||||
/// Construct a new T3 with the given `period` and volume factor `v`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or
|
||||
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
|
||||
pub fn new(period: usize, v: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "T3 volume factor must be a finite value in [0.0, 1.0]",
|
||||
});
|
||||
}
|
||||
let v2 = v * v;
|
||||
let v3 = v2 * v;
|
||||
Ok(Self {
|
||||
period,
|
||||
v,
|
||||
c1: -v3,
|
||||
c2: 3.0 * v2 + 3.0 * v3,
|
||||
c3: -6.0 * v2 - 3.0 * v - 3.0 * v3,
|
||||
c4: 1.0 + 3.0 * v + v3 + 3.0 * v2,
|
||||
e1: Ema::new(period)?,
|
||||
e2: Ema::new(period)?,
|
||||
e3: Ema::new(period)?,
|
||||
e4: Ema::new(period)?,
|
||||
e5: Ema::new(period)?,
|
||||
e6: Ema::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured volume factor `v`.
|
||||
pub const fn volume_factor(&self) -> f64 {
|
||||
self.v
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for T3 {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; the cascade is not advanced.
|
||||
return self.current;
|
||||
}
|
||||
let e1 = self.e1.update(input)?;
|
||||
let e2 = self.e2.update(e1)?;
|
||||
let e3 = self.e3.update(e2)?;
|
||||
let e4 = self.e4.update(e3)?;
|
||||
let e5 = self.e5.update(e4)?;
|
||||
let e6 = self.e6.update(e5)?;
|
||||
let out = self.c1 * e6 + self.c2 * e5 + self.c3 * e4 + self.c4 * e3;
|
||||
self.current = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.e1.reset();
|
||||
self.e2.reset();
|
||||
self.e3.reset();
|
||||
self.e4.reset();
|
||||
self.e5.reset();
|
||||
self.e6.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6 * self.period - 5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"T3"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_out_of_range_volume_factor() {
|
||||
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(T3::new(5, 1.5), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(
|
||||
T3::new(5, f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(T3::new(5, 0.0).is_ok());
|
||||
assert!(T3::new(5, 1.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_sum_to_one() {
|
||||
// c1 + c2 + c3 + c4 == 1 for any v, so a constant series is preserved.
|
||||
for &v in &[0.0, 0.3, 0.7, 1.0] {
|
||||
let t3 = T3::new(5, v).unwrap();
|
||||
assert_relative_eq!(t3.c1 + t3.c2 + t3.c3 + t3.c4, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut t3 = T3::new(4, 0.7).unwrap();
|
||||
assert_eq!(t3.warmup_period(), 6 * 4 - 5);
|
||||
let out = t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().take(t3.warmup_period() - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[t3.warmup_period() - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
let mut t3 = T3::new(6, 0.7).unwrap();
|
||||
let out = t3.batch(&[50.0; 80]);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert_relative_eq!(*last, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_factor_collapses_to_triple_cascaded_ema() {
|
||||
// With v = 0 the coefficients are c1=c2=c3=0, c4=1, so T3 == e3,
|
||||
// the third stage of the EMA cascade.
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 9.0)
|
||||
.collect();
|
||||
let mut t3 = T3::new(5, 0.0).unwrap();
|
||||
let got = t3.batch(&prices);
|
||||
|
||||
let mut e1 = Ema::new(5).unwrap();
|
||||
let mut e2 = Ema::new(5).unwrap();
|
||||
let mut e3 = Ema::new(5).unwrap();
|
||||
let want: Vec<Option<f64>> = prices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
e1.update(*p)
|
||||
.and_then(|a| e2.update(a))
|
||||
.and_then(|b| e3.update(b))
|
||||
})
|
||||
.collect();
|
||||
|
||||
for i in (t3.warmup_period() - 1)..prices.len() {
|
||||
assert_relative_eq!(got[i].unwrap(), want[i].unwrap(), epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut t3 = T3::new(4, 0.7).unwrap();
|
||||
let out = t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
|
||||
let last = *out.last().unwrap();
|
||||
assert!(last.is_some());
|
||||
assert_eq!(t3.update(f64::NAN), last);
|
||||
assert_eq!(t3.update(f64::INFINITY), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t3 = T3::new(4, 0.7).unwrap();
|
||||
t3.batch(&(1..=60).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(t3.is_ready());
|
||||
t3.reset();
|
||||
assert!(!t3.is_ready());
|
||||
assert_eq!(t3.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 7.0)
|
||||
.collect();
|
||||
let batch = T3::new(7, 0.7).unwrap().batch(&prices);
|
||||
let mut b = T3::new(7, 0.7).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
|
||||
/// where `EMA2 = EMA(EMA1)` and `EMA3 = EMA(EMA2)`.
|
||||
///
|
||||
/// Reduces lag further than DEMA at the cost of more responsiveness to noise.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Tema};
|
||||
///
|
||||
/// let mut indicator = Tema::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tema {
|
||||
ema1: Ema,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Triangular Moving Average.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Sma;
|
||||
|
||||
/// Triangular Moving Average — a simple moving average applied twice, which
|
||||
/// triangular-weights the window so the middle bars carry the most weight and
|
||||
/// the edges the least.
|
||||
///
|
||||
/// For period `n` the two stacked SMAs use lengths `n1` and `n2`:
|
||||
/// an odd `n` uses `n1 = n2 = (n + 1) / 2`; an even `n` uses `n1 = n / 2` and
|
||||
/// `n2 = n / 2 + 1`. Either way the first output lands after exactly `n`
|
||||
/// inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Trima};
|
||||
///
|
||||
/// let mut indicator = Trima::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Trima {
|
||||
period: usize,
|
||||
inner: Sma,
|
||||
outer: Sma,
|
||||
}
|
||||
|
||||
impl Trima {
|
||||
/// Construct a new TRIMA 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);
|
||||
}
|
||||
let (n1, n2) = if period % 2 == 1 {
|
||||
(period.div_ceil(2), period.div_ceil(2))
|
||||
} else {
|
||||
(period / 2, period / 2 + 1)
|
||||
};
|
||||
Ok(Self {
|
||||
period,
|
||||
inner: Sma::new(n1)?,
|
||||
outer: Sma::new(n2)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
self.outer.value()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Trima {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; do not double-feed the inner SMA's
|
||||
// stale value into the outer SMA.
|
||||
return self.outer.value();
|
||||
}
|
||||
// Genuine stacking: the outer SMA consumes the inner SMA's output.
|
||||
match self.inner.update(input) {
|
||||
Some(v) => self.outer.update(v),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
self.outer.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.outer.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TRIMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_period_reference_values() {
|
||||
// TRIMA(5) is SMA(3) of SMA(3).
|
||||
// SMA(3) of 1..=7 -> [_,_,2,3,4,5,6]; SMA(3) of that -> [_,_,_,_,3,4,5].
|
||||
let mut trima = Trima::new(5).unwrap();
|
||||
let out = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[3], None);
|
||||
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[5].unwrap(), 4.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[6].unwrap(), 5.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
// Even period: TRIMA(6) -> SMA(3) of SMA(4); first value at input 6.
|
||||
let mut trima = Trima::new(6).unwrap();
|
||||
let out = trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
assert_eq!(trima.warmup_period(), 6);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
let mut trima = Trima::new(7).unwrap();
|
||||
let out = trima.batch(&[42.0; 20]);
|
||||
for x in out.iter().skip(6) {
|
||||
assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut trima = Trima::new(5).unwrap();
|
||||
let ready = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = ready[4];
|
||||
assert!(last.is_some());
|
||||
assert_eq!(trima.update(f64::NAN), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut trima = Trima::new(5).unwrap();
|
||||
trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(trima.is_ready());
|
||||
trima.reset();
|
||||
assert!(!trima.is_ready());
|
||||
assert_eq!(trima.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let batch = Trima::new(8).unwrap().batch(&prices);
|
||||
let mut b = Trima::new(8).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where
|
||||
/// `TR_t = EMA(EMA(EMA(price)))`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Trix};
|
||||
///
|
||||
/// let mut indicator = Trix::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Trix {
|
||||
ema1: Ema,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! True Range.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// True Range — the single-bar building block of every ATR-based indicator.
|
||||
///
|
||||
/// ```text
|
||||
/// TR = max( high − low, |high − close_prev|, |low − close_prev| )
|
||||
/// ```
|
||||
///
|
||||
/// True Range is the greatest of the bar's own range and the two gaps to the
|
||||
/// previous close, so it captures volatility that opens *between* bars rather
|
||||
/// than only within them. The first bar has no previous close and falls back
|
||||
/// to `high − low`. Where [`Atr`](crate::Atr) smooths this series, `TrueRange`
|
||||
/// exposes it raw, one value per bar.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TrueRange};
|
||||
///
|
||||
/// let mut indicator = TrueRange::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 TrueRange {
|
||||
prev_close: Option<f64>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl TrueRange {
|
||||
/// Construct a new True Range indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev_close: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TrueRange {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tr = candle.true_range(self.prev_close);
|
||||
self.prev_close = Some(candle.close);
|
||||
self.has_emitted = true;
|
||||
Some(tr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TrueRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[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((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// Bar 1 has no previous close -> TR = high - low = 12 - 8 = 4.
|
||||
// Bar 2: prev close 11, TR = max(10-9, |10-11|, |9-11|) = max(1, 1, 2) = 2.
|
||||
let mut tr = TrueRange::new();
|
||||
let out = tr.batch(&[c(12.0, 8.0, 11.0, 0), c(10.0, 9.0, 9.5, 1)]);
|
||||
assert_relative_eq!(out[0].unwrap(), 4.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut tr = TrueRange::new();
|
||||
assert_eq!(tr.warmup_period(), 1);
|
||||
assert!(!tr.is_ready());
|
||||
assert!(tr.update(c(11.0, 9.0, 10.0, 0)).is_some());
|
||||
assert!(tr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_negative() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut tr = TrueRange::new();
|
||||
for v in tr.batch(&candles).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "true range must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tr = TrueRange::new();
|
||||
tr.batch(&[c(12.0, 8.0, 10.0, 0), c(13.0, 9.0, 11.0, 1)]);
|
||||
assert!(tr.is_ready());
|
||||
tr.reset();
|
||||
assert!(!tr.is_ready());
|
||||
// After reset the next bar again has no previous close.
|
||||
assert_relative_eq!(
|
||||
tr.update(c(12.0, 8.0, 10.0, 0)).unwrap(),
|
||||
4.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.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 = TrueRange::new();
|
||||
let mut b = TrueRange::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user