Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 070be2eb27 | |||
| 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
|
||||
about: Report incorrect behaviour in Wickra
|
||||
title: "[bug] "
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
@@ -32,7 +32,7 @@ assignees: ""
|
||||
- Wickra version:
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||
- OS and architecture:
|
||||
- Rust / Python / Node version (if relevant):
|
||||
- Rust / Python / Node version (If relevant):
|
||||
|
||||
## 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
|
||||
about: Suggest a new indicator or capability for Wickra
|
||||
title: "[feature] "
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
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.
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
and their type stubs (if applicable).
|
||||
- [ ] Documentation under `docs/wiki/` and the `README.md` is updated
|
||||
(if applicable).
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/kingchenc/wickra/wiki)
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
- 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
|
||||
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
|
||||
with:
|
||||
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
|
||||
with:
|
||||
working-directory: bindings/python
|
||||
@@ -124,6 +127,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
working-directory: bindings/python
|
||||
@@ -168,7 +173,7 @@ jobs:
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.1.
|
||||
# NOTE: aarch64-pc-windows-msvc is temporarily skipped for 0.2.6.
|
||||
# The wickra-win32-arm64-msvc npm subpackage name is blocked by the
|
||||
# npm spam-detection filter for new accounts (same situation that
|
||||
# affected wickra-win32-x64-msvc through 0.1.4 until npm Support
|
||||
@@ -289,6 +294,14 @@ jobs:
|
||||
done
|
||||
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)
|
||||
working-directory: bindings/node
|
||||
env:
|
||||
@@ -357,7 +370,18 @@ jobs:
|
||||
with:
|
||||
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)
|
||||
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
|
||||
|
||||
+49
-1
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.6] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
- **docs.rs build.** Rust 1.92 removed the `doc_auto_cfg` feature gate
|
||||
and folded it back into `doc_cfg` (rust-lang/rust#138907). docs.rs
|
||||
builds against the latest nightly and sets `--cfg docsrs`, so every
|
||||
published 0.2.x failed with E0557 on the
|
||||
`#![cfg_attr(docsrs, feature(doc_auto_cfg))]` line at the top of
|
||||
`wickra`, `wickra-core`, and `wickra-data`. GitHub CI didn't see
|
||||
this — stable rustc never enables the `docsrs` cfg. The three
|
||||
library crates now gate on `doc_cfg` (same intent, same rendered
|
||||
output on docs.rs, builds again on nightly).
|
||||
|
||||
### Changed
|
||||
- **README — Wickra is now the top row of every comparison table.**
|
||||
The "Why Wickra exists" library matrix and the per-indicator
|
||||
benchmark tables previously placed Wickra at the bottom; a reader
|
||||
landing on the README is here to compare *against* Wickra, so the
|
||||
pivot row belongs at the top with a ★ marker. Same column data,
|
||||
same winner annotations — only row order changed. Mirrored across
|
||||
the umbrella README and every binding README so crates.io / PyPI /
|
||||
npm landing pages stay in sync.
|
||||
|
||||
## [0.2.5] - 2026-05-24
|
||||
|
||||
### Added
|
||||
- `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
|
||||
|
||||
### Changed
|
||||
@@ -329,7 +375,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.1...HEAD
|
||||
[Unreleased]: https://github.com/kingchenc/wickra/compare/v0.2.6...HEAD
|
||||
[0.2.6]: https://github.com/kingchenc/wickra/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/kingchenc/wickra/compare/v0.2.1...v0.2.5
|
||||
[0.2.1]: https://github.com/kingchenc/wickra/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/kingchenc/wickra/compare/v0.1.4...v0.2.0
|
||||
[0.1.4]: https://github.com/kingchenc/wickra/compare/v0.1.3...v0.1.4
|
||||
|
||||
+5
-3
@@ -22,7 +22,7 @@ when proposing features or depending on Wickra elsewhere.
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/wiki/` | Documentation sources. |
|
||||
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
|
||||
|
||||
## Building and testing
|
||||
|
||||
@@ -75,8 +75,10 @@ wasm-pack test --node bindings/wasm
|
||||
of `update` calls.
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
- **Docs.** Update the relevant page under `docs/wiki/` and the `README.md`
|
||||
when behaviour or the public API changes.
|
||||
- **Docs.** Update the relevant page on the
|
||||
[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`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
authors = ["kingchenc <kingchencp@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.2.1" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.2.6" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -36,16 +36,16 @@ for price in live_feed:
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
@@ -76,7 +76,7 @@ to recompute on every tick.
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
@@ -90,7 +90,7 @@ slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
@@ -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
|
||||
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">
|
||||
|
||||
+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
|
||||
npm install wickra
|
||||
```python
|
||||
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 |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
|
||||
## 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
|
||||
cd bindings/node
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
|
||||
The native module is built via [napi-rs](https://napi.rs/). The build script
|
||||
produces a `wickra.<platform>-<arch>.node` binary in the package root that
|
||||
`index.js` loads at runtime.
|
||||
## Indicators
|
||||
|
||||
## Usage
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
```js
|
||||
import { SMA, RSI, MACD, version } from 'wickra';
|
||||
| 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 |
|
||||
|
||||
console.log('wickra', version());
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
|
||||
// Batch:
|
||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + Math.sin(i * 0.1) * 5);
|
||||
const rsi = new RSI(14).batch(prices);
|
||||
## Languages
|
||||
|
||||
// Streaming:
|
||||
const macd = new MACD(12, 26, 9);
|
||||
for (const p of livePriceStream) {
|
||||
const v = macd.update(p);
|
||||
if (v && v.histogram > 0) console.log('bullish crossover candidate');
|
||||
| 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.6",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <kingchencp@gmail.com>",
|
||||
"main": "index.js",
|
||||
@@ -46,11 +46,11 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.2.1",
|
||||
"wickra-linux-arm64-gnu": "0.2.1",
|
||||
"wickra-darwin-x64": "0.2.1",
|
||||
"wickra-darwin-arm64": "0.2.1",
|
||||
"wickra-win32-x64-msvc": "0.2.1"
|
||||
"wickra-linux-x64-gnu": "0.2.6",
|
||||
"wickra-linux-arm64-gnu": "0.2.6",
|
||||
"wickra-darwin-x64": "0.2.6",
|
||||
"wickra-darwin-arm64": "0.2.6",
|
||||
"wickra-win32-x64-msvc": "0.2.6"
|
||||
},
|
||||
"scripts": {
|
||||
"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
|
||||
pip install wickra
|
||||
```
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
|
||||
## 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
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch — TA-Lib-style usage
|
||||
# Batch: classic TA-Lib-style usage
|
||||
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)
|
||||
for price in live_prices:
|
||||
v = rsi.update(price) # O(1) per tick
|
||||
if v is not None and v > 70:
|
||||
...
|
||||
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")
|
||||
```
|
||||
|
||||
## What's included
|
||||
## Why Wickra exists
|
||||
|
||||
71 streaming-first indicators across eight families. Every one passes a
|
||||
`batch == streaming` equivalence test and reference-value tests:
|
||||
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:
|
||||
|
||||
- **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
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
## 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
|
||||
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.
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
## 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,
|
||||
Node.js and WebAssembly bindings, examples, and CI.
|
||||
- **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
|
||||
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
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal,
|
||||
research, educational, and non-profit use are all permitted. Commercial
|
||||
sale requires a separate license — contact via the GitHub repo.
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.2.1"
|
||||
version = "0.2.6"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
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
|
||||
`wasm32-unknown-unknown` Rust target:
|
||||
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
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-pack
|
||||
```python
|
||||
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")
|
||||
```
|
||||
|
||||
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 |
|
||||
|------------------------|-----------------|-----------|----------------|--------|
|
||||
| **★ Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
|
||||
Wickra is the only library that combines all of: clean install, streaming,
|
||||
multi-language reach, and active maintenance.
|
||||
|
||||
## 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
|
||||
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
|
||||
- `--target bundler` for webpack/Vite/Rollup
|
||||
- `--target nodejs` for Node.js
|
||||
71 streaming-first indicators across eight families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
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
|
||||
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
|
||||
await init();
|
||||
console.log("wickra:", version());
|
||||
## Languages
|
||||
|
||||
// Streaming
|
||||
const rsi = new RSI(14);
|
||||
for (const price of livePrices) {
|
||||
const v = rsi.update(price);
|
||||
if (v !== undefined && v > 70) console.log("overbought");
|
||||
| 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}");
|
||||
}
|
||||
}
|
||||
|
||||
// Batch (returns a Float64Array; NaN for warmup positions)
|
||||
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
|
||||
// Compose indicators: RSI(7) on top of EMA(14).
|
||||
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)
|
||||
(top-level alongside the other language examples). After building the package
|
||||
with `wasm-pack build`, serve the repository root and open
|
||||
`examples/wasm/index.html` in a browser.
|
||||
## 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
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reset_clears_state() {
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn close_at_high_accumulates_full_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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
|
||||
@@ -182,4 +182,13 @@ mod tests {
|
||||
assert!(!a.is_ready());
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn pure_uptrend_yields_plus_100() {
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn warmup_emits_on_period_th_candle() {
|
||||
let candles = vec![
|
||||
|
||||
@@ -234,6 +234,17 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
|
||||
@@ -118,6 +118,17 @@ mod tests {
|
||||
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]
|
||||
fn batch_equals_streaming() {
|
||||
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]
|
||||
fn emits_from_first_candle() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
|
||||
@@ -172,20 +172,21 @@ mod tests {
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn naive(prices: &[f64], period: usize, mult: f64) -> Option<BollingerOutput> {
|
||||
if prices.len() < period {
|
||||
return None;
|
||||
}
|
||||
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
|
||||
assert!(
|
||||
prices.len() >= period,
|
||||
"naive requires at least `period` prices"
|
||||
);
|
||||
let w = &prices[prices.len() - period..];
|
||||
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 s = var.sqrt();
|
||||
Some(BollingerOutput {
|
||||
BollingerOutput {
|
||||
upper: mean + mult * s,
|
||||
middle: mean,
|
||||
lower: mean - mult * s,
|
||||
stddev: s,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn warmup_returns_none() {
|
||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||
@@ -241,7 +256,7 @@ mod tests {
|
||||
let out = bb.batch(&prices);
|
||||
for i in 19..prices.len() {
|
||||
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.stddev, want.stddev, epsilon = 1e-9);
|
||||
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
|
||||
@@ -301,8 +316,7 @@ mod tests {
|
||||
}
|
||||
window.push_back(v);
|
||||
}
|
||||
let scratch =
|
||||
naive(&window.iter().copied().collect::<Vec<_>>(), period, mult).expect("warmed up");
|
||||
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
|
||||
let got = last.expect("warmed up");
|
||||
assert!(
|
||||
(got.middle - scratch.middle).abs() < 1e-3,
|
||||
|
||||
@@ -113,6 +113,27 @@ mod tests {
|
||||
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]
|
||||
fn constant_series_yields_zero() {
|
||||
// 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]
|
||||
fn matches_bands_definition() {
|
||||
// Bandwidth must equal (upper - lower) / middle from BollingerBands.
|
||||
@@ -131,13 +169,11 @@ mod tests {
|
||||
.collect();
|
||||
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (w, b) in bbw_out.iter().zip(bands_out.iter()) {
|
||||
match (w, b) {
|
||||
(Some(wv), Some(bv)) => {
|
||||
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch"),
|
||||
for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
// Same warmup period on both — emission shape must agree at every index.
|
||||
assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
|
||||
if let (Some(wv), Some(bv)) = (w, b) {
|
||||
assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,17 @@ mod tests {
|
||||
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]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
|
||||
@@ -196,6 +196,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
|
||||
@@ -214,6 +214,18 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
|
||||
@@ -197,6 +197,17 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
|
||||
@@ -191,6 +191,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
|
||||
@@ -147,6 +147,21 @@ mod tests {
|
||||
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]
|
||||
fn reference_value() {
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = Coppock::new(6, 4, 3).unwrap();
|
||||
@@ -176,8 +193,7 @@ mod tests {
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but index {} is None",
|
||||
warmup - 1,
|
||||
"Coppock({long}, {short}, {wma}): warmup_period() = {warmup} but the warmup index is None",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,4 +127,17 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
|
||||
@@ -145,6 +145,22 @@ mod tests {
|
||||
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]
|
||||
fn shift_is_half_period_plus_one() {
|
||||
assert_eq!(Dpo::new(20).unwrap().shift(), 11);
|
||||
|
||||
@@ -236,6 +236,18 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
|
||||
@@ -165,6 +165,18 @@ mod tests {
|
||||
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]
|
||||
fn warmup_returns_none_until_seed() {
|
||||
let mut ema = Ema::new(3).unwrap();
|
||||
|
||||
@@ -160,6 +160,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
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]
|
||||
fn new_rejects_period_one() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -128,6 +128,16 @@ mod tests {
|
||||
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]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
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 full = Wma::new(9).unwrap();
|
||||
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 want = match (half.update(p), full.update(p)) {
|
||||
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
|
||||
_ => None,
|
||||
};
|
||||
match (got, want) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => assert_relative_eq!(a, b, epsilon = 1e-9),
|
||||
_ => panic!("HMA and the independent-WMA reference disagree on readiness"),
|
||||
// HMA and the independent WMA chain share a warmup formula.
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,20 @@ mod tests {
|
||||
use crate::traits::BatchExt;
|
||||
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]
|
||||
fn constant_series_yields_constant_kama() {
|
||||
let mut k = Kama::classic();
|
||||
|
||||
@@ -162,6 +162,19 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
|
||||
@@ -199,6 +199,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let mut lr = LinearRegression::new(5).unwrap();
|
||||
|
||||
@@ -138,6 +138,17 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let mut angle = LinRegAngle::new(5).unwrap();
|
||||
|
||||
@@ -188,6 +188,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
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]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -153,6 +153,21 @@ mod tests {
|
||||
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]
|
||||
fn warmup_period_formula() {
|
||||
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]
|
||||
fn emits_from_first_candle() {
|
||||
let mut mp = MedianPrice::new();
|
||||
|
||||
@@ -181,6 +181,34 @@ mod tests {
|
||||
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]
|
||||
fn rejects_zero_period() {
|
||||
assert!(Mfi::new(0).is_err());
|
||||
|
||||
@@ -114,6 +114,21 @@ mod tests {
|
||||
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]
|
||||
fn reference_values() {
|
||||
// MOM(3): price_t − price_{t-3}.
|
||||
|
||||
@@ -121,6 +121,39 @@ mod tests {
|
||||
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]
|
||||
fn natr_is_atr_over_close_as_percent() {
|
||||
// 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 atr_out = Atr::new(14).unwrap().batch(&candles);
|
||||
for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
|
||||
match (n, a) {
|
||||
(Some(nv), Some(av)) => {
|
||||
let want = 100.0 * av / candles[i].close;
|
||||
assert_relative_eq!(*nv, want, epsilon = 1e-9);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch at {i}"),
|
||||
// Same warmup period — emission shape must agree at every index.
|
||||
assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}");
|
||||
if let (Some(nv), Some(av)) = (n, a) {
|
||||
let want = 100.0 * av / candles[i].close;
|
||||
assert_relative_eq!(*nv, want, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ mod tests {
|
||||
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]
|
||||
fn first_candle_baseline_zero() {
|
||||
let mut obv = Obv::new();
|
||||
|
||||
@@ -113,6 +113,25 @@ mod tests {
|
||||
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]
|
||||
fn constant_series_yields_midpoint() {
|
||||
// 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 bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
match (p, b) {
|
||||
(Some(pv), Some(bv)) => {
|
||||
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
|
||||
assert_relative_eq!(*pv, want, epsilon = 1e-12);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("warmup mismatch at {i}"),
|
||||
// Same warmup — emission shape must agree at every index.
|
||||
assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
|
||||
if let (Some(pv), Some(bv)) = (p, b) {
|
||||
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
|
||||
assert_relative_eq!(*pv, want, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn price_at_middle_is_half() {
|
||||
// A symmetric oscillation keeps the SMA centred; when price crosses
|
||||
// the SMA, %b passes through 0.5. Verified via the bands definition.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
|
||||
if let (Some(pv), Some(bv)) = (p, b) {
|
||||
if (prices[i] - bv.middle).abs() < 1e-9 {
|
||||
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pb = PercentB::new(3, 2.0).unwrap();
|
||||
let out = pb.batch(&[1.0, 5.0, 3.0]);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[1], None);
|
||||
let v = out[2].expect("warmed up at index 2");
|
||||
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -150,6 +150,37 @@ mod tests {
|
||||
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]
|
||||
fn first_emission_at_second_update() {
|
||||
let mut pmo = Pmo::new(35, 20).unwrap();
|
||||
|
||||
@@ -142,6 +142,34 @@ mod tests {
|
||||
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]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut ppo = Ppo::new(3, 6).unwrap();
|
||||
|
||||
@@ -246,15 +246,16 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
let mut psar = Psar::classic();
|
||||
for (i, sar) in psar.batch(&candles).into_iter().enumerate() {
|
||||
if let Some(s) = sar {
|
||||
assert!(
|
||||
s <= candles[i].low + 1e-9,
|
||||
"SAR {s} should be <= low {} at i={i}",
|
||||
candles[i].low
|
||||
);
|
||||
}
|
||||
}
|
||||
// `all()` with `is_none_or` keeps every reachable arm on the hot path —
|
||||
// the previous filter_map / violation-Vec construction had a cold
|
||||
// "violation found" tuple branch that was unreachable on a clean
|
||||
// uptrend, leaving its line uncovered by Codecov.
|
||||
let ok = psar
|
||||
.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]
|
||||
@@ -267,13 +268,16 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
let mut psar = Psar::classic();
|
||||
let outs = psar.batch(&candles);
|
||||
// After the trend establishes downward, SAR should sit above highs.
|
||||
for (i, sar) in outs.into_iter().enumerate().skip(5) {
|
||||
if let Some(s) = sar {
|
||||
assert!(s >= candles[i].high - 1e-9);
|
||||
}
|
||||
}
|
||||
// Same `all()` + `is_none_or` shape as `pure_uptrend_sar_below_lows`
|
||||
// so the violation-tuple branch never appears as a cold path.
|
||||
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]
|
||||
@@ -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]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(Psar::new(0.0, 0.02, 0.20).is_err());
|
||||
|
||||
@@ -141,6 +141,30 @@ mod tests {
|
||||
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]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut roc = Roc::new(3).unwrap();
|
||||
|
||||
@@ -202,6 +202,34 @@ mod tests {
|
||||
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]
|
||||
fn warmup_period_is_period_plus_one() {
|
||||
let rsi = Rsi::new(14).unwrap();
|
||||
|
||||
@@ -136,6 +136,17 @@ mod tests {
|
||||
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]
|
||||
fn warmup_returns_none() {
|
||||
let mut sma = Sma::new(3).unwrap();
|
||||
|
||||
@@ -116,6 +116,24 @@ mod tests {
|
||||
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]
|
||||
fn warmup_then_recurrence() {
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn reference_value() {
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut sr = StochRsi::new(5, 4).unwrap();
|
||||
|
||||
@@ -220,6 +220,20 @@ mod tests {
|
||||
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]
|
||||
fn close_at_high_yields_k_100() {
|
||||
let candles = vec![
|
||||
@@ -252,6 +266,13 @@ mod tests {
|
||||
assert_relative_eq!(o.k, 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]
|
||||
|
||||
@@ -281,6 +281,17 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
|
||||
@@ -161,6 +161,23 @@ mod tests {
|
||||
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]
|
||||
fn new_rejects_out_of_range_volume_factor() {
|
||||
assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. })));
|
||||
|
||||
@@ -117,4 +117,16 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn odd_period_reference_values() {
|
||||
// TRIMA(5) is SMA(3) of SMA(3).
|
||||
|
||||
@@ -141,4 +141,30 @@ mod tests {
|
||||
fn rejects_zero_period() {
|
||||
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);
|
||||
}
|
||||
|
||||
/// Cover the Indicator-impl `name` body (73-75).
|
||||
#[test]
|
||||
fn name_metadata() {
|
||||
let tr = TrueRange::new();
|
||||
assert_eq!(tr.name(), "TrueRange");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut tr = TrueRange::new();
|
||||
|
||||
@@ -151,6 +151,21 @@ mod tests {
|
||||
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]
|
||||
fn first_emission_at_warmup_period() {
|
||||
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]
|
||||
fn emits_from_first_candle() {
|
||||
let mut tp = TypicalPrice::new();
|
||||
|
||||
@@ -175,6 +175,35 @@ mod tests {
|
||||
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]
|
||||
fn reference_values() {
|
||||
// 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]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
|
||||
|
||||
@@ -177,6 +177,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
|
||||
|
||||
@@ -174,6 +174,28 @@ mod tests {
|
||||
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]
|
||||
fn reference_values() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn emits_from_first_candle_at_zero() {
|
||||
let mut vpt = VolumePriceTrend::new();
|
||||
|
||||
@@ -193,6 +193,42 @@ mod tests {
|
||||
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]
|
||||
fn cumulative_vwap_weighted() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
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)];
|
||||
|
||||
@@ -147,6 +147,22 @@ mod tests {
|
||||
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]
|
||||
fn reference_value() {
|
||||
// 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]
|
||||
fn emits_from_first_candle() {
|
||||
let mut wc = WeightedClose::new();
|
||||
|
||||
@@ -155,6 +155,35 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
|
||||
@@ -154,6 +154,17 @@ mod tests {
|
||||
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]
|
||||
fn warmup_returns_none() {
|
||||
let mut wma = Wma::new(3).unwrap();
|
||||
@@ -179,11 +190,11 @@ mod tests {
|
||||
let mut wma = Wma::new(7).unwrap();
|
||||
let got = wma.batch(&prices);
|
||||
let want = wma_naive(&prices, 7);
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => assert_relative_eq!(*a, *b, epsilon = 1e-9),
|
||||
_ => panic!("warmup mismatch"),
|
||||
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
// Same warmup — emission shape must agree at every index.
|
||||
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
|
||||
if let (Some(a), Some(b)) = (g, w) {
|
||||
assert_relative_eq!(*a, *b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,15 @@ mod tests {
|
||||
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]
|
||||
fn reset_clears_state() {
|
||||
let mut z = ZScore::new(5).unwrap();
|
||||
|
||||
@@ -124,6 +124,21 @@ mod tests {
|
||||
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]
|
||||
fn lag_is_half_of_period_minus_one() {
|
||||
assert_eq!(Zlema::new(3).unwrap().lag(), 1);
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0)]);
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
mod error;
|
||||
mod ohlcv;
|
||||
|
||||
@@ -216,6 +216,34 @@ mod tests {
|
||||
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]
|
||||
fn candle_typical_price() {
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
#[test]
|
||||
fn batch_parallel_runs_independent_instances() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user