Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221f7a71bc | ||
|
|
b5afc0a7e7 | ||
|
|
9acb2f607e | ||
|
|
32caf023dd | ||
|
|
250b75d468 | ||
|
|
adc8488939 | ||
|
|
d55d3db3d1 | ||
|
|
512bbf75c4 | ||
|
|
8f6ffe5a62 | ||
|
|
d9a1950007 | ||
|
|
c7f1e14629 | ||
|
|
a5d4926718 | ||
|
|
645b002958 | ||
|
|
5a6689cf1a | ||
|
|
8582338b5d | ||
|
|
a9670b0ad1 | ||
|
|
b86cf68eb8 | ||
|
|
24919153dd | ||
|
|
73507b1cb6 | ||
|
|
6dfa4ee134 | ||
|
|
6969541bb1 | ||
|
|
ba4e126799 | ||
|
|
aa2846c250 | ||
|
|
1255892b1e | ||
|
|
36dc951f1b | ||
|
|
0abc5f71c1 | ||
|
|
404fd29c31 | ||
|
|
050e5b74b8 | ||
|
|
62fe7a81aa | ||
|
|
3fcf2094f1 | ||
|
|
a2ccd202aa | ||
|
|
4aec5d544c | ||
|
|
e6375746d3 | ||
|
|
a876b145b0 |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Report incorrect behaviour in Wickra
|
about: Report incorrect behaviour in Wickra
|
||||||
title: "[bug] "
|
title: "[Bug] "
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ""
|
assignees: ""
|
||||||
---
|
---
|
||||||
@@ -32,7 +32,7 @@ assignees: ""
|
|||||||
- Wickra version:
|
- Wickra version:
|
||||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||||
- OS and architecture:
|
- OS and architecture:
|
||||||
- Rust / Python / Node version (if relevant):
|
- Rust / Python / Node version (If relevant):
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: Bug report (Detailed)
|
||||||
|
about: Long-form bug report with environment matrix, minimal reproducer, and expected-vs-actual sections.
|
||||||
|
title: "[Bug] <short description>"
|
||||||
|
labels: ["bug", "triage"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- One or two sentences. What did you expect, what happened instead? -->
|
||||||
|
|
||||||
|
## Affected binding
|
||||||
|
|
||||||
|
- [ ] Rust crate (`wickra`)
|
||||||
|
- [ ] Python (`pip install wickra`)
|
||||||
|
- [ ] Node.js (`npm install wickra`)
|
||||||
|
- [ ] WebAssembly
|
||||||
|
- [ ] Docs / examples only
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| -------------------- | -------------------------------------- |
|
||||||
|
| Wickra version | `e.g. 0.4.2` |
|
||||||
|
| Binding version | `e.g. python 0.4.2 / node 0.4.2` |
|
||||||
|
| OS / arch | `e.g. Windows 11 x86_64, Linux glibc` |
|
||||||
|
| Rust toolchain | `rustc --version` (If building from source) |
|
||||||
|
| Python / Node version | `python --version` / `node --version` |
|
||||||
|
|
||||||
|
## Minimal reproducer
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Paste the smallest possible code snippet that triggers the bug.
|
||||||
|
If the input data matters, attach a CSV/JSON or paste a few rows inline.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```python
|
||||||
|
# or rust / js
|
||||||
|
import wickra as ta
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actual output
|
||||||
|
|
||||||
|
```
|
||||||
|
<paste stack trace, panic, wrong values, etc.>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected output
|
||||||
|
|
||||||
|
<!-- What should the indicator / API have returned? Reference a paper, TA-Lib, or another implementation if possible. -->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Logs, screenshots, links to related issues, anything else useful. -->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: Documentation issue
|
||||||
|
about: Something in the README, rustdoc, examples, or guides is wrong, missing, or confusing.
|
||||||
|
title: "[Docs] <short description>"
|
||||||
|
labels: ["documentation", "good first issue"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where
|
||||||
|
|
||||||
|
<!-- Link or path. e.g. README.md#streaming-vs-batch, docs/guide/ema.md, rustdoc for `wickra::Ema::update`. -->
|
||||||
|
|
||||||
|
## What's wrong / missing
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- [ ] Incorrect information
|
||||||
|
- [ ] Outdated for current API
|
||||||
|
- [ ] Missing example
|
||||||
|
- [ ] Unclear wording
|
||||||
|
- [ ] Broken link / broken code block
|
||||||
|
- [ ] Other
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Suggested change
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Paste the corrected wording, a clearer example, or a sketch of the
|
||||||
|
section you'd like to see. PRs welcome.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Quote of the confusing passage, screenshot, etc. -->
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest a new indicator or capability for Wickra
|
about: Suggest a new indicator or capability for Wickra
|
||||||
title: "[feature] "
|
title: "[Feature] "
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees: ""
|
assignees: ""
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: Feature request (Detailed)
|
||||||
|
about: Long-form proposal with API sketch, scope checkboxes, prior-art links, and contribution intent.
|
||||||
|
title: "[Feat] <short description>"
|
||||||
|
labels: ["enhancement", "triage"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem / motivation
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What are you trying to do that Wickra doesn't support today?
|
||||||
|
Describe the user-facing pain point, not the implementation.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Proposed solution
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Sketch the API or behavior you'd like. A short code snippet of how
|
||||||
|
you'd want to call it is worth a thousand words.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```python
|
||||||
|
import wickra as ta
|
||||||
|
|
||||||
|
# proposed API
|
||||||
|
ind = ta.SuperTrend(period=10, multiplier=3.0)
|
||||||
|
ind.update(close, high, low)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- [ ] New indicator
|
||||||
|
- [ ] New method on an existing indicator
|
||||||
|
- [ ] New binding / platform target
|
||||||
|
- [ ] Performance improvement
|
||||||
|
- [ ] Ergonomics / API cleanup
|
||||||
|
- [ ] Other (Explain below)
|
||||||
|
|
||||||
|
## Reference / prior art
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Link the paper, book chapter, TA-Lib function, TradingView Pine source,
|
||||||
|
or other implementations you'd like Wickra to match.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
<!-- What workarounds exist today? Why aren't they enough? -->
|
||||||
|
|
||||||
|
## Willingness to contribute
|
||||||
|
|
||||||
|
- [ ] I'd like to implement this myself with guidance
|
||||||
|
- [ ] I can help review / test
|
||||||
|
- [ ] Requesting only — no bandwidth to implement
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: Performance regression
|
||||||
|
about: Report a measurable slowdown, memory blowup, or throughput drop.
|
||||||
|
title: "[Perf] <indicator / API> regressed in <version>"
|
||||||
|
labels: ["performance", "regression", "triage"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- Which code path got slower, by how much, and since when? -->
|
||||||
|
|
||||||
|
## Affected code path
|
||||||
|
|
||||||
|
- Indicator / API: `e.g. EMA.update`
|
||||||
|
- Binding: `Rust / Python / Node / Wasm`
|
||||||
|
- Hot loop or one-shot call?
|
||||||
|
|
||||||
|
## Versions compared
|
||||||
|
|
||||||
|
| Version | Throughput / latency / memory | Notes |
|
||||||
|
| -------- | ----------------------------- | ----- |
|
||||||
|
| `0.4.1` | `e.g. 12.3 ns/iter` | baseline (Good) |
|
||||||
|
| `0.4.2` | `e.g. 38.7 ns/iter` | regressed |
|
||||||
|
|
||||||
|
## Benchmark / reproducer
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Paste the criterion / pytest-benchmark / hyperfine command and its output.
|
||||||
|
For one-off measurements, include the timing snippet inline.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo bench --bench ema -- --save-baseline new
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ema/update time: [38.5 ns 38.7 ns 38.9 ns]
|
||||||
|
change: [+213.4% +214.8% +216.1%] (p = 0.00 < 0.05)
|
||||||
|
Performance has regressed.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hardware / environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------------ | -------------------------------------- |
|
||||||
|
| CPU | `e.g. Ryzen 9 7950X, AVX2 + AVX512` |
|
||||||
|
| OS / arch | `e.g. Linux 6.8 x86_64` |
|
||||||
|
| Toolchain | `rustc 1.x.y` |
|
||||||
|
| Build flags | `RUSTFLAGS=...`, `--release`, profile |
|
||||||
|
|
||||||
|
## Suspected cause
|
||||||
|
|
||||||
|
<!-- Optional. Link the commit / PR if you've bisected it. -->
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Question / usage help
|
||||||
|
about: Ask how to do something with Wickra. For open-ended discussion prefer GitHub Discussions.
|
||||||
|
title: "[Question] <short description>"
|
||||||
|
labels: ["question"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If this is open-ended ("which indicator should I use for X?") please
|
||||||
|
> use **Discussions** instead — issues are for actionable items.
|
||||||
|
|
||||||
|
## What are you trying to do?
|
||||||
|
|
||||||
|
<!-- The end goal, not the API call. -->
|
||||||
|
|
||||||
|
## What have you tried?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Code, docs you've read, search terms that didn't help.
|
||||||
|
Show that you've spent a few minutes before asking.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```python
|
||||||
|
import wickra as ta
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's confusing or blocking you?
|
||||||
|
|
||||||
|
<!-- Specific question. "Why does X return NaN for the first N points?" beats "doesn't work". -->
|
||||||
|
|
||||||
|
## Environment (Only if relevant)
|
||||||
|
|
||||||
|
- Wickra version: `e.g. 0.4.2`
|
||||||
|
- Binding: `Rust / Python / Node / Wasm`
|
||||||
@@ -23,9 +23,10 @@
|
|||||||
- [ ] `cargo test --workspace` passes.
|
- [ ] `cargo test --workspace` passes.
|
||||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||||
and their type stubs (if applicable).
|
and their type stubs (If applicable).
|
||||||
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
|
- [ ] The relevant page on the [project Wiki](https://github.com/kingchenc/wickra/wiki)
|
||||||
(if applicable).
|
and the `README.md` are updated (If applicable). Wiki edits go to a
|
||||||
|
separate repository: `https://github.com/kingchenc/wickra.wiki.git`.
|
||||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||||
|
|
||||||
## Notes for reviewers
|
## Notes for reviewers
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to Wickra!
|
||||||
|
Please fill in the sections below. Delete any that don't apply.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1–3 sentences: what does this PR change and why? -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix (Non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (Non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (Fix or feature that changes existing public API)
|
||||||
|
- [ ] Performance improvement
|
||||||
|
- [ ] Refactor (No functional change)
|
||||||
|
- [ ] Documentation only
|
||||||
|
- [ ] CI / build / tooling
|
||||||
|
|
||||||
|
## Affected surfaces
|
||||||
|
|
||||||
|
- [ ] Rust crate (`crates/wickra`)
|
||||||
|
- [ ] Python binding (`bindings/python`)
|
||||||
|
- [ ] Node.js binding (`bindings/node`)
|
||||||
|
- [ ] WebAssembly binding (`bindings/wasm`)
|
||||||
|
- [ ] Examples / docs
|
||||||
|
|
||||||
|
## Linked issues
|
||||||
|
|
||||||
|
<!-- "Closes #123", "Refs #456". One per line. -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## How was this tested?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- Unit tests added / updated under `crates/*/tests/` or `bindings/*/tests/`
|
||||||
|
- Property / fuzz tests touched? (Under `fuzz/`)
|
||||||
|
- Manual repro steps, if applicable
|
||||||
|
- Benchmarks run (Paste before/after if perf-sensitive)
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Numerical correctness (If you touched an indicator)
|
||||||
|
|
||||||
|
- [ ] Output matches an existing reference (TA-Lib, paper, prior Wickra release) within documented tolerance
|
||||||
|
- [ ] Streaming `update()` matches batch / `from_slice` output on the same input
|
||||||
|
- [ ] Edge cases covered: empty input, single point, NaN, leading warm-up window
|
||||||
|
|
||||||
|
## Performance impact (If applicable)
|
||||||
|
|
||||||
|
| Benchmark | Before | After | Δ |
|
||||||
|
| --------- | ------ | ----- | - |
|
||||||
|
| | | | |
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `cargo fmt --all` and `cargo clippy --all-targets -- -D warnings` are clean
|
||||||
|
- [ ] `cargo test --workspace` passes locally
|
||||||
|
- [ ] Binding tests run (If a binding changed)
|
||||||
|
- [ ] Public API changes are reflected in `CHANGELOG.md`
|
||||||
|
- [ ] Public API changes are reflected in rustdoc / README / examples
|
||||||
|
- [ ] No `todo*.md` or other local-only notes are staged
|
||||||
|
- [ ] License header / `LICENSE` reference unchanged (PolyForm-NC-1.0.0)
|
||||||
|
|
||||||
|
## Notes for reviewers
|
||||||
|
|
||||||
|
<!-- Anything reviewers should look at first, known follow-ups, deliberately out-of-scope items. -->
|
||||||
@@ -250,7 +250,16 @@ jobs:
|
|||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
|
||||||
- name: Install wasm-pack
|
- name: Install wasm-pack
|
||||||
uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
|
# jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
|
||||||
|
# old wasm-pack (~0.10.x) whose `build` subcommand does not yet accept
|
||||||
|
# `--features`, so `wasm-pack build … --features panic-hook` fails
|
||||||
|
# with "Found argument '--features' which wasn't expected". Use the
|
||||||
|
# same taiki-e prebuilt-binary installer we already use for
|
||||||
|
# cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
|
||||||
|
# release, which has `--features` as a top-level flag (since 0.12).
|
||||||
|
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||||
|
with:
|
||||||
|
tool: wasm-pack
|
||||||
|
|
||||||
- name: Build WASM package
|
- name: Build WASM package
|
||||||
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
|
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ jobs:
|
|||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
- name: Sync root README into bindings/python so it ships with the wheel
|
||||||
|
shell: bash
|
||||||
|
run: cp README.md bindings/python/README.md
|
||||||
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||||
with:
|
with:
|
||||||
working-directory: bindings/python
|
working-directory: bindings/python
|
||||||
@@ -124,6 +127,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- name: Sync root README into bindings/python so it ships in the sdist
|
||||||
|
run: cp README.md bindings/python/README.md
|
||||||
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||||
with:
|
with:
|
||||||
working-directory: bindings/python
|
working-directory: bindings/python
|
||||||
@@ -168,7 +173,7 @@ jobs:
|
|||||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||||
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.1.
|
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.5.
|
||||||
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
|
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
|
||||||
# npm spam-detection filter for new accounts (same situation that
|
# npm spam-detection filter for new accounts (same situation that
|
||||||
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
|
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
|
||||||
@@ -289,6 +294,14 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|
||||||
|
- name: Sync root README into bindings/node so it ships with the npm tarball
|
||||||
|
# npm reads README.md from the package directory at publish time. Copy
|
||||||
|
# the canonical root README in just before the publish so every
|
||||||
|
# registry shows the same project page.
|
||||||
|
shell: bash
|
||||||
|
run: cp ../../README.md README.md
|
||||||
|
working-directory: bindings/node
|
||||||
|
|
||||||
- name: Publish main package to npm (idempotent)
|
- name: Publish main package to npm (idempotent)
|
||||||
working-directory: bindings/node
|
working-directory: bindings/node
|
||||||
env:
|
env:
|
||||||
@@ -357,7 +370,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0
|
- name: Install wasm-pack (latest, via prebuilt binary)
|
||||||
|
# See the matching note in ci.yml: jetli's default installs an old
|
||||||
|
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
||||||
|
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
||||||
|
with:
|
||||||
|
tool: wasm-pack
|
||||||
|
|
||||||
|
- name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
|
||||||
|
# wasm-pack copies the crate's README.md into the generated pkg/
|
||||||
|
# directory it then publishes. Refresh it from the canonical root
|
||||||
|
# README right before the build.
|
||||||
|
run: cp README.md bindings/wasm/README.md
|
||||||
|
|
||||||
- name: Build WASM package (bundler target)
|
- name: Build WASM package (bundler target)
|
||||||
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
|
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
|
||||||
|
|||||||
+25
-1
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-05-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `BinanceConfig` plus `BinanceKlineStream::connect_with_config(symbols, interval, config)`
|
||||||
|
in `wickra-data`'s `live::binance` module. `connect()` keeps its previous
|
||||||
|
signature and now forwards to the new entry-point with the defaults, so the
|
||||||
|
public API is backwards-compatible. The config lets callers point the
|
||||||
|
stream at Binance Testnet (`wss://testnet.binance.vision`) or tune the
|
||||||
|
read timeout, reconnect attempt count, initial / capped backoff and frame
|
||||||
|
size limits without rewriting the connector.
|
||||||
|
- README **Disclaimer** section clarifying that Wickra is an indicator
|
||||||
|
toolkit (not a trading system) and that any production-trading use is at
|
||||||
|
the caller's own risk. The legal terms in [LICENSE](LICENSE) are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `BinanceKlineStream::next_event` now writes the Pong reply to a server
|
||||||
|
`Ping` on a best-effort basis. A failed write means the connection is
|
||||||
|
already dead, so the existing timeout / read-error reconnect arm one
|
||||||
|
loop iteration later picks it up — the previous explicit reconnect on
|
||||||
|
Pong-write failure is gone. Observable behaviour is unchanged for every
|
||||||
|
healthy connection.
|
||||||
|
|
||||||
## [0.2.1] - 2026-05-23
|
## [0.2.1] - 2026-05-23
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -329,7 +352,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
optional Binance live feed.
|
optional Binance live feed.
|
||||||
- Bindings for Python, Node.js, and WebAssembly.
|
- Bindings for Python, Node.js, and WebAssembly.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.1...HEAD
|
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.5...HEAD
|
||||||
|
[0.2.5]: https://github.com/kingchenc/wickra/compare/v0.2.1...v0.2.5
|
||||||
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
|
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
|
||||||
[0.2.0]: https://github.com/kingchenc/wickra/compare/v0.1.4...v0.2.0
|
[0.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.4]: https://github.com/kingchenc/wickra/compare/v0.1.3...v0.1.4
|
||||||
|
|||||||
+5
-3
@@ -22,7 +22,7 @@ when proposing features or depending on Wickra elsewhere.
|
|||||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||||
| `examples/` | Runnable examples. |
|
| `examples/` | Runnable examples. |
|
||||||
| `docs/wiki/` | Documentation sources. |
|
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
|
||||||
|
|
||||||
## Building and testing
|
## Building and testing
|
||||||
|
|
||||||
@@ -75,8 +75,10 @@ wasm-pack test --node bindings/wasm
|
|||||||
of `update` calls.
|
of `update` calls.
|
||||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||||
- **Docs.** Update the relevant page under `docs/wiki/` and the `README.md`
|
- **Docs.** Update the relevant page on the
|
||||||
when behaviour or the public API changes.
|
[project Wiki](https://github.com/kingchenc/wickra/wiki) and the
|
||||||
|
`README.md` when behaviour or the public API changes. The Wiki lives in
|
||||||
|
a separate git repository: `https://github.com/kingchenc/wickra.wiki.git`.
|
||||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||||
|
|
||||||
## Commit and pull-request workflow
|
## Commit and pull-request workflow
|
||||||
|
|||||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"criterion",
|
"criterion",
|
||||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-core"
|
name = "wickra-core"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"proptest",
|
"proptest",
|
||||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-data"
|
name = "wickra-data"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-node"
|
name = "wickra-node"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-python"
|
name = "wickra-python"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wickra-wasm"
|
name = "wickra-wasm"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
|||||||
exclude = ["fuzz"]
|
exclude = ["fuzz"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
authors = ["kingchenc <kingchencp@gmail.com>"]
|
authors = ["kingchenc <kingchencp@gmail.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
|||||||
categories = ["finance", "mathematics", "science"]
|
categories = ["finance", "mathematics", "science"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wickra-core = { path = "crates/wickra-core", version = "0.2.1" }
|
wickra-core = { path = "crates/wickra-core", version = "0.2.5" }
|
||||||
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
|
|||||||
@@ -287,6 +287,16 @@ government, hobby trading bots: all fine. The one thing that's not allowed is
|
|||||||
commercial sale of the software or of services built around it. If you want to
|
commercial sale of the software or of services built around it. If you want to
|
||||||
use Wickra commercially, get in touch about a license.
|
use Wickra commercially, get in touch about a license.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
Wickra is an indicator toolkit, not a trading system. Values it computes are
|
||||||
|
deterministic transforms of the input data — they are not financial advice and
|
||||||
|
they do not predict the market. Any use of this library in a production
|
||||||
|
trading context is at your own risk.
|
||||||
|
|
||||||
|
The library is provided **as is**, without warranty of any kind; see
|
||||||
|
[LICENSE](LICENSE) for the full terms.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+288
-27
@@ -1,45 +1,306 @@
|
|||||||
# wickra
|
# Wickra
|
||||||
|
|
||||||
Node.js bindings for the Wickra streaming-first technical indicators library.
|
[](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)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
## Install
|
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||||
|
|
||||||
Once published, install per platform via the precompiled native package:
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
|
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||||
|
machine that updates in O(1) per new data point, so live trading bots and
|
||||||
|
historical backtests share the exact same implementation.
|
||||||
|
|
||||||
```bash
|
```python
|
||||||
npm install wickra
|
import numpy as np
|
||||||
|
import wickra as ta
|
||||||
|
|
||||||
|
# Batch: classic TA-Lib-style usage
|
||||||
|
prices = np.linspace(100, 200, 1000)
|
||||||
|
rsi = ta.RSI(14)
|
||||||
|
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||||
|
|
||||||
|
# Streaming: same indicator, fed tick by tick
|
||||||
|
rsi = ta.RSI(14)
|
||||||
|
for price in live_feed:
|
||||||
|
value = rsi.update(price) # O(1) — no recomputation over history
|
||||||
|
if value is not None and value > 70:
|
||||||
|
print("overbought")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build from source
|
## Why Wickra exists
|
||||||
|
|
||||||
|
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||||
|
talipp, tulipy — and every one of them shares the same blind spot:
|
||||||
|
|
||||||
|
| Library | Install pain | Streaming | Multi-language | Active |
|
||||||
|
|--------------------|-----------------|-----------|----------------|--------|
|
||||||
|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||||
|
| pandas-ta | clean | no | no | slow |
|
||||||
|
| finta | clean | no | no | stale |
|
||||||
|
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||||
|
| talipp | clean | yes | no | yes |
|
||||||
|
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||||
|
| ooples (C#) | clean | no | C# only | yes |
|
||||||
|
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||||
|
|
||||||
|
Wickra is the only library that combines all of: clean install, streaming,
|
||||||
|
multi-language reach, and active maintenance.
|
||||||
|
|
||||||
|
## Benchmark: how much faster is "streaming-first"?
|
||||||
|
|
||||||
|
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 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) | **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 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.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
|
||||||
|
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||||
|
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||||
|
> them in your environment to see those rows light up too.
|
||||||
|
|
||||||
|
Run the suite yourself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bindings/node
|
pip install -e bindings/python[bench]
|
||||||
npm install
|
python -m benchmarks.compare_libraries
|
||||||
npm run build
|
|
||||||
npm test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The native module is built via [napi-rs](https://napi.rs/). The build script
|
## Indicators
|
||||||
produces a `wickra.<platform>-<arch>.node` binary in the package root that
|
|
||||||
`index.js` loads at runtime.
|
|
||||||
|
|
||||||
## Usage
|
71 streaming-first indicators across eight families. Every one passes the
|
||||||
|
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||||
|
semantics tests.
|
||||||
|
|
||||||
```js
|
| Family | Indicators |
|
||||||
import { SMA, RSI, MACD, version } from 'wickra';
|
|--------|-----------|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
console.log('wickra', version());
|
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||||
|
inherit it automatically.
|
||||||
|
|
||||||
// Batch:
|
## Languages
|
||||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + Math.sin(i * 0.1) * 5);
|
|
||||||
const rsi = new RSI(14).batch(prices);
|
|
||||||
|
|
||||||
// Streaming:
|
| Binding | Install | Example |
|
||||||
const macd = new MACD(12, 26, 9);
|
|-------------------|-----------------------------------------------|---------|
|
||||||
for (const p of livePriceStream) {
|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||||
const v = macd.update(p);
|
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||||
if (v && v.histogram > 0) console.log('bullish crossover candidate');
|
| 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.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||||
|
|
||||||
|
// Streaming or batch — same trait, same code.
|
||||||
|
let mut sma = Sma::new(14)?;
|
||||||
|
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||||
|
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
for price in live_feed {
|
||||||
|
if let Some(v) = rsi.update(price) {
|
||||||
|
println!("RSI = {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose indicators: RSI(7) on top of EMA(14).
|
||||||
|
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||||
|
chain.update(price);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Live data sources
|
||||||
|
|
||||||
|
`wickra-data` (separate crate, opt-in) ships:
|
||||||
|
|
||||||
|
- A streaming OHLCV **CSV reader**.
|
||||||
|
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||||
|
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||||
|
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, Rsi};
|
||||||
|
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||||
|
|
||||||
|
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
while let Some(event) = stream.next_event().await? {
|
||||||
|
if event.is_closed {
|
||||||
|
if let Some(v) = rsi.update(event.candle.close) {
|
||||||
|
println!("RSI = {v:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See `index.d.ts` for the full TypeScript surface.
|
A Python live-trading example using the public `websockets` package lives at
|
||||||
|
`examples/python/live_trading.py`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
wickra/
|
||||||
|
├── crates/
|
||||||
|
│ ├── 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/ 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
|
||||||
|
# Rust core + tests
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo bench -p wickra
|
||||||
|
|
||||||
|
# Python binding (requires Rust toolchain + maturin)
|
||||||
|
cd bindings/python
|
||||||
|
maturin develop --release
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||||
|
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||||
|
|
||||||
|
# Node binding (requires @napi-rs/cli)
|
||||||
|
cd bindings/node && npm install && npm run build && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||||
|
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||||
|
|
||||||
|
A short orientation for first-time contributors:
|
||||||
|
|
||||||
|
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||||
|
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||||
|
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||||
|
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||||
|
proptest. The four bindings inherit your indicator automatically once
|
||||||
|
you expose it in the language wrappers.
|
||||||
|
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||||
|
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||||
|
most regressions; please don't disable them.
|
||||||
|
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||||
|
its own tests; please keep the `batch == streaming` invariant.
|
||||||
|
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
are CI gates; running them locally before pushing keeps reviews short.
|
||||||
|
|
||||||
|
For larger architectural changes, open an issue first so we can sketch the
|
||||||
|
shape together before you invest the time.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||||
|
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||||
|
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||||
|
commercial sale of the software or of services built around it. If you want to
|
||||||
|
use Wickra commercially, get in touch about a license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||||
|
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||||
|
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/issues">
|
||||||
|
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-arm64",
|
"name": "wickra-darwin-arm64",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-arm64.node",
|
"main": "wickra.darwin-arm64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-darwin-x64",
|
"name": "wickra-darwin-x64",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.darwin-x64.node",
|
"main": "wickra.darwin-x64.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-arm64-gnu",
|
"name": "wickra-linux-arm64-gnu",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.linux-arm64-gnu.node",
|
"main": "wickra.linux-arm64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-linux-x64-gnu",
|
"name": "wickra-linux-x64-gnu",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.linux-x64-gnu.node",
|
"main": "wickra.linux-x64-gnu.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra-win32-x64-msvc",
|
"name": "wickra-win32-x64-msvc",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||||
"main": "wickra.win32-x64-msvc.node",
|
"main": "wickra.win32-x64-msvc.node",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wickra",
|
"name": "wickra",
|
||||||
"version": "0.2.1",
|
"version": "0.2.5",
|
||||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||||
"author": "kingchenc <kingchencp@gmail.com>",
|
"author": "kingchenc <kingchencp@gmail.com>",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"wickra-linux-x64-gnu": "0.2.1",
|
"wickra-linux-x64-gnu": "0.2.5",
|
||||||
"wickra-linux-arm64-gnu": "0.2.1",
|
"wickra-linux-arm64-gnu": "0.2.5",
|
||||||
"wickra-darwin-x64": "0.2.1",
|
"wickra-darwin-x64": "0.2.5",
|
||||||
"wickra-darwin-arm64": "0.2.1",
|
"wickra-darwin-arm64": "0.2.5",
|
||||||
"wickra-win32-x64-msvc": "0.2.1"
|
"wickra-win32-x64-msvc": "0.2.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "napi build --platform --release",
|
"build": "napi build --platform --release",
|
||||||
|
|||||||
+285
-45
@@ -1,66 +1,306 @@
|
|||||||
# Wickra — Python bindings
|
# Wickra
|
||||||
|
|
||||||
Streaming-first technical indicators powered by a Rust core.
|
[](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)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
```bash
|
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||||
pip install wickra
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick start
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
|
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||||
|
machine that updates in O(1) per new data point, so live trading bots and
|
||||||
|
historical backtests share the exact same implementation.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import wickra as ta
|
import wickra as ta
|
||||||
|
|
||||||
# Batch — TA-Lib-style usage
|
# Batch: classic TA-Lib-style usage
|
||||||
prices = np.linspace(100, 200, 1000)
|
prices = np.linspace(100, 200, 1000)
|
||||||
rsi = ta.RSI(14).batch(prices) # NumPy array; NaN during warmup
|
|
||||||
|
|
||||||
# Streaming — feed ticks one at a time
|
|
||||||
rsi = ta.RSI(14)
|
rsi = ta.RSI(14)
|
||||||
for price in live_prices:
|
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||||
v = rsi.update(price) # O(1) per tick
|
|
||||||
if v is not None and v > 70:
|
# Streaming: same indicator, fed tick by tick
|
||||||
...
|
rsi = ta.RSI(14)
|
||||||
|
for price in live_feed:
|
||||||
|
value = rsi.update(price) # O(1) — no recomputation over history
|
||||||
|
if value is not None and value > 70:
|
||||||
|
print("overbought")
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's included
|
## Why Wickra exists
|
||||||
|
|
||||||
71 streaming-first indicators across eight families. Every one passes a
|
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||||
`batch == streaming` equivalence test and reference-value tests:
|
talipp, tulipy — and every one of them shares the same blind spot:
|
||||||
|
|
||||||
- **Moving Averages** — SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA,
|
| Library | Install pain | Streaming | Multi-language | Active |
|
||||||
ZLEMA, T3, VWMA
|
|--------------------|-----------------|-----------|----------------|--------|
|
||||||
- **Momentum Oscillators** — RSI (Wilder), Stochastic, CCI, ROC, Williams %R,
|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||||
MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator
|
| pandas-ta | clean | no | no | slow |
|
||||||
- **Trend & Directional** — MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon
|
| finta | clean | no | no | stale |
|
||||||
Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter
|
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||||
- **Price Oscillators** — PPO, DPO, Coppock, Accelerator Oscillator, Balance
|
| talipp | clean | yes | no | yes |
|
||||||
of Power
|
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||||
- **Volatility & Bands** — ATR, Bollinger Bands, Keltner Channels, Donchian
|
| ooples (C#) | clean | no | C# only | yes |
|
||||||
Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger
|
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||||
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
|
Wickra is the only library that combines all of: clean install, streaming,
|
||||||
|
multi-language reach, and active maintenance.
|
||||||
|
|
||||||
Classic TA libraries are batch-only: every live tick triggers a full
|
## Benchmark: how much faster is "streaming-first"?
|
||||||
recomputation over the entire history. Wickra updates indicator state in
|
|
||||||
O(1) per tick. On a 5K-bar history the streaming RSI gap is ~17× over the
|
|
||||||
nearest peer with a streaming API and 100×+ over batch-only libraries.
|
|
||||||
|
|
||||||
## Full project
|
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.
|
||||||
|
|
||||||
See <https://github.com/kingchenc/wickra> for benchmarks, the Rust core,
|
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 7950X3D, 64 GB DDR5,
|
||||||
Node.js and WebAssembly bindings, examples, and CI.
|
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 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) | **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 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.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
|
||||||
|
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||||
|
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||||
|
> them in your environment to see those rows light up too.
|
||||||
|
|
||||||
|
Run the suite yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e bindings/python[bench]
|
||||||
|
python -m benchmarks.compare_libraries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indicators
|
||||||
|
|
||||||
|
71 streaming-first indicators across eight families. Every one passes the
|
||||||
|
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||||
|
semantics tests.
|
||||||
|
|
||||||
|
| Family | Indicators |
|
||||||
|
|--------|-----------|
|
||||||
|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA |
|
||||||
|
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator |
|
||||||
|
| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||||
|
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power |
|
||||||
|
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility |
|
||||||
|
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop |
|
||||||
|
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement |
|
||||||
|
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle |
|
||||||
|
|
||||||
|
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||||
|
inherit it automatically.
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
| Binding | Install | Example |
|
||||||
|
|-------------------|-----------------------------------------------|---------|
|
||||||
|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||||
|
|
||||||
|
// Streaming or batch — same trait, same code.
|
||||||
|
let mut sma = Sma::new(14)?;
|
||||||
|
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||||
|
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
for price in live_feed {
|
||||||
|
if let Some(v) = rsi.update(price) {
|
||||||
|
println!("RSI = {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose indicators: RSI(7) on top of EMA(14).
|
||||||
|
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||||
|
chain.update(price);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Live data sources
|
||||||
|
|
||||||
|
`wickra-data` (separate crate, opt-in) ships:
|
||||||
|
|
||||||
|
- A streaming OHLCV **CSV reader**.
|
||||||
|
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||||
|
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||||
|
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, Rsi};
|
||||||
|
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||||
|
|
||||||
|
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
while let Some(event) = stream.next_event().await? {
|
||||||
|
if event.is_closed {
|
||||||
|
if let Some(v) = rsi.update(event.candle.close) {
|
||||||
|
println!("RSI = {v:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A Python live-trading example using the public `websockets` package lives at
|
||||||
|
`examples/python/live_trading.py`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
wickra/
|
||||||
|
├── crates/
|
||||||
|
│ ├── 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/ 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
|
||||||
|
# Rust core + tests
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo bench -p wickra
|
||||||
|
|
||||||
|
# Python binding (requires Rust toolchain + maturin)
|
||||||
|
cd bindings/python
|
||||||
|
maturin develop --release
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||||
|
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||||
|
|
||||||
|
# Node binding (requires @napi-rs/cli)
|
||||||
|
cd bindings/node && npm install && npm run build && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||||
|
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||||
|
|
||||||
|
A short orientation for first-time contributors:
|
||||||
|
|
||||||
|
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||||
|
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||||
|
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||||
|
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||||
|
proptest. The four bindings inherit your indicator automatically once
|
||||||
|
you expose it in the language wrappers.
|
||||||
|
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||||
|
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||||
|
most regressions; please don't disable them.
|
||||||
|
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||||
|
its own tests; please keep the `batch == streaming` invariant.
|
||||||
|
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
are CI gates; running them locally before pushing keeps reviews short.
|
||||||
|
|
||||||
|
For larger architectural changes, open an issue first so we can sketch the
|
||||||
|
shape together before you invest the time.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal,
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||||
research, educational, and non-profit use are all permitted. Commercial
|
|
||||||
sale requires a separate license — contact via the GitHub repo.
|
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||||
|
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||||
|
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||||
|
commercial sale of the software or of services built around it. If you want to
|
||||||
|
use Wickra commercially, get in touch about a license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||||
|
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||||
|
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/issues">
|
||||||
|
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "wickra"
|
name = "wickra"
|
||||||
version = "0.2.1"
|
version = "0.2.5"
|
||||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||||
|
|||||||
+287
-30
@@ -1,49 +1,306 @@
|
|||||||
# wickra-wasm
|
# Wickra
|
||||||
|
|
||||||
WebAssembly bindings for the Wickra streaming-first technical indicators library.
|
[](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)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
## Build
|
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||||
|
|
||||||
You need [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and the
|
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||||
`wasm32-unknown-unknown` Rust target:
|
bindings for Python, Node.js, and WebAssembly. Every indicator is a state
|
||||||
|
machine that updates in O(1) per new data point, so live trading bots and
|
||||||
|
historical backtests share the exact same implementation.
|
||||||
|
|
||||||
```bash
|
```python
|
||||||
rustup target add wasm32-unknown-unknown
|
import numpy as np
|
||||||
cargo install wasm-pack
|
import wickra as ta
|
||||||
|
|
||||||
|
# Batch: classic TA-Lib-style usage
|
||||||
|
prices = np.linspace(100, 200, 1000)
|
||||||
|
rsi = ta.RSI(14)
|
||||||
|
values = rsi.batch(prices) # numpy array, NaN during warmup
|
||||||
|
|
||||||
|
# Streaming: same indicator, fed tick by tick
|
||||||
|
rsi = ta.RSI(14)
|
||||||
|
for price in live_feed:
|
||||||
|
value = rsi.update(price) # O(1) — no recomputation over history
|
||||||
|
if value is not None and value > 70:
|
||||||
|
print("overbought")
|
||||||
```
|
```
|
||||||
|
|
||||||
Then from the repository root:
|
## Why Wickra exists
|
||||||
|
|
||||||
|
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||||
|
talipp, tulipy — and every one of them shares the same blind spot:
|
||||||
|
|
||||||
|
| Library | Install pain | Streaming | Multi-language | Active |
|
||||||
|
|--------------------|-----------------|-----------|----------------|--------|
|
||||||
|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||||
|
| pandas-ta | clean | no | no | slow |
|
||||||
|
| finta | clean | no | no | stale |
|
||||||
|
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||||
|
| talipp | clean | yes | no | yes |
|
||||||
|
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||||
|
| ooples (C#) | clean | no | C# only | yes |
|
||||||
|
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||||
|
|
||||||
|
Wickra is the only library that combines all of: clean install, streaming,
|
||||||
|
multi-language reach, and active maintenance.
|
||||||
|
|
||||||
|
## Benchmark: how much faster is "streaming-first"?
|
||||||
|
|
||||||
|
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 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) | **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 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.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
|
||||||
|
> pain Wickra was built to remove. The benchmark script auto-detects every
|
||||||
|
> peer library it can find and runs them on the same inputs as Wickra; install
|
||||||
|
> them in your environment to see those rows light up too.
|
||||||
|
|
||||||
|
Run the suite yourself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
pip install -e bindings/python[bench]
|
||||||
|
python -m benchmarks.compare_libraries
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiled package lands in `bindings/wasm/pkg/`. Targets:
|
## Indicators
|
||||||
|
|
||||||
- `--target web` for native ES modules in browsers
|
71 streaming-first indicators across eight families. Every one passes the
|
||||||
- `--target bundler` for webpack/Vite/Rollup
|
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||||
- `--target nodejs` for Node.js
|
semantics tests.
|
||||||
|
|
||||||
## Example
|
| 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 |
|
||||||
|
|
||||||
```js
|
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||||
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
|
inherit it automatically.
|
||||||
|
|
||||||
await init();
|
## Languages
|
||||||
console.log("wickra:", version());
|
|
||||||
|
|
||||||
// Streaming
|
| Binding | Install | Example |
|
||||||
const rsi = new RSI(14);
|
|-------------------|-----------------------------------------------|---------|
|
||||||
for (const price of livePrices) {
|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||||
const v = rsi.update(price);
|
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||||
if (v !== undefined && v > 70) console.log("overbought");
|
| 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.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
|
||||||
|
|
||||||
|
// Streaming or batch — same trait, same code.
|
||||||
|
let mut sma = Sma::new(14)?;
|
||||||
|
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||||
|
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
for price in live_feed {
|
||||||
|
if let Some(v) = rsi.update(price) {
|
||||||
|
println!("RSI = {v}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch (returns a Float64Array; NaN for warmup positions)
|
// Compose indicators: RSI(7) on top of EMA(14).
|
||||||
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
|
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
|
||||||
|
chain.update(price);
|
||||||
```
|
```
|
||||||
|
|
||||||
An interactive demo lives in [`examples/wasm/index.html`](../../examples/wasm/index.html)
|
## Live data sources
|
||||||
(top-level alongside the other language examples). After building the package
|
|
||||||
with `wasm-pack build`, serve the repository root and open
|
`wickra-data` (separate crate, opt-in) ships:
|
||||||
`examples/wasm/index.html` in a browser.
|
|
||||||
|
- A streaming OHLCV **CSV reader**.
|
||||||
|
- A **tick-to-candle aggregator** with arbitrary timeframes.
|
||||||
|
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
|
||||||
|
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{Indicator, Rsi};
|
||||||
|
use wickra_data::live::binance::{BinanceKlineStream, Interval};
|
||||||
|
|
||||||
|
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
|
||||||
|
let mut rsi = Rsi::new(14)?;
|
||||||
|
while let Some(event) = stream.next_event().await? {
|
||||||
|
if event.is_closed {
|
||||||
|
if let Some(v) = rsi.update(event.candle.close) {
|
||||||
|
println!("RSI = {v:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A Python live-trading example using the public `websockets` package lives at
|
||||||
|
`examples/python/live_trading.py`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
wickra/
|
||||||
|
├── crates/
|
||||||
|
│ ├── 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/ 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
|
||||||
|
# Rust core + tests
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo bench -p wickra
|
||||||
|
|
||||||
|
# Python binding (requires Rust toolchain + maturin)
|
||||||
|
cd bindings/python
|
||||||
|
maturin develop --release
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
|
||||||
|
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||||
|
|
||||||
|
# Node binding (requires @napi-rs/cli)
|
||||||
|
cd bindings/node && npm install && npm run build && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||||
|
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||||
|
|
||||||
|
A short orientation for first-time contributors:
|
||||||
|
|
||||||
|
- **Adding an indicator.** Implement the `Indicator` trait in
|
||||||
|
`crates/wickra-core/src/indicators/<name>.rs`, wire it into
|
||||||
|
`indicators/mod.rs` and the crate root, and add reference-value tests,
|
||||||
|
a `batch == streaming` equivalence test, and (where it makes sense) a
|
||||||
|
proptest. The four bindings inherit your indicator automatically once
|
||||||
|
you expose it in the language wrappers.
|
||||||
|
- **Fixing a numeric bug.** Add a failing test that pins the textbook value
|
||||||
|
first, then fix the math. Property tests in `crates/wickra-core` catch
|
||||||
|
most regressions; please don't disable them.
|
||||||
|
- **Improving a binding.** Each binding lives under `bindings/<lang>` with
|
||||||
|
its own tests; please keep the `batch == streaming` invariant.
|
||||||
|
- **Style.** `cargo fmt --all` + `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
are CI gates; running them locally before pushing keeps reviews short.
|
||||||
|
|
||||||
|
For larger architectural changes, open an issue first so we can sketch the
|
||||||
|
shape together before you invest the time.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||||
|
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||||
|
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||||
|
commercial sale of the software or of services built around it. If you want to
|
||||||
|
use Wickra commercially, get in touch about a license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||||
|
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||||
|
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kingchenc/wickra/issues">
|
||||||
|
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ mod tests {
|
|||||||
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
|
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `params` (69-71) and the Indicator-impl
|
||||||
|
/// `name` body (99-101). Existing tests inspect numeric output but
|
||||||
|
/// never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ac = AcceleratorOscillator::classic();
|
||||||
|
assert_eq!(ac.params(), (5, 34, 5));
|
||||||
|
assert_eq!(ac.name(), "AcceleratorOscillator");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ mod tests {
|
|||||||
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (94-96). The other accessors
|
||||||
|
/// are exercised by existing tests; `name` was never queried.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let adl = Adl::new();
|
||||||
|
assert_eq!(adl.name(), "ADL");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_at_high_accumulates_full_volume() {
|
fn close_at_high_accumulates_full_volume() {
|
||||||
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
|
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
|
||||||
|
|||||||
@@ -269,6 +269,37 @@ mod tests {
|
|||||||
assert!(Adx::new(0).is_err());
|
assert!(Adx::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (lines 89-91) and the Indicator-impl
|
||||||
|
/// `warmup_period` (199-201) + `name` (207-209). None of the trend tests
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let adx = Adx::new(14).unwrap();
|
||||||
|
assert_eq!(adx.period(), 14);
|
||||||
|
assert_eq!(adx.warmup_period(), 28);
|
||||||
|
assert_eq!(adx.name(), "ADX");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `tr_v == 0.0` defensive branches in `update` (lines 142,
|
||||||
|
/// 147) — feeding a stream of perfectly flat candles (H == L == close
|
||||||
|
/// every bar) gives true-range 0 each step, so the smoothed `tr_smooth`
|
||||||
|
/// stays at 0.0 and the `plus_di` / `minus_di` divisions would otherwise
|
||||||
|
/// blow up. The indicator must emit zeros (DX denominator is also 0).
|
||||||
|
#[test]
|
||||||
|
fn zero_true_range_yields_zero_di_and_zero_adx() {
|
||||||
|
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||||
|
let mut adx = Adx::new(5).unwrap();
|
||||||
|
let last = adx
|
||||||
|
.batch(&candles)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.expect("ADX emits after 2 * period candles");
|
||||||
|
assert_eq!(last.plus_di, 0.0);
|
||||||
|
assert_eq!(last.minus_di, 0.0);
|
||||||
|
assert_eq!(last.adx, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_equals_streaming() {
|
fn batch_equals_streaming() {
|
||||||
let candles: Vec<Candle> = (0..60)
|
let candles: Vec<Candle> = (0..60)
|
||||||
|
|||||||
@@ -182,4 +182,13 @@ mod tests {
|
|||||||
assert!(!a.is_ready());
|
assert!(!a.is_ready());
|
||||||
assert_eq!(a.update(candles[0]), None);
|
assert_eq!(a.update(candles[0]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (56-58) and the Indicator-impl
|
||||||
|
/// `name` body (104-106). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let a = Aroon::new(14).unwrap();
|
||||||
|
assert_eq!(a.period(), 14);
|
||||||
|
assert_eq!(a.name(), "Aroon");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ mod tests {
|
|||||||
assert!(AroonOscillator::new(0).is_err());
|
assert!(AroonOscillator::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (57-64) and the
|
||||||
|
/// Indicator-impl `name` body (90-92). `warmup_period` is covered
|
||||||
|
/// already by `warmup_period_matches_aroon`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut osc = AroonOscillator::new(7).unwrap();
|
||||||
|
assert_eq!(osc.period(), 7);
|
||||||
|
assert_eq!(osc.name(), "AroonOscillator");
|
||||||
|
assert_eq!(osc.value(), None);
|
||||||
|
for i in 0..8 {
|
||||||
|
osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
|
||||||
|
}
|
||||||
|
assert!(osc.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pure_uptrend_yields_plus_100() {
|
fn pure_uptrend_yields_plus_100() {
|
||||||
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ mod tests {
|
|||||||
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (54-62) and the
|
||||||
|
/// Indicator-impl `name` body (103-105). Existing tests inspect
|
||||||
|
/// numeric ATR output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut atr = Atr::new(14).unwrap();
|
||||||
|
assert_eq!(atr.period(), 14);
|
||||||
|
assert_eq!(atr.name(), "ATR");
|
||||||
|
assert_eq!(atr.value(), None);
|
||||||
|
for _ in 0..14 {
|
||||||
|
atr.update(c(11.0, 9.0, 10.0));
|
||||||
|
}
|
||||||
|
assert!(atr.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_emits_on_period_th_candle() {
|
fn warmup_emits_on_period_th_candle() {
|
||||||
let candles = vec![
|
let candles = vec![
|
||||||
|
|||||||
@@ -234,6 +234,17 @@ mod tests {
|
|||||||
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
|
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `params` (77-79) and the Indicator-impl
|
||||||
|
/// `name` body (130-132). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let s = AtrTrailingStop::classic();
|
||||||
|
let (atr_p, mult) = s.params();
|
||||||
|
assert_eq!(atr_p, 14);
|
||||||
|
assert!((mult - 3.0).abs() < 1e-12);
|
||||||
|
assert_eq!(s.name(), "AtrTrailingStop");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40)
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ mod tests {
|
|||||||
assert!(AwesomeOscillator::new(0, 5).is_err());
|
assert!(AwesomeOscillator::new(0, 5).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `periods` (59-61) and the Indicator-impl
|
||||||
|
/// `warmup_period` (83-85) + `name` (91-93). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ao = AwesomeOscillator::classic();
|
||||||
|
assert_eq!(ao.periods(), (5, 34));
|
||||||
|
assert_eq!(ao.warmup_period(), 34);
|
||||||
|
assert_eq!(ao.name(), "AwesomeOscillator");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_equals_streaming() {
|
fn batch_equals_streaming() {
|
||||||
let candles: Vec<Candle> = (0..50)
|
let candles: Vec<Candle> = (0..50)
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (73-75).
|
||||||
|
#[test]
|
||||||
|
fn name_metadata() {
|
||||||
|
let bop = BalanceOfPower::new();
|
||||||
|
assert_eq!(bop.name(), "BalanceOfPower");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle() {
|
fn emits_from_first_candle() {
|
||||||
let mut bop = BalanceOfPower::new();
|
let mut bop = BalanceOfPower::new();
|
||||||
|
|||||||
@@ -172,20 +172,21 @@ mod tests {
|
|||||||
use crate::traits::BatchExt;
|
use crate::traits::BatchExt;
|
||||||
use approx::assert_relative_eq;
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
fn naive(prices: &[f64], period: usize, mult: f64) -> Option<BollingerOutput> {
|
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
|
||||||
if prices.len() < period {
|
assert!(
|
||||||
return None;
|
prices.len() >= period,
|
||||||
}
|
"naive requires at least `period` prices"
|
||||||
|
);
|
||||||
let w = &prices[prices.len() - period..];
|
let w = &prices[prices.len() - period..];
|
||||||
let mean = w.iter().sum::<f64>() / period as f64;
|
let mean = w.iter().sum::<f64>() / period as f64;
|
||||||
let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||||
let s = var.sqrt();
|
let s = var.sqrt();
|
||||||
Some(BollingerOutput {
|
BollingerOutput {
|
||||||
upper: mean + mult * s,
|
upper: mean + mult * s,
|
||||||
middle: mean,
|
middle: mean,
|
||||||
lower: mean - mult * s,
|
lower: mean - mult * s,
|
||||||
stddev: s,
|
stddev: s,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -212,6 +213,20 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the convenience constructor `BollingerBands::classic()` plus the
|
||||||
|
/// const accessors `period` / `multiplier` and the Indicator-impl
|
||||||
|
/// metadata methods `warmup_period` / `name`. Existing tests never
|
||||||
|
/// invoked `classic()` (every test passed explicit parameters to
|
||||||
|
/// `new`) and never queried any of the four getters.
|
||||||
|
#[test]
|
||||||
|
fn classic_and_accessors_and_metadata() {
|
||||||
|
let bb = BollingerBands::classic();
|
||||||
|
assert_eq!(bb.period(), 20);
|
||||||
|
assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
|
||||||
|
assert_eq!(bb.warmup_period(), 20);
|
||||||
|
assert_eq!(bb.name(), "BollingerBands");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_returns_none() {
|
fn warmup_returns_none() {
|
||||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||||
@@ -241,7 +256,7 @@ mod tests {
|
|||||||
let out = bb.batch(&prices);
|
let out = bb.batch(&prices);
|
||||||
for i in 19..prices.len() {
|
for i in 19..prices.len() {
|
||||||
let got = out[i].unwrap();
|
let got = out[i].unwrap();
|
||||||
let want = naive(&prices[..=i], 20, 2.0).unwrap();
|
let want = naive(&prices[..=i], 20, 2.0);
|
||||||
assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
|
assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
|
||||||
assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
|
assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
|
||||||
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
|
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
|
||||||
@@ -301,8 +316,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
window.push_back(v);
|
window.push_back(v);
|
||||||
}
|
}
|
||||||
let scratch =
|
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
|
||||||
naive(&window.iter().copied().collect::<Vec<_>>(), period, mult).expect("warmed up");
|
|
||||||
let got = last.expect("warmed up");
|
let got = last.expect("warmed up");
|
||||||
assert!(
|
assert!(
|
||||||
(got.middle - scratch.middle).abs() < 1e-3,
|
(got.middle - scratch.middle).abs() < 1e-3,
|
||||||
|
|||||||
@@ -113,6 +113,27 @@ mod tests {
|
|||||||
assert!(BollingerBandwidth::new(20, -1.0).is_err());
|
assert!(BollingerBandwidth::new(20, -1.0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the public const accessors `period`, `multiplier`, `value` and
|
||||||
|
/// the Indicator-impl `warmup_period` + `name` methods. None of the
|
||||||
|
/// pre-existing tests inspected the metadata surface — they only fed
|
||||||
|
/// numeric updates and asserted on the bandwidth values, leaving the
|
||||||
|
/// five getter bodies (lines 54-66, 90-92, 98-100) untouched.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||||
|
assert_eq!(bbw.period(), 20);
|
||||||
|
assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12);
|
||||||
|
// value() before warmup must be the literal None branch of self.last.
|
||||||
|
assert_eq!(bbw.value(), None);
|
||||||
|
assert_eq!(bbw.warmup_period(), 20);
|
||||||
|
assert_eq!(bbw.name(), "BollingerBandwidth");
|
||||||
|
// Drive past warmup so value() exercises the Some branch as well.
|
||||||
|
for i in 1..=20 {
|
||||||
|
bbw.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(bbw.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn constant_series_yields_zero() {
|
fn constant_series_yields_zero() {
|
||||||
// Flat prices: the bands collapse onto the middle, so width is 0.
|
// Flat prices: the bands collapse onto the middle, so width is 0.
|
||||||
@@ -123,6 +144,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the defensive `o.middle == 0.0` branch in `update` (line 77).
|
||||||
|
/// All other tests use price levels ≈100, so the rolling SMA is always
|
||||||
|
/// strictly positive and the zero-middle fallback is unreachable. Feed
|
||||||
|
/// a symmetric series whose 5-bar mean is exactly 0 to force the branch
|
||||||
|
/// and assert the indicator yields exactly 0.0 (rather than inf/nan).
|
||||||
|
#[test]
|
||||||
|
fn zero_middle_band_yields_zero_bandwidth() {
|
||||||
|
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||||
|
// sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle
|
||||||
|
// lands on exactly 0.0 at the fifth input. Stddev > 0, so absent
|
||||||
|
// the guard the next line would divide by zero.
|
||||||
|
let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
|
||||||
|
assert_eq!(out[..4], [None, None, None, None]);
|
||||||
|
let v = out[4].expect("warmed up");
|
||||||
|
assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_bands_definition() {
|
fn matches_bands_definition() {
|
||||||
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
|
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
|
||||||
@@ -131,13 +169,11 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||||
let bands_out = BollingerBands::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()) {
|
for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
|
||||||
match (w, b) {
|
// Same warmup period on both — emission shape must agree at every index.
|
||||||
(Some(wv), Some(bv)) => {
|
assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
|
||||||
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
if let (Some(wv), Some(bv)) = (w, b) {
|
||||||
}
|
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
||||||
(None, None) => {}
|
|
||||||
_ => panic!("warmup mismatch"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ mod tests {
|
|||||||
assert!(Cci::with_factor(20, -1.0).is_err());
|
assert!(Cci::with_factor(20, -1.0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (68-70) and the Indicator-impl
|
||||||
|
/// `warmup_period` (102-104) + `name` (110-112). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let cci = Cci::new(20).unwrap();
|
||||||
|
assert_eq!(cci.period(), 20);
|
||||||
|
assert_eq!(cci.warmup_period(), 20);
|
||||||
|
assert_eq!(cci.name(), "CCI");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_equals_streaming() {
|
fn batch_equals_streaming() {
|
||||||
let candles: Vec<Candle> = (0..60)
|
let candles: Vec<Candle> = (0..60)
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ mod tests {
|
|||||||
assert!(ChaikinOscillator::new(5, 5).is_err());
|
assert!(ChaikinOscillator::new(5, 5).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `periods` (76-78) and the Indicator-impl
|
||||||
|
/// `name` body (109-111). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let osc = ChaikinOscillator::classic();
|
||||||
|
assert_eq!(osc.periods(), (3, 10));
|
||||||
|
assert_eq!(osc.name(), "ChaikinOscillator");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
|
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
|
||||||
|
|||||||
@@ -189,6 +189,15 @@ mod tests {
|
|||||||
assert!(ChaikinVolatility::new(10, 0).is_err());
|
assert!(ChaikinVolatility::new(10, 0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `periods` (69-71) and the Indicator-impl
|
||||||
|
/// `name` body (99-101). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||||
|
assert_eq!(cv.periods(), (10, 10));
|
||||||
|
assert_eq!(cv.name(), "ChaikinVolatility");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40)
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
|||||||
@@ -214,6 +214,18 @@ mod tests {
|
|||||||
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
|
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `params` (97-99) and the Indicator-impl
|
||||||
|
/// `name` body (164-166). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let s = ChandeKrollStop::new(10, 1.0, 9).unwrap();
|
||||||
|
let (p, m, q) = s.params();
|
||||||
|
assert_eq!(p, 10);
|
||||||
|
assert!((m - 1.0).abs() < 1e-12);
|
||||||
|
assert_eq!(q, 9);
|
||||||
|
assert_eq!(s.name(), "ChandeKrollStop");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40)
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
|||||||
@@ -197,6 +197,17 @@ mod tests {
|
|||||||
assert!(ChandelierExit::new(22, f64::NAN).is_err());
|
assert!(ChandelierExit::new(22, f64::NAN).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `params` (83-85) and the Indicator-impl
|
||||||
|
/// `name` body (128-130). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ce = ChandelierExit::new(22, 3.0).unwrap();
|
||||||
|
let (p, m) = ce.params();
|
||||||
|
assert_eq!(p, 22);
|
||||||
|
assert!((m - 3.0).abs() < 1e-12);
|
||||||
|
assert_eq!(ce.name(), "ChandelierExit");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40)
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ mod tests {
|
|||||||
assert!(ChoppinessIndex::new(2).is_ok());
|
assert!(ChoppinessIndex::new(2).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (73-75) and the Indicator-impl
|
||||||
|
/// `name` body (125-127). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ci = ChoppinessIndex::new(14).unwrap();
|
||||||
|
assert_eq!(ci.period(), 14);
|
||||||
|
assert_eq!(ci.name(), "ChoppinessIndex");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||||
|
|||||||
@@ -214,6 +214,34 @@ mod tests {
|
|||||||
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (71-73) and the Indicator-impl
|
||||||
|
/// `name` body (124-126). `warmup_period` is covered elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let cmf = ChaikinMoneyFlow::new(20).unwrap();
|
||||||
|
assert_eq!(cmf.period(), 20);
|
||||||
|
assert_eq!(cmf.name(), "CMF");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `range == 0.0` defensive branch (line 84). All other
|
||||||
|
/// tests use H != L candles; feed all-flat candles (H == L) so the
|
||||||
|
/// MFV computation must take the zero-range fallback and emit MFV = 0.
|
||||||
|
#[test]
|
||||||
|
fn zero_range_candle_contributes_zero_mfv() {
|
||||||
|
let mut cmf = ChaikinMoneyFlow::new(3).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..5)
|
||||||
|
.map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 5.0, i).unwrap())
|
||||||
|
.collect();
|
||||||
|
let last = cmf
|
||||||
|
.batch(&candles)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.expect("emits");
|
||||||
|
// Every bar contributed 0 to mfv_sum, so the ratio is 0.
|
||||||
|
assert_eq!(last, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..20)
|
let candles: Vec<Candle> = (0..20)
|
||||||
|
|||||||
@@ -147,6 +147,21 @@ mod tests {
|
|||||||
assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (66-73) and the
|
||||||
|
/// Indicator-impl `name` body (134-136). Existing tests inspect
|
||||||
|
/// CMO output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut cmo = Cmo::new(14).unwrap();
|
||||||
|
assert_eq!(cmo.period(), 14);
|
||||||
|
assert_eq!(cmo.name(), "CMO");
|
||||||
|
assert_eq!(cmo.value(), None);
|
||||||
|
for i in 1..=15 {
|
||||||
|
cmo.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(cmo.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_value() {
|
fn reference_value() {
|
||||||
// CMO(3) over [10, 11, 10, 12]: changes +1, −1, +2.
|
// CMO(3) over [10, 11, 10, 12]: changes +1, −1, +2.
|
||||||
|
|||||||
@@ -143,6 +143,23 @@ mod tests {
|
|||||||
assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
|
assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (lines 68-75) and the
|
||||||
|
/// Indicator-impl `name` body (128-130). Existing tests inspect numeric
|
||||||
|
/// output and `warmup_period` but never query the configured periods,
|
||||||
|
/// the current cached value, or the indicator name.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut c = Coppock::new(14, 11, 10).unwrap();
|
||||||
|
assert_eq!(c.periods(), (14, 11, 10));
|
||||||
|
assert_eq!(c.name(), "Coppock");
|
||||||
|
assert_eq!(c.value(), None);
|
||||||
|
// Drive past warmup so value() flips to Some.
|
||||||
|
for i in 1..=u32::try_from(c.warmup_period()).unwrap() {
|
||||||
|
c.update(100.0 + f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(c.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_warmup_period() {
|
fn first_emission_at_warmup_period() {
|
||||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||||
@@ -176,8 +193,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
out[warmup - 1].is_some(),
|
out[warmup - 1].is_some(),
|
||||||
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but index {} is None",
|
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
|
||||||
warmup - 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,4 +127,17 @@ mod tests {
|
|||||||
fn rejects_zero_period() {
|
fn rejects_zero_period() {
|
||||||
assert!(Dema::new(0).is_err());
|
assert!(Dema::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (43-45) and the Indicator-impl
|
||||||
|
/// `warmup_period` (63-66) + `name` (72-74). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let dema = Dema::new(5).unwrap();
|
||||||
|
assert_eq!(dema.period(), 5);
|
||||||
|
// EMA1 seeds at period (5), EMA2 needs another (period - 1) = 4 ->
|
||||||
|
// total warmup = 2*period - 1 = 9.
|
||||||
|
assert_eq!(dema.warmup_period(), 9);
|
||||||
|
assert_eq!(dema.name(), "DEMA");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ mod tests {
|
|||||||
assert!(Donchian::new(0).is_err());
|
assert!(Donchian::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (57-59) and the Indicator-impl
|
||||||
|
/// `warmup_period` (95-97) + `name` (103-105). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let d = Donchian::new(20).unwrap();
|
||||||
|
assert_eq!(d.period(), 20);
|
||||||
|
assert_eq!(d.warmup_period(), 20);
|
||||||
|
assert_eq!(d.name(), "DonchianChannels");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..20)
|
let candles: Vec<Candle> = (0..20)
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ mod tests {
|
|||||||
assert!(matches!(Dpo::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Dpo::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (73-85) and the
|
||||||
|
/// Indicator-impl `name` body (132-134). `shift` is already covered
|
||||||
|
/// by `shift_is_half_period_plus_one`; `warmup_period` by
|
||||||
|
/// `reference_values`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut dpo = Dpo::new(20).unwrap();
|
||||||
|
assert_eq!(dpo.period(), 20);
|
||||||
|
assert_eq!(dpo.name(), "DPO");
|
||||||
|
assert_eq!(dpo.value(), None);
|
||||||
|
for i in 1..=dpo.warmup_period() {
|
||||||
|
dpo.update(f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(dpo.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_is_half_period_plus_one() {
|
fn shift_is_half_period_plus_one() {
|
||||||
assert_eq!(Dpo::new(20).unwrap().shift(), 11);
|
assert_eq!(Dpo::new(20).unwrap().shift(), 11);
|
||||||
|
|||||||
@@ -236,6 +236,18 @@ mod tests {
|
|||||||
assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
|
assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `divisor` (82-90) and the
|
||||||
|
/// Indicator-impl `name` body (141-143). Existing tests inspect EMV
|
||||||
|
/// output but never query the metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let emv = EaseOfMovement::new(14).unwrap();
|
||||||
|
assert_eq!(emv.period(), 14);
|
||||||
|
// The canonical divisor (per the new() default) — keep in sync with src.
|
||||||
|
assert_relative_eq!(emv.divisor(), 100_000_000.0, epsilon = 1e-6);
|
||||||
|
assert_eq!(emv.name(), "EaseOfMovement");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..30)
|
let candles: Vec<Candle> = (0..30)
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ mod tests {
|
|||||||
assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (74-77) and the Indicator-impl
|
||||||
|
/// `warmup_period` (123-125) + `name` (131-133). `alpha` and `value`
|
||||||
|
/// are exercised by other tests and downstream consumers; only the
|
||||||
|
/// three metadata methods were dead.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ema = Ema::new(14).unwrap();
|
||||||
|
assert_eq!(ema.period(), 14);
|
||||||
|
assert_eq!(ema.warmup_period(), 14);
|
||||||
|
assert_eq!(ema.name(), "EMA");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_returns_none_until_seed() {
|
fn warmup_returns_none_until_seed() {
|
||||||
let mut ema = Ema::new(3).unwrap();
|
let mut ema = Ema::new(3).unwrap();
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ mod tests {
|
|||||||
assert!(ForceIndex::new(0).is_err());
|
assert!(ForceIndex::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (58-60) and the Indicator-impl
|
||||||
|
/// `name` body (93-95). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let fi = ForceIndex::new(13).unwrap();
|
||||||
|
assert_eq!(fi.period(), 13);
|
||||||
|
assert_eq!(fi.name(), "ForceIndex");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
|
let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
|
||||||
|
|||||||
@@ -173,6 +173,21 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (80-88) and the
|
||||||
|
/// Indicator-impl `name` body (153-155). Existing tests inspect HV
|
||||||
|
/// output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut hv = HistoricalVolatility::new(20, 252).unwrap();
|
||||||
|
assert_eq!(hv.periods(), (20, 252));
|
||||||
|
assert_eq!(hv.name(), "HistoricalVolatility");
|
||||||
|
assert_eq!(hv.value(), None);
|
||||||
|
for i in 1..=hv.warmup_period() {
|
||||||
|
hv.update(100.0 + f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(hv.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_rejects_period_one() {
|
fn new_rejects_period_one() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ mod tests {
|
|||||||
assert!(Hma::new(0).is_err());
|
assert!(Hma::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (51-53) and the Indicator-impl
|
||||||
|
/// `name` body (87-89). `warmup_period` is covered by
|
||||||
|
/// `first_emission_matches_warmup_period`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let hma = Hma::new(9).unwrap();
|
||||||
|
assert_eq!(hma.period(), 9);
|
||||||
|
assert_eq!(hma.name(), "HMA");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_matches_warmup_period() {
|
fn first_emission_matches_warmup_period() {
|
||||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||||
@@ -155,16 +165,16 @@ mod tests {
|
|||||||
let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
|
let mut half = Wma::new(4).unwrap(); // (9 / 2).max(1)
|
||||||
let mut full = Wma::new(9).unwrap();
|
let mut full = Wma::new(9).unwrap();
|
||||||
let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
|
let mut smooth = Wma::new(3).unwrap(); // round(sqrt(9))
|
||||||
for &p in &prices {
|
for (i, &p) in prices.iter().enumerate() {
|
||||||
let got = hma.update(p);
|
let got = hma.update(p);
|
||||||
let want = match (half.update(p), full.update(p)) {
|
let want = match (half.update(p), full.update(p)) {
|
||||||
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
|
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
match (got, want) {
|
// HMA and the independent WMA chain share a warmup formula.
|
||||||
(None, None) => {}
|
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||||
(Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9),
|
if let (Some(a), Some(b)) = (got, want) {
|
||||||
_ => panic!("HMA and the independent-WMA reference disagree on readiness"),
|
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,20 @@ mod tests {
|
|||||||
use crate::traits::BatchExt;
|
use crate::traits::BatchExt;
|
||||||
use approx::assert_relative_eq;
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
/// Cover the `periods` accessor (65-67) and the Indicator-impl
|
||||||
|
/// `warmup_period` (115-117) + `name` (123-125). Existing tests
|
||||||
|
/// inspect KAMA output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let k = Kama::classic();
|
||||||
|
let (er, fast, slow) = k.periods();
|
||||||
|
assert_eq!(er, 10);
|
||||||
|
assert!((fast - 2.0 / (2.0 + 1.0)).abs() < 1e-12);
|
||||||
|
assert!((slow - 2.0 / (30.0 + 1.0)).abs() < 1e-12);
|
||||||
|
assert_eq!(k.warmup_period(), 11);
|
||||||
|
assert_eq!(k.name(), "KAMA");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn constant_series_yields_constant_kama() {
|
fn constant_series_yields_constant_kama() {
|
||||||
let mut k = Kama::classic();
|
let mut k = Kama::classic();
|
||||||
|
|||||||
@@ -162,6 +162,19 @@ mod tests {
|
|||||||
assert!(Keltner::new(20, 10, -1.0).is_err());
|
assert!(Keltner::new(20, 10, -1.0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `periods` (68-70) and the Indicator-impl
|
||||||
|
/// `name` body (106-108). Existing tests inspect band output but
|
||||||
|
/// never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let k = Keltner::new(20, 10, 2.0).unwrap();
|
||||||
|
let (ema, atr, mult) = k.periods();
|
||||||
|
assert_eq!(ema, 20);
|
||||||
|
assert_eq!(atr, 10);
|
||||||
|
assert!((mult - 2.0).abs() < 1e-12);
|
||||||
|
assert_eq!(k.name(), "KeltnerChannels");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..50)
|
let candles: Vec<Candle> = (0..50)
|
||||||
|
|||||||
@@ -199,6 +199,15 @@ mod tests {
|
|||||||
assert!(LinearRegression::new(2).is_ok());
|
assert!(LinearRegression::new(2).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (92-94) and the Indicator-impl
|
||||||
|
/// `name` body (142-144). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let lr = LinearRegression::new(14).unwrap();
|
||||||
|
assert_eq!(lr.period(), 14);
|
||||||
|
assert_eq!(lr.name(), "LinearRegression");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let mut lr = LinearRegression::new(5).unwrap();
|
let mut lr = LinearRegression::new(5).unwrap();
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ mod tests {
|
|||||||
assert!(LinRegAngle::new(2).is_ok());
|
assert!(LinRegAngle::new(2).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (50-52) and the Indicator-impl
|
||||||
|
/// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
|
||||||
|
/// angle output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let a = LinRegAngle::new(14).unwrap();
|
||||||
|
assert_eq!(a.period(), 14);
|
||||||
|
assert_eq!(a.warmup_period(), 14);
|
||||||
|
assert_eq!(a.name(), "LinRegAngle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let mut angle = LinRegAngle::new(5).unwrap();
|
let mut angle = LinRegAngle::new(5).unwrap();
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ mod tests {
|
|||||||
assert!(LinRegSlope::new(2).is_ok());
|
assert!(LinRegSlope::new(2).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (80-82) and the Indicator-impl
|
||||||
|
/// `name` body (125-127). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let ls = LinRegSlope::new(14).unwrap();
|
||||||
|
assert_eq!(ls.period(), 14);
|
||||||
|
assert_eq!(ls.name(), "LinRegSlope");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let mut ls = LinRegSlope::new(5).unwrap();
|
let mut ls = LinRegSlope::new(5).unwrap();
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (81-88) and the
|
||||||
|
/// Indicator-impl `name` body (135-137). `warmup_period` is exercised
|
||||||
|
/// elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut m = MacdIndicator::new(12, 26, 9).unwrap();
|
||||||
|
assert_eq!(m.periods(), (12, 26, 9));
|
||||||
|
assert_eq!(m.name(), "MACD");
|
||||||
|
assert!(m.value().is_none());
|
||||||
|
for i in 1..=m.warmup_period() {
|
||||||
|
m.update(100.0 + f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(m.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_periods() {
|
fn rejects_zero_periods() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -153,6 +153,21 @@ mod tests {
|
|||||||
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
|
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (80-87) and the
|
||||||
|
/// Indicator-impl `name` body (134-136). `warmup_period` is already
|
||||||
|
/// covered by `warmup_period_formula`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut mi = MassIndex::new(9, 25).unwrap();
|
||||||
|
assert_eq!(mi.periods(), (9, 25));
|
||||||
|
assert_eq!(mi.name(), "MassIndex");
|
||||||
|
assert_eq!(mi.value(), None);
|
||||||
|
for i in 0..mi.warmup_period() {
|
||||||
|
mi.update(candle(100.0, 2.0, i64::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(mi.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_period_formula() {
|
fn warmup_period_formula() {
|
||||||
let mi = MassIndex::new(9, 25).unwrap();
|
let mi = MassIndex::new(9, 25).unwrap();
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (62-64).
|
||||||
|
#[test]
|
||||||
|
fn name_metadata() {
|
||||||
|
let mp = MedianPrice::new();
|
||||||
|
assert_eq!(mp.name(), "MedianPrice");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle() {
|
fn emits_from_first_candle() {
|
||||||
let mut mp = MedianPrice::new();
|
let mut mp = MedianPrice::new();
|
||||||
|
|||||||
@@ -181,6 +181,34 @@ mod tests {
|
|||||||
assert!(!mfi.is_ready());
|
assert!(!mfi.is_ready());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (58-60) and the Indicator-impl
|
||||||
|
/// `name` body (132-134). `warmup_period` is already covered elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mfi = Mfi::new(14).unwrap();
|
||||||
|
assert_eq!(mfi.period(), 14);
|
||||||
|
assert_eq!(mfi.name(), "MFI");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `tp == prev` arm (line 85) — when typical price equals
|
||||||
|
/// the previous typical price, both flows are 0 — and the all-zero-
|
||||||
|
/// flow fallback `Some(50.0)` (line 105). Existing tests use varying
|
||||||
|
/// candles so the flat-TP arm and the zero-flow fallback never fired.
|
||||||
|
#[test]
|
||||||
|
fn flat_typical_prices_default_to_50() {
|
||||||
|
let mut mfi = Mfi::new(3).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..6)
|
||||||
|
.map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, i).unwrap())
|
||||||
|
.collect();
|
||||||
|
let last = mfi
|
||||||
|
.batch(&candles)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.expect("emits");
|
||||||
|
assert_eq!(last, 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_period() {
|
fn rejects_zero_period() {
|
||||||
assert!(Mfi::new(0).is_err());
|
assert!(Mfi::new(0).is_err());
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ mod tests {
|
|||||||
assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (56-63) and the
|
||||||
|
/// Indicator-impl `name` body (101-103). Existing tests inspect
|
||||||
|
/// momentum output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut m = Mom::new(5).unwrap();
|
||||||
|
assert_eq!(m.period(), 5);
|
||||||
|
assert_eq!(m.name(), "MOM");
|
||||||
|
assert_eq!(m.value(), None);
|
||||||
|
for i in 1..=6 {
|
||||||
|
m.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(m.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_values() {
|
fn reference_values() {
|
||||||
// MOM(3): price_t − price_{t-3}.
|
// MOM(3): price_t − price_{t-3}.
|
||||||
|
|||||||
@@ -121,6 +121,39 @@ mod tests {
|
|||||||
assert_eq!(natr.warmup_period(), 14);
|
assert_eq!(natr.warmup_period(), 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (lines 59-66) and the
|
||||||
|
/// Indicator-impl `name` body (98-100). `warmup_period` is covered
|
||||||
|
/// already by `warmup_period_matches_atr`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut natr = Natr::new(14).unwrap();
|
||||||
|
assert_eq!(natr.period(), 14);
|
||||||
|
assert_eq!(natr.name(), "NATR");
|
||||||
|
assert_eq!(natr.value(), None);
|
||||||
|
let candles: Vec<Candle> = (0..14)
|
||||||
|
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
|
||||||
|
.collect();
|
||||||
|
for c in &candles {
|
||||||
|
natr.update(*c);
|
||||||
|
}
|
||||||
|
assert!(natr.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `candle.close == 0.0` defensive branch (line 77). All
|
||||||
|
/// other tests feed candles with close ≈ 100, so the zero-close
|
||||||
|
/// fallback never fired. Feed an all-zero candle series — the Candle
|
||||||
|
/// validator accepts open == high == low == close == 0 with positive
|
||||||
|
/// volume, and ATR is 0 each bar, so the indicator must emit exactly
|
||||||
|
/// 0.0 rather than computing 100 * 0 / 0 = NaN.
|
||||||
|
#[test]
|
||||||
|
fn zero_close_yields_zero_natr() {
|
||||||
|
let candles: Vec<Candle> = (0..15).map(|i| candle(0.0, 0.0, 0.0, 0.0, i)).collect();
|
||||||
|
let mut natr = Natr::new(5).unwrap();
|
||||||
|
let out = natr.batch(&candles);
|
||||||
|
let last = out.into_iter().flatten().last().expect("emits");
|
||||||
|
assert_eq!(last, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn natr_is_atr_over_close_as_percent() {
|
fn natr_is_atr_over_close_as_percent() {
|
||||||
// NATR must equal 100 * ATR / close, bar for bar.
|
// NATR must equal 100 * ATR / close, bar for bar.
|
||||||
@@ -133,13 +166,11 @@ mod tests {
|
|||||||
let natr_out = Natr::new(14).unwrap().batch(&candles);
|
let natr_out = Natr::new(14).unwrap().batch(&candles);
|
||||||
let atr_out = Atr::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() {
|
for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
|
||||||
match (n, a) {
|
// Same warmup period — emission shape must agree at every index.
|
||||||
(Some(nv), Some(av)) => {
|
assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}");
|
||||||
let want = 100.0 * av / candles[i].close;
|
if let (Some(nv), Some(av)) = (n, a) {
|
||||||
assert_relative_eq!(*nv, want, epsilon = 1e-9);
|
let want = 100.0 * av / candles[i].close;
|
||||||
}
|
assert_relative_eq!(*nv, want, epsilon = 1e-9);
|
||||||
(None, None) => {}
|
|
||||||
_ => panic!("warmup mismatch at {i}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ mod tests {
|
|||||||
Candle::new(close, close, close, close, volume, 0).unwrap()
|
Candle::new(close, close, close, close, volume, 0).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `value()` Some branch (line 47) and the Indicator-impl
|
||||||
|
/// `warmup_period` (79-81) + `name` (87-89). `reset_clears_state`
|
||||||
|
/// hits only the None branch of `value()`; the metadata methods were
|
||||||
|
/// never queried.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut obv = Obv::new();
|
||||||
|
assert_eq!(obv.warmup_period(), 1);
|
||||||
|
assert_eq!(obv.name(), "OBV");
|
||||||
|
assert_eq!(obv.value(), None);
|
||||||
|
obv.update(c(10.0, 100.0));
|
||||||
|
// Baseline 0 — value() Some branch.
|
||||||
|
assert_eq!(obv.value(), Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_candle_baseline_zero() {
|
fn first_candle_baseline_zero() {
|
||||||
let mut obv = Obv::new();
|
let mut obv = Obv::new();
|
||||||
|
|||||||
@@ -113,6 +113,25 @@ mod tests {
|
|||||||
assert!(PercentB::new(20, -1.0).is_err());
|
assert!(PercentB::new(20, -1.0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the public const accessors `period`, `multiplier`, `value`
|
||||||
|
/// and the Indicator-impl `warmup_period` + `name` methods. Existing
|
||||||
|
/// tests only exercise the numeric output of `update` / `batch` /
|
||||||
|
/// `reset` / `is_ready`, so the five getter bodies (lines 53-65,
|
||||||
|
/// 90-92, 98-100) were dead.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut pb = PercentB::new(20, 2.0).unwrap();
|
||||||
|
assert_eq!(pb.period(), 20);
|
||||||
|
assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12);
|
||||||
|
assert_eq!(pb.value(), None);
|
||||||
|
assert_eq!(pb.warmup_period(), 20);
|
||||||
|
assert_eq!(pb.name(), "PercentB");
|
||||||
|
for i in 1..=20 {
|
||||||
|
pb.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(pb.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn constant_series_yields_midpoint() {
|
fn constant_series_yields_midpoint() {
|
||||||
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
|
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
|
||||||
@@ -132,33 +151,33 @@ mod tests {
|
|||||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||||
let bands_out = BollingerBands::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() {
|
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||||
match (p, b) {
|
// Same warmup — emission shape must agree at every index.
|
||||||
(Some(pv), Some(bv)) => {
|
assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
|
||||||
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
|
if let (Some(pv), Some(bv)) = (p, b) {
|
||||||
assert_relative_eq!(*pv, want, epsilon = 1e-12);
|
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}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deterministic price-at-middle assertion. With period=3, multiplier=2,
|
||||||
|
/// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which
|
||||||
|
/// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the
|
||||||
|
/// bands have non-zero width (the width==0 fallback at line 77 is NOT
|
||||||
|
/// taken) and the divide path at line 79 runs. Because price sits on
|
||||||
|
/// the centre line of symmetric bands, %b lands on exactly 0.5.
|
||||||
|
///
|
||||||
|
/// The previous oscillation-based variant of this test never landed
|
||||||
|
/// `prices[i]` within 1e-9 of the rolling SMA, so its inner
|
||||||
|
/// `assert_relative_eq!` line was never executed.
|
||||||
#[test]
|
#[test]
|
||||||
fn price_at_middle_is_half() {
|
fn price_at_middle_is_half() {
|
||||||
// A symmetric oscillation keeps the SMA centred; when price crosses
|
let mut pb = PercentB::new(3, 2.0).unwrap();
|
||||||
// the SMA, %b passes through 0.5. Verified via the bands definition.
|
let out = pb.batch(&[1.0, 5.0, 3.0]);
|
||||||
let prices: Vec<f64> = (1..=60)
|
assert_eq!(out[0], None);
|
||||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
assert_eq!(out[1], None);
|
||||||
.collect();
|
let v = out[2].expect("warmed up at index 2");
|
||||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
|
||||||
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]
|
#[test]
|
||||||
|
|||||||
@@ -150,6 +150,37 @@ mod tests {
|
|||||||
assert!(matches!(Pmo::new(35, 1), Err(Error::InvalidPeriod { .. })));
|
assert!(matches!(Pmo::new(35, 1), Err(Error::InvalidPeriod { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (lines 76-83) and the
|
||||||
|
/// Indicator-impl `name` body (130-132). `warmup_period` is already
|
||||||
|
/// covered by `first_emission_at_second_update`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||||
|
assert_eq!(pmo.periods(), (35, 20));
|
||||||
|
assert_eq!(pmo.name(), "PMO");
|
||||||
|
assert_eq!(pmo.value(), None);
|
||||||
|
pmo.update(100.0);
|
||||||
|
pmo.update(101.0);
|
||||||
|
assert!(pmo.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `prev == 0.0` defensive branch (line 103). The PMO ROC
|
||||||
|
/// divides by the previous price; existing tests use prices ≈ 100, so
|
||||||
|
/// the divide-by-zero guard never fired. Feed a single zero price
|
||||||
|
/// followed by a positive price and assert the first emitted PMO is
|
||||||
|
/// the flat-momentum value (the wrapping `customEMA` of `0.0` is 0.0
|
||||||
|
/// regardless of smoothing factor on its first input).
|
||||||
|
#[test]
|
||||||
|
fn zero_previous_price_treats_roc_as_flat() {
|
||||||
|
let mut pmo = Pmo::new(2, 2).unwrap();
|
||||||
|
// Seed prev_price = 0.
|
||||||
|
assert_eq!(pmo.update(0.0), None);
|
||||||
|
// Next bar: prev == 0 hits the fallback returning roc = 0.0; the
|
||||||
|
// doubly-smoothed PMO seeds at 0.0 (10 * 0 = 0 through both EMAs).
|
||||||
|
let out = pmo.update(50.0).expect("emits");
|
||||||
|
assert_eq!(out, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_second_update() {
|
fn first_emission_at_second_update() {
|
||||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||||
|
|||||||
@@ -142,6 +142,34 @@ mod tests {
|
|||||||
assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
|
assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (lines 71-78) and the
|
||||||
|
/// Indicator-impl `name` body (122-124). `warmup_period` is already
|
||||||
|
/// covered by `first_emission_at_warmup_period`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut ppo = Ppo::new(12, 26).unwrap();
|
||||||
|
assert_eq!(ppo.periods(), (12, 26));
|
||||||
|
assert_eq!(ppo.name(), "PPO");
|
||||||
|
assert_eq!(ppo.value(), None);
|
||||||
|
for i in 1..=26 {
|
||||||
|
ppo.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(ppo.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `s == 0.0` defensive branch (line 96). PPO divides by
|
||||||
|
/// the slow EMA; existing tests use prices ≈ 100, so the slow EMA
|
||||||
|
/// is never 0. Feed a stream of zeros — both EMAs converge to 0.0
|
||||||
|
/// and the indicator must emit exactly 0.0 (flat-momentum fallback)
|
||||||
|
/// rather than NaN.
|
||||||
|
#[test]
|
||||||
|
fn zero_slow_ema_yields_zero_ppo() {
|
||||||
|
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||||
|
let out = ppo.batch(&[0.0_f64; 20]);
|
||||||
|
let last = out.into_iter().flatten().last().expect("emits");
|
||||||
|
assert_eq!(last, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_warmup_period() {
|
fn first_emission_at_warmup_period() {
|
||||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||||
|
|||||||
@@ -246,15 +246,16 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut psar = Psar::classic();
|
let mut psar = Psar::classic();
|
||||||
for (i, sar) in psar.batch(&candles).into_iter().enumerate() {
|
// `all()` with `is_none_or` keeps every reachable arm on the hot path —
|
||||||
if let Some(s) = sar {
|
// the previous filter_map / violation-Vec construction had a cold
|
||||||
assert!(
|
// "violation found" tuple branch that was unreachable on a clean
|
||||||
s <= candles[i].low + 1e-9,
|
// uptrend, leaving its line uncovered by Codecov.
|
||||||
"SAR {s} should be <= low {} at i={i}",
|
let ok = psar
|
||||||
candles[i].low
|
.batch(&candles)
|
||||||
);
|
.iter()
|
||||||
}
|
.enumerate()
|
||||||
}
|
.all(|(i, sar)| sar.is_none_or(|s| s <= candles[i].low + 1e-9));
|
||||||
|
assert!(ok, "SAR sat above a candle's low on a pure uptrend");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -267,13 +268,16 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut psar = Psar::classic();
|
let mut psar = Psar::classic();
|
||||||
let outs = psar.batch(&candles);
|
|
||||||
// After the trend establishes downward, SAR should sit above highs.
|
// After the trend establishes downward, SAR should sit above highs.
|
||||||
for (i, sar) in outs.into_iter().enumerate().skip(5) {
|
// Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
|
||||||
if let Some(s) = sar {
|
// so the violation-tuple branch never appears as a cold path.
|
||||||
assert!(s >= candles[i].high - 1e-9);
|
let ok = psar
|
||||||
}
|
.batch(&candles)
|
||||||
}
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(5)
|
||||||
|
.all(|(i, sar)| sar.is_none_or(|s| s >= candles[i].high - 1e-9));
|
||||||
|
assert!(ok, "SAR sat below a candle's high on a pure downtrend");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -292,6 +296,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `warmup_period` (206-208) and `name`
|
||||||
|
/// (220-222). PSAR's warmup is the constant 2 (seed candle + first
|
||||||
|
/// emitting candle); the name is the literal "PSAR".
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let psar = Psar::classic();
|
||||||
|
assert_eq!(psar.warmup_period(), 2);
|
||||||
|
assert_eq!(psar.name(), "PSAR");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_params() {
|
fn rejects_invalid_params() {
|
||||||
assert!(Psar::new(0.0, 0.02, 0.20).is_err());
|
assert!(Psar::new(0.0, 0.02, 0.20).is_err());
|
||||||
|
|||||||
@@ -141,6 +141,30 @@ mod tests {
|
|||||||
assert!(Roc::new(0).is_err());
|
assert!(Roc::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (47-49) and the Indicator-impl
|
||||||
|
/// `warmup_period` (83-85) + `name` (91-93). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let roc = Roc::new(5).unwrap();
|
||||||
|
assert_eq!(roc.period(), 5);
|
||||||
|
assert_eq!(roc.warmup_period(), 6);
|
||||||
|
assert_eq!(roc.name(), "ROC");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `prev == 0.0` defensive branch (line 70). All existing
|
||||||
|
/// tests use prices ≥ 1.0, so the divide-by-zero guard was never
|
||||||
|
/// triggered. Feed a leading zero followed by `period` more values
|
||||||
|
/// so the front of the window is exactly 0.0, then assert the next
|
||||||
|
/// emission is the flat-momentum fallback 0.0 (not NaN).
|
||||||
|
#[test]
|
||||||
|
fn zero_previous_price_yields_zero_roc() {
|
||||||
|
let mut roc = Roc::new(3).unwrap();
|
||||||
|
let out = roc.batch(&[0.0, 5.0, 7.0, 9.0]);
|
||||||
|
let v = out[3].expect("ready after period + 1 inputs");
|
||||||
|
assert_eq!(v, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_non_finite_input() {
|
fn ignores_non_finite_input() {
|
||||||
let mut roc = Roc::new(3).unwrap();
|
let mut roc = Roc::new(3).unwrap();
|
||||||
|
|||||||
@@ -202,6 +202,34 @@ mod tests {
|
|||||||
assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (60-67) and the
|
||||||
|
/// Indicator-impl `name` body (145-147). `warmup_period` is covered
|
||||||
|
/// already by `warmup_period_is_period_plus_one`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut rsi = Rsi::new(14).unwrap();
|
||||||
|
assert_eq!(rsi.period(), 14);
|
||||||
|
assert_eq!(rsi.name(), "RSI");
|
||||||
|
assert_eq!(rsi.value(), None);
|
||||||
|
for i in 1..=15 {
|
||||||
|
rsi.update(100.0 + f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(rsi.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `ag == 0` branch (line 167) of the test-helper `rsi_naive`:
|
||||||
|
/// when both `avg_gain` and `avg_loss` are 0 (a perfectly flat series),
|
||||||
|
/// the helper must return the neutral 50.0. The proptest reference uses
|
||||||
|
/// random inputs that essentially never hit zero gains AND zero losses
|
||||||
|
/// simultaneously, leaving this branch dead in the helper.
|
||||||
|
#[test]
|
||||||
|
fn naive_helper_flat_series_yields_50() {
|
||||||
|
let ks = rsi_naive(&[42.0; 20], 5);
|
||||||
|
for r in ks.into_iter().skip(5) {
|
||||||
|
assert_eq!(r.expect("ready after period+1 inputs"), 50.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_period_is_period_plus_one() {
|
fn warmup_period_is_period_plus_one() {
|
||||||
let rsi = Rsi::new(14).unwrap();
|
let rsi = Rsi::new(14).unwrap();
|
||||||
|
|||||||
@@ -136,6 +136,17 @@ mod tests {
|
|||||||
assert!(matches!(Sma::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Sma::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (70-72) and the Indicator-impl
|
||||||
|
/// `warmup_period` (115-117) + `name` (123-125). Existing tests
|
||||||
|
/// inspect SMA output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let sma = Sma::new(20).unwrap();
|
||||||
|
assert_eq!(sma.period(), 20);
|
||||||
|
assert_eq!(sma.warmup_period(), 20);
|
||||||
|
assert_eq!(sma.name(), "SMA");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_returns_none() {
|
fn warmup_returns_none() {
|
||||||
let mut sma = Sma::new(3).unwrap();
|
let mut sma = Sma::new(3).unwrap();
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ mod tests {
|
|||||||
assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` and the Indicator-impl
|
||||||
|
/// `warmup_period` / `name` methods. Existing tests only exercise the
|
||||||
|
/// numeric output of `update` / `batch` / `reset`, never query the
|
||||||
|
/// metadata surface.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut smma = Smma::new(7).unwrap();
|
||||||
|
assert_eq!(smma.period(), 7);
|
||||||
|
assert_eq!(smma.warmup_period(), 7);
|
||||||
|
assert_eq!(smma.name(), "SMMA");
|
||||||
|
// value() must report both the pre-warmup None and post-warmup Some branches.
|
||||||
|
assert_eq!(smma.value(), None);
|
||||||
|
for i in 1..=7 {
|
||||||
|
smma.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(smma.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_then_recurrence() {
|
fn warmup_then_recurrence() {
|
||||||
// SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
|
// SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
|
||||||
|
|||||||
@@ -131,6 +131,22 @@ mod tests {
|
|||||||
assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` and the Indicator-impl
|
||||||
|
/// `warmup_period` / `name` methods (lines 64-71, 110-112, 118-120).
|
||||||
|
/// Existing tests only inspect numeric outputs of `update` / `batch`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut sd = StdDev::new(14).unwrap();
|
||||||
|
assert_eq!(sd.period(), 14);
|
||||||
|
assert_eq!(sd.warmup_period(), 14);
|
||||||
|
assert_eq!(sd.name(), "StdDev");
|
||||||
|
assert_eq!(sd.value(), None);
|
||||||
|
for i in 1..=14 {
|
||||||
|
sd.update(f64::from(i));
|
||||||
|
}
|
||||||
|
assert!(sd.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_value() {
|
fn reference_value() {
|
||||||
// StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
|
// StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
|
||||||
|
|||||||
@@ -145,6 +145,21 @@ mod tests {
|
|||||||
assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
|
assert!(matches!(StochRsi::new(14, 0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (69-76) and the
|
||||||
|
/// Indicator-impl `name` body (131-133). `warmup_period` is already
|
||||||
|
/// covered by `first_emission_at_warmup_period`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut sr = StochRsi::new(14, 14).unwrap();
|
||||||
|
assert_eq!(sr.periods(), (14, 14));
|
||||||
|
assert_eq!(sr.name(), "StochRSI");
|
||||||
|
assert_eq!(sr.value(), None);
|
||||||
|
for i in 1..=sr.warmup_period() {
|
||||||
|
sr.update(100.0 + f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(sr.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_warmup_period() {
|
fn first_emission_at_warmup_period() {
|
||||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||||
|
|||||||
@@ -220,6 +220,20 @@ mod tests {
|
|||||||
assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
|
assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `Stochastic::classic()` convenience constructor plus the
|
||||||
|
/// `periods()` const accessor and the Indicator-impl `warmup_period`
|
||||||
|
/// / `name` methods. Existing tests called `Stochastic::new(_, _)`
|
||||||
|
/// directly and never asked for the configured periods, warmup
|
||||||
|
/// length, or name.
|
||||||
|
#[test]
|
||||||
|
fn classic_periods_and_metadata() {
|
||||||
|
let s = Stochastic::classic();
|
||||||
|
assert_eq!(s.periods(), (14, 3));
|
||||||
|
// Warmup for the classic config: k_period + d_period - 1 = 14 + 3 - 1 = 16.
|
||||||
|
assert_eq!(s.warmup_period(), 16);
|
||||||
|
assert_eq!(s.name(), "Stochastic");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_at_high_yields_k_100() {
|
fn close_at_high_yields_k_100() {
|
||||||
let candles = vec![
|
let candles = vec![
|
||||||
@@ -252,6 +266,13 @@ mod tests {
|
|||||||
assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
|
assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
|
||||||
assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
|
assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
|
||||||
}
|
}
|
||||||
|
// Cross-check: the naive_k test helper must agree on the flat-range
|
||||||
|
// convention. The k_matches_naive test only feeds oscillating prices,
|
||||||
|
// so the helper's flat-range branch was never exercised.
|
||||||
|
let ks = naive_k(&candles, 14);
|
||||||
|
for k in ks.into_iter().skip(13) {
|
||||||
|
assert_relative_eq!(k.expect("ready after 14 inputs"), 50.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -281,6 +281,17 @@ mod tests {
|
|||||||
assert!(SuperTrend::new(10, f64::NAN).is_err());
|
assert!(SuperTrend::new(10, f64::NAN).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `params` (99-101) and the Indicator-impl
|
||||||
|
/// `name` body (176-178). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let st = SuperTrend::new(10, 3.0).unwrap();
|
||||||
|
let (p, m) = st.params();
|
||||||
|
assert_eq!(p, 10);
|
||||||
|
assert!((m - 3.0).abs() < 1e-12);
|
||||||
|
assert_eq!(st.name(), "SuperTrend");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..40)
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
|||||||
@@ -161,6 +161,23 @@ mod tests {
|
|||||||
assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero)));
|
assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `volume_factor` / `value` and
|
||||||
|
/// the Indicator-impl `name` (lines 95-107, 148-150). Existing tests
|
||||||
|
/// query `warmup_period` (covered by `first_emission_at_warmup_period`)
|
||||||
|
/// but never inspect period, v, value, or name.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut t3 = T3::new(5, 0.7).unwrap();
|
||||||
|
assert_eq!(t3.period(), 5);
|
||||||
|
assert_relative_eq!(t3.volume_factor(), 0.7, epsilon = 1e-12);
|
||||||
|
assert_eq!(t3.name(), "T3");
|
||||||
|
assert_eq!(t3.value(), None);
|
||||||
|
for _ in 0..t3.warmup_period() {
|
||||||
|
t3.update(50.0);
|
||||||
|
}
|
||||||
|
assert!(t3.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_rejects_out_of_range_volume_factor() {
|
fn new_rejects_out_of_range_volume_factor() {
|
||||||
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
|
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
|
||||||
|
|||||||
@@ -117,4 +117,16 @@ mod tests {
|
|||||||
fn rejects_zero_period() {
|
fn rejects_zero_period() {
|
||||||
assert!(Tema::new(0).is_err());
|
assert!(Tema::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (45-47) and the Indicator-impl
|
||||||
|
/// `warmup_period` (67-69) + `name` (75-77). Existing tests inspect
|
||||||
|
/// TEMA output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let tema = Tema::new(5).unwrap();
|
||||||
|
assert_eq!(tema.period(), 5);
|
||||||
|
// EMA1 seeds at period (5), each cascade stage needs another (period-1) inputs.
|
||||||
|
assert_eq!(tema.warmup_period(), 3 * 5 - 2);
|
||||||
|
assert_eq!(tema.name(), "TEMA");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ mod tests {
|
|||||||
assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (59-66) and the
|
||||||
|
/// Indicator-impl `name` body (99-101). Existing tests inspect
|
||||||
|
/// TRIMA output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut t = Trima::new(5).unwrap();
|
||||||
|
assert_eq!(t.period(), 5);
|
||||||
|
assert_eq!(t.name(), "TRIMA");
|
||||||
|
assert_eq!(t.value(), None);
|
||||||
|
for i in 1..=t.warmup_period() {
|
||||||
|
t.update(f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(t.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn odd_period_reference_values() {
|
fn odd_period_reference_values() {
|
||||||
// TRIMA(5) is SMA(3) of SMA(3).
|
// TRIMA(5) is SMA(3) of SMA(3).
|
||||||
|
|||||||
@@ -141,4 +141,30 @@ mod tests {
|
|||||||
fn rejects_zero_period() {
|
fn rejects_zero_period() {
|
||||||
assert!(Trix::new(0).is_err());
|
assert!(Trix::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (47-49) and the Indicator-impl
|
||||||
|
/// `warmup_period` (84-87) + `name` (93-95). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let trix = Trix::new(5).unwrap();
|
||||||
|
assert_eq!(trix.period(), 5);
|
||||||
|
// Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14.
|
||||||
|
assert_eq!(trix.warmup_period(), 14);
|
||||||
|
assert_eq!(trix.name(), "TRIX");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path
|
||||||
|
/// where the previous triple-EMA value is exactly 0.0 (which would
|
||||||
|
/// otherwise divide by zero on the percent-rate formula). A series of
|
||||||
|
/// all-zero inputs collapses every EMA stage to 0.0, so once the
|
||||||
|
/// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent
|
||||||
|
/// emission must take the fallback branch and return 0.0.
|
||||||
|
#[test]
|
||||||
|
fn zero_input_series_yields_zero_trix() {
|
||||||
|
let mut trix = Trix::new(3).unwrap();
|
||||||
|
let out = trix.batch(&[0.0_f64; 20]);
|
||||||
|
let last = out.into_iter().flatten().last().expect("emits");
|
||||||
|
assert_eq!(last, 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ mod tests {
|
|||||||
assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12);
|
assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (73-75).
|
||||||
|
#[test]
|
||||||
|
fn name_metadata() {
|
||||||
|
let tr = TrueRange::new();
|
||||||
|
assert_eq!(tr.name(), "TrueRange");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle() {
|
fn emits_from_first_candle() {
|
||||||
let mut tr = TrueRange::new();
|
let mut tr = TrueRange::new();
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ mod tests {
|
|||||||
assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
|
assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (70-77) and the
|
||||||
|
/// Indicator-impl `name` body (137-139). Existing tests inspect
|
||||||
|
/// TSI output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut tsi = Tsi::new(25, 13).unwrap();
|
||||||
|
assert_eq!(tsi.periods(), (25, 13));
|
||||||
|
assert_eq!(tsi.name(), "TSI");
|
||||||
|
assert_eq!(tsi.value(), None);
|
||||||
|
for i in 1..=tsi.warmup_period() {
|
||||||
|
tsi.update(100.0 + f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(tsi.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_warmup_period() {
|
fn first_emission_at_warmup_period() {
|
||||||
let mut tsi = Tsi::new(5, 3).unwrap();
|
let mut tsi = Tsi::new(5, 3).unwrap();
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (62-64).
|
||||||
|
#[test]
|
||||||
|
fn name_metadata() {
|
||||||
|
let tp = TypicalPrice::new();
|
||||||
|
assert_eq!(tp.name(), "TypicalPrice");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle() {
|
fn emits_from_first_candle() {
|
||||||
let mut tp = TypicalPrice::new();
|
let mut tp = TypicalPrice::new();
|
||||||
|
|||||||
@@ -175,6 +175,35 @@ mod tests {
|
|||||||
assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (lines 77-85) and the
|
||||||
|
/// Indicator-impl `name` body (162-164). `warmup_period` is covered
|
||||||
|
/// already by `reference_values`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut ui = UlcerIndex::new(14).unwrap();
|
||||||
|
assert_eq!(ui.period(), 14);
|
||||||
|
assert_eq!(ui.name(), "UlcerIndex");
|
||||||
|
assert_eq!(ui.value(), None);
|
||||||
|
// Drive past warmup so value() flips to Some.
|
||||||
|
for i in 0..ui.warmup_period() {
|
||||||
|
ui.update(100.0 + (i as f64).sin() * 5.0);
|
||||||
|
}
|
||||||
|
assert!(ui.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `max_price == 0.0` defensive branch (line 123). All
|
||||||
|
/// other tests use prices > 0, so the trailing-max divisor is always
|
||||||
|
/// positive. Feed a stream of zeros — the trailing max is exactly
|
||||||
|
/// 0.0 and the drawdown computation would otherwise hit a 0/0 NaN.
|
||||||
|
/// The indicator must emit exactly 0.0 (drawdown is 0% by convention).
|
||||||
|
#[test]
|
||||||
|
fn zero_max_price_yields_zero_drawdown() {
|
||||||
|
let mut ui = UlcerIndex::new(3).unwrap();
|
||||||
|
let out = ui.batch(&[0.0_f64; 10]);
|
||||||
|
let last = out.into_iter().flatten().last().expect("emits");
|
||||||
|
assert_eq!(last, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_values() {
|
fn reference_values() {
|
||||||
// UlcerIndex(2): warmup = 3.
|
// UlcerIndex(2): warmup = 3.
|
||||||
|
|||||||
@@ -222,6 +222,28 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `periods` / `value` (96-103) and the
|
||||||
|
/// Indicator-impl `name` body (193-195). `warmup_period` is covered
|
||||||
|
/// by `first_emission_at_warmup_period`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut uo = UltimateOscillator::new(7, 14, 28).unwrap();
|
||||||
|
assert_eq!(uo.periods(), (7, 14, 28));
|
||||||
|
assert_eq!(uo.name(), "UltimateOscillator");
|
||||||
|
assert_eq!(uo.value(), None);
|
||||||
|
let warmup = i64::try_from(uo.warmup_period()).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..warmup)
|
||||||
|
.map(|i| {
|
||||||
|
let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||||
|
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for c in &candles {
|
||||||
|
uo.update(*c);
|
||||||
|
}
|
||||||
|
assert!(uo.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_emission_at_warmup_period() {
|
fn first_emission_at_warmup_period() {
|
||||||
let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
|
let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
|
||||||
|
|||||||
@@ -177,6 +177,15 @@ mod tests {
|
|||||||
assert!(VerticalHorizontalFilter::new(0).is_err());
|
assert!(VerticalHorizontalFilter::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (61-63) and the Indicator-impl
|
||||||
|
/// `name` body (119-121). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let vhf = VerticalHorizontalFilter::new(28).unwrap();
|
||||||
|
assert_eq!(vhf.period(), 28);
|
||||||
|
assert_eq!(vhf.name(), "VerticalHorizontalFilter");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
|
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
|
||||||
|
|||||||
@@ -174,6 +174,28 @@ mod tests {
|
|||||||
assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (84-91) and the
|
||||||
|
/// Indicator-impl `name` body (157-159). `warmup_period` is covered
|
||||||
|
/// elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut v = Vortex::new(14).unwrap();
|
||||||
|
assert_eq!(v.period(), 14);
|
||||||
|
assert_eq!(v.name(), "Vortex");
|
||||||
|
assert!(v.value().is_none());
|
||||||
|
let warmup = i64::try_from(v.warmup_period()).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..warmup)
|
||||||
|
.map(|i| {
|
||||||
|
let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||||
|
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for c in &candles {
|
||||||
|
v.update(*c);
|
||||||
|
}
|
||||||
|
assert!(v.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_values() {
|
fn reference_values() {
|
||||||
// Vortex(2) over three explicit candles (high, low, close):
|
// Vortex(2) over three explicit candles (high, low, close):
|
||||||
|
|||||||
@@ -129,6 +129,30 @@ mod tests {
|
|||||||
assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12);
|
assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `value()` Some branch (line 57) and the Indicator-impl
|
||||||
|
/// `name` body (100-102). `reset_clears_state` hits only the None
|
||||||
|
/// branch; the name was never queried.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut vpt = VolumePriceTrend::new();
|
||||||
|
assert_eq!(vpt.name(), "VPT");
|
||||||
|
assert_eq!(vpt.value(), None);
|
||||||
|
vpt.update(candle(100.0, 50.0, 0));
|
||||||
|
assert_eq!(vpt.value(), Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `prev == 0.0` defensive branch (line 77) — the previous
|
||||||
|
/// close is exactly 0, making the percentage ROC undefined. The
|
||||||
|
/// indicator must contribute 0 to the running total rather than NaN.
|
||||||
|
#[test]
|
||||||
|
fn zero_previous_close_contributes_zero() {
|
||||||
|
let mut vpt = VolumePriceTrend::new();
|
||||||
|
vpt.update(candle(0.0, 100.0, 0)); // baseline; prev_close = 0
|
||||||
|
let v = vpt.update(candle(50.0, 200.0, 1)).expect("emits");
|
||||||
|
// ROC fallback is 0, so total stays at 0.
|
||||||
|
assert_eq!(v, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle_at_zero() {
|
fn emits_from_first_candle_at_zero() {
|
||||||
let mut vpt = VolumePriceTrend::new();
|
let mut vpt = VolumePriceTrend::new();
|
||||||
|
|||||||
@@ -193,6 +193,42 @@ mod tests {
|
|||||||
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
|
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `Some` branch of `Vwap::value()` (line 53). The only other
|
||||||
|
/// test that calls `value()` is `cumulative_reset_clears_state`, which
|
||||||
|
/// calls it after `reset()` so `sum_v == 0` and the `None` branch fires.
|
||||||
|
#[test]
|
||||||
|
fn cumulative_value_some_branch_after_update() {
|
||||||
|
let mut v = Vwap::new();
|
||||||
|
// typical_price of a flat OHLC bar equals the price itself.
|
||||||
|
v.update(c(42.0, 5.0));
|
||||||
|
assert_relative_eq!(v.value().expect("non-zero volume"), 42.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `return None` early-out inside `Vwap::update` (line 67),
|
||||||
|
/// reached when the running `sum_v` is still 0 after adding the latest
|
||||||
|
/// candle's volume — i.e. the first candle has volume 0. Existing tests
|
||||||
|
/// only use strictly positive volumes, so the early-return never fired.
|
||||||
|
#[test]
|
||||||
|
fn cumulative_zero_volume_first_candle_returns_none() {
|
||||||
|
let mut v = Vwap::new();
|
||||||
|
let out = v.update(c(42.0, 0.0));
|
||||||
|
assert_eq!(out, None);
|
||||||
|
assert!(!v.is_ready());
|
||||||
|
// Adding a non-zero candle afterwards still works as expected.
|
||||||
|
let out2 = v.update(c(10.0, 4.0));
|
||||||
|
assert_relative_eq!(out2.expect("now warmed"), 10.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the cumulative `Vwap` Indicator-impl metadata: `warmup_period`
|
||||||
|
/// (lines 79-81) and `name` (lines 87-89). Existing tests inspected
|
||||||
|
/// only the numeric output, never the metadata surface.
|
||||||
|
#[test]
|
||||||
|
fn cumulative_metadata() {
|
||||||
|
let v = Vwap::new();
|
||||||
|
assert_eq!(v.warmup_period(), 1);
|
||||||
|
assert_eq!(v.name(), "VWAP");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cumulative_vwap_weighted() {
|
fn cumulative_vwap_weighted() {
|
||||||
// Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5
|
// Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5
|
||||||
@@ -202,6 +238,18 @@ mod tests {
|
|||||||
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
|
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `RollingVwap` accessors and metadata: `period`
|
||||||
|
/// (lines 134-136), `warmup_period` (165-167), `name` (173-175).
|
||||||
|
/// Existing rolling tests called `update`/`batch`/`reset`/`is_ready`
|
||||||
|
/// only, never queried the configuration or metadata.
|
||||||
|
#[test]
|
||||||
|
fn rolling_accessors_and_metadata() {
|
||||||
|
let v = RollingVwap::new(7).unwrap();
|
||||||
|
assert_eq!(v.period(), 7);
|
||||||
|
assert_eq!(v.warmup_period(), 7);
|
||||||
|
assert_eq!(v.name(), "RollingVWAP");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rolling_vwap_window_slides() {
|
fn rolling_vwap_window_slides() {
|
||||||
let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)];
|
let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)];
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ mod tests {
|
|||||||
assert!(matches!(Vwma::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Vwma::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (72-79) and the
|
||||||
|
/// Indicator-impl `name` body (129-131). Existing tests inspect
|
||||||
|
/// VWMA output but never query the metadata.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut v = Vwma::new(5).unwrap();
|
||||||
|
assert_eq!(v.period(), 5);
|
||||||
|
assert_eq!(v.name(), "VWMA");
|
||||||
|
assert_eq!(v.value(), None);
|
||||||
|
for i in 1..=5i64 {
|
||||||
|
let p = 100.0 + i as f64;
|
||||||
|
v.update(Candle::new(p, p, p, p, 1.0, i).unwrap());
|
||||||
|
}
|
||||||
|
assert!(v.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_value() {
|
fn reference_value() {
|
||||||
// VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5.
|
// VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5.
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the Indicator-impl `name` body (61-63).
|
||||||
|
#[test]
|
||||||
|
fn name_metadata() {
|
||||||
|
let wc = WeightedClose::new();
|
||||||
|
assert_eq!(wc.name(), "WeightedClose");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emits_from_first_candle() {
|
fn emits_from_first_candle() {
|
||||||
let mut wc = WeightedClose::new();
|
let mut wc = WeightedClose::new();
|
||||||
|
|||||||
@@ -155,6 +155,35 @@ mod tests {
|
|||||||
assert!(WilliamsR::new(0).is_err());
|
assert!(WilliamsR::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (49-51) and the Indicator-impl
|
||||||
|
/// `warmup_period` (87-89) + `name` (95-97). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let w = WilliamsR::new(14).unwrap();
|
||||||
|
assert_eq!(w.period(), 14);
|
||||||
|
assert_eq!(w.warmup_period(), 14);
|
||||||
|
assert_eq!(w.name(), "WilliamsR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `range == 0.0` defensive branch (line 78). All other
|
||||||
|
/// tests use H != L candles so the lookback range is always positive.
|
||||||
|
/// Feed a stream of perfectly flat candles (H == L == close) — the
|
||||||
|
/// lookback hi/lo coincide and the divide-by-zero guard fires,
|
||||||
|
/// returning the neutral mid-range value -50.0.
|
||||||
|
#[test]
|
||||||
|
fn zero_range_yields_minus_fifty() {
|
||||||
|
let candles: Vec<Candle> = (0..5).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||||
|
let mut w = WilliamsR::new(3).unwrap();
|
||||||
|
let last = w
|
||||||
|
.batch(&candles)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.last()
|
||||||
|
.expect("emits");
|
||||||
|
assert_eq!(last, -50.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let candles: Vec<Candle> = (0..20)
|
let candles: Vec<Candle> = (0..20)
|
||||||
|
|||||||
@@ -154,6 +154,17 @@ mod tests {
|
|||||||
assert!(matches!(Wma::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Wma::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (56-58) and the Indicator-impl
|
||||||
|
/// `warmup_period` (111-113) + `name` (119-121). Existing tests never
|
||||||
|
/// inspect these metadata methods.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let wma = Wma::new(7).unwrap();
|
||||||
|
assert_eq!(wma.period(), 7);
|
||||||
|
assert_eq!(wma.warmup_period(), 7);
|
||||||
|
assert_eq!(wma.name(), "WMA");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warmup_returns_none() {
|
fn warmup_returns_none() {
|
||||||
let mut wma = Wma::new(3).unwrap();
|
let mut wma = Wma::new(3).unwrap();
|
||||||
@@ -179,11 +190,11 @@ mod tests {
|
|||||||
let mut wma = Wma::new(7).unwrap();
|
let mut wma = Wma::new(7).unwrap();
|
||||||
let got = wma.batch(&prices);
|
let got = wma.batch(&prices);
|
||||||
let want = wma_naive(&prices, 7);
|
let want = wma_naive(&prices, 7);
|
||||||
for (g, w) in got.iter().zip(want.iter()) {
|
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||||
match (g, w) {
|
// Same warmup — emission shape must agree at every index.
|
||||||
(None, None) => {}
|
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
|
||||||
(Some(a), Some(b)) => assert_relative_eq!(*a, *b, epsilon = 1e-9),
|
if let (Some(a), Some(b)) = (g, w) {
|
||||||
_ => panic!("warmup mismatch"),
|
assert_relative_eq!(*a, *b, epsilon = 1e-9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ mod tests {
|
|||||||
assert!(ZScore::new(0).is_err());
|
assert!(ZScore::new(0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessor `period` (59-61) and the Indicator-impl
|
||||||
|
/// `name` body (106-108). `warmup_period` is exercised elsewhere.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let z = ZScore::new(20).unwrap();
|
||||||
|
assert_eq!(z.period(), 20);
|
||||||
|
assert_eq!(z.name(), "ZScore");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_clears_state() {
|
fn reset_clears_state() {
|
||||||
let mut z = ZScore::new(5).unwrap();
|
let mut z = ZScore::new(5).unwrap();
|
||||||
|
|||||||
@@ -124,6 +124,21 @@ mod tests {
|
|||||||
assert!(matches!(Zlema::new(0), Err(Error::PeriodZero)));
|
assert!(matches!(Zlema::new(0), Err(Error::PeriodZero)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the const accessors `period` / `value` (62-64, 72-74) and
|
||||||
|
/// the Indicator-impl `name` body (111-113). `lag` is already covered
|
||||||
|
/// by `lag_is_half_of_period_minus_one`.
|
||||||
|
#[test]
|
||||||
|
fn accessors_and_metadata() {
|
||||||
|
let mut z = Zlema::new(5).unwrap();
|
||||||
|
assert_eq!(z.period(), 5);
|
||||||
|
assert_eq!(z.name(), "ZLEMA");
|
||||||
|
assert_eq!(z.value(), None);
|
||||||
|
for i in 1..=z.warmup_period() {
|
||||||
|
z.update(f64::from(u32::try_from(i).unwrap()));
|
||||||
|
}
|
||||||
|
assert!(z.value().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lag_is_half_of_period_minus_one() {
|
fn lag_is_half_of_period_minus_one() {
|
||||||
assert_eq!(Zlema::new(3).unwrap().lag(), 1);
|
assert_eq!(Zlema::new(3).unwrap().lag(), 1);
|
||||||
|
|||||||
@@ -216,6 +216,34 @@ mod tests {
|
|||||||
assert!(matches!(err, Error::InvalidCandle { .. }));
|
assert!(matches!(err, Error::InvalidCandle { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102).
|
||||||
|
/// Every existing test routes through the validating `Candle::new`, so the
|
||||||
|
/// unchecked path is dead.
|
||||||
|
///
|
||||||
|
/// The first assertion shows that a valid set of fields round-trips
|
||||||
|
/// verbatim. The second feeds `high < low` (which `Candle::new` would
|
||||||
|
/// reject with `Error::InvalidCandle`) and asserts the unchecked
|
||||||
|
/// constructor still produces the struct as-is — documenting and
|
||||||
|
/// enforcing the API contract that the unchecked variant performs no
|
||||||
|
/// validation and is the caller's responsibility.
|
||||||
|
#[test]
|
||||||
|
fn candle_new_unchecked_preserves_fields_verbatim() {
|
||||||
|
let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
|
||||||
|
assert_eq!(c.open, 1.0);
|
||||||
|
assert_eq!(c.high, 2.0);
|
||||||
|
assert_eq!(c.low, 0.5);
|
||||||
|
assert_eq!(c.close, 1.5);
|
||||||
|
assert_eq!(c.volume, 100.0);
|
||||||
|
assert_eq!(c.timestamp, 42);
|
||||||
|
|
||||||
|
// Skip-validation contract: an OHLC combination that the checked
|
||||||
|
// constructor rejects (high < low) is still built without error.
|
||||||
|
assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
|
||||||
|
let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
|
||||||
|
assert_eq!(unchecked.high, 9.0);
|
||||||
|
assert_eq!(unchecked.low, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candle_typical_price() {
|
fn candle_typical_price() {
|
||||||
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
|
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
|
||||||
|
|||||||
@@ -275,6 +275,54 @@ mod tests {
|
|||||||
assert_eq!(c.update(1.0), Some(8.0));
|
assert_eq!(c.update(1.0), Some(8.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `Chain::first` / `Chain::second` borrow accessors and the
|
||||||
|
/// `Chain::warmup_period` + `Chain::name` Indicator-impl bodies.
|
||||||
|
///
|
||||||
|
/// Existing chain tests only invoked the Indicator surface (`update`,
|
||||||
|
/// `reset`, `is_ready`) on the wrapped `Chain`. The const borrow accessors
|
||||||
|
/// and the `warmup_period` / `name` impls were never traversed, so Codecov
|
||||||
|
/// flagged traits.rs lines 140-142, 145-147, 167-170, 176-178 as missed.
|
||||||
|
/// `chain.warmup_period()` also reaches `Doubler::warmup_period`
|
||||||
|
/// (228-230), and `chain.first().name()` reaches `Doubler::name`
|
||||||
|
/// (234-236) — both helper methods were uncovered for the same reason.
|
||||||
|
#[test]
|
||||||
|
fn chain_accessors_and_metadata() {
|
||||||
|
let chain = Chain::new(Doubler::default(), Doubler::default());
|
||||||
|
// Borrow accessors return the wrapped stages; query each via .name()
|
||||||
|
// so Doubler::name (lines 234-236) is also exercised.
|
||||||
|
assert_eq!(chain.first().name(), "Doubler");
|
||||||
|
assert_eq!(chain.second().name(), "Doubler");
|
||||||
|
// Doubler::warmup_period (lines 228-230) is 0; Chain::warmup_period
|
||||||
|
// sums the two, so the result must also be 0.
|
||||||
|
assert_eq!(chain.first().warmup_period(), 0);
|
||||||
|
assert_eq!(chain.second().warmup_period(), 0);
|
||||||
|
assert_eq!(chain.warmup_period(), 0);
|
||||||
|
// Chain::name returns the literal "Chain" (line 177).
|
||||||
|
assert_eq!(chain.name(), "Chain");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the full Indicator surface of the `Identity` test helper:
|
||||||
|
/// `reset` (198-200), `warmup_period` (201-203), `is_ready` (204-206),
|
||||||
|
/// and `name` (207-209). The only other test using `Identity`
|
||||||
|
/// (`batch_replays_update`) calls `batch`, which exercises `update`
|
||||||
|
/// alone, leaving the remaining four trait methods uncovered.
|
||||||
|
#[test]
|
||||||
|
fn identity_helper_full_indicator_surface() {
|
||||||
|
let mut id = Identity::default();
|
||||||
|
// warmup_period is the literal 0; name is the literal "Identity".
|
||||||
|
assert_eq!(id.warmup_period(), 0);
|
||||||
|
assert_eq!(id.name(), "Identity");
|
||||||
|
// is_ready exercises the `self.seen` return with seen=false first…
|
||||||
|
assert!(!id.is_ready());
|
||||||
|
// …then with seen=true after a single update.
|
||||||
|
let out = id.update(42.0);
|
||||||
|
assert_eq!(out, Some(42.0));
|
||||||
|
assert!(id.is_ready());
|
||||||
|
// reset() flips seen back to false; is_ready reflects it.
|
||||||
|
id.reset();
|
||||||
|
assert!(!id.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_parallel_runs_independent_instances() {
|
fn batch_parallel_runs_independent_instances() {
|
||||||
|
|||||||
@@ -325,16 +325,18 @@ impl TickAggregator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.reserve(gap_count as usize);
|
out.reserve(gap_count as usize);
|
||||||
|
// Bucket alignment guarantees start + (gap_count - 1) * step ≤
|
||||||
|
// next_bucket - step < i64::MAX, so iterating `gap_count` times
|
||||||
|
// with `saturating_add(step)` cannot reach i64::MAX inside the
|
||||||
|
// loop body. `prev.close` is finite (it came from a validated
|
||||||
|
// bar) and volume is exactly 0.0, so the OHLCV invariants hold
|
||||||
|
// by construction — skip re-validation via Candle::new_unchecked.
|
||||||
let mut t = start;
|
let mut t = start;
|
||||||
while t < next_bucket {
|
for _ in 0..gap_count {
|
||||||
// `prev.close` is finite (it came from a validated bar), so this
|
out.push(Candle::new_unchecked(
|
||||||
// flat candle always passes `Candle::new`'s checks.
|
|
||||||
out.push(Candle::new(
|
|
||||||
prev.close, prev.close, prev.close, prev.close, 0.0, t,
|
prev.close, prev.close, prev.close, prev.close, 0.0, t,
|
||||||
)?);
|
));
|
||||||
t = t.checked_add(step).ok_or_else(|| {
|
t = t.saturating_add(step);
|
||||||
Error::Malformed("timestamp overflow while gap-filling".to_string())
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -369,6 +371,30 @@ mod tests {
|
|||||||
assert!(Timeframe::new(-1).is_err());
|
assert!(Timeframe::new(-1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover the `Timeframe::millis`, `Timeframe::seconds`, and
|
||||||
|
/// `Timeframe::one_minute_ms` convenience constructors (lines 40-52).
|
||||||
|
/// All existing tests build Timeframes via `new` / `minutes` / `hours` /
|
||||||
|
/// `days`, never via the three thin convenience wrappers.
|
||||||
|
#[test]
|
||||||
|
fn timeframe_convenience_constructors() {
|
||||||
|
assert_eq!(Timeframe::millis(250).unwrap().bucket(), 250);
|
||||||
|
assert!(Timeframe::millis(0).is_err());
|
||||||
|
assert_eq!(Timeframe::seconds(30).unwrap().bucket(), 30);
|
||||||
|
assert!(Timeframe::seconds(-1).is_err());
|
||||||
|
// one_minute_ms is the infallible 60_000-ms shortcut.
|
||||||
|
assert_eq!(Timeframe::one_minute_ms().bucket(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover the `TickAggregator::timeframe` const accessor (lines 353-355).
|
||||||
|
/// Existing tests only inspect emitted candles, never query the
|
||||||
|
/// configured timeframe back out.
|
||||||
|
#[test]
|
||||||
|
fn aggregator_timeframe_getter() {
|
||||||
|
let tf = Timeframe::new(60).unwrap();
|
||||||
|
let agg = TickAggregator::new(tf);
|
||||||
|
assert_eq!(agg.timeframe().bucket(), 60);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn minute_hour_day_constructors_compute_seconds() {
|
fn minute_hour_day_constructors_compute_seconds() {
|
||||||
assert_eq!(Timeframe::minutes(1).unwrap().bucket(), 60);
|
assert_eq!(Timeframe::minutes(1).unwrap().bucket(), 60);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user