Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70e9cbb397 | |||
| 37e5e19b57 | |||
| c189075491 | |||
| 74dd82a867 | |||
| b654db312e | |||
| 88f119109d | |||
| 2945b47e1a | |||
| c212f91256 | |||
| 6c2ddf319f | |||
| 9db1ff8023 | |||
| fb6eae7fe2 | |||
| 0edb9f4857 | |||
| ea684e0d48 | |||
| 62ab84c472 | |||
| 0f2ff9c3c7 | |||
| e85334a2e9 | |||
| 4e3c41ea80 | |||
| 55284a3042 | |||
| 9b8e1346ed | |||
| 05fcdd9a5e | |||
| 5aa0949bce | |||
| b971e671b4 | |||
| 7a18a26daf | |||
| 4f9ed34884 | |||
| 7e1e988596 | |||
| f10b8c2e2d | |||
| 880a0e7430 | |||
| 6287bd48c1 | |||
| 54194a4ff8 | |||
| 3ea0f12b7a | |||
| d9d3ad18aa | |||
| 7f1a6df202 | |||
| 1ea05fb2a1 | |||
| 24e723fa7d | |||
| a39adb9dae | |||
| 1cd5d1d8da | |||
| 466faddd87 | |||
| 178fbfd68e | |||
| e30b3c6b35 | |||
| 070be2eb27 | |||
| 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 | |||
| 5a573650d2 | |||
| 8aa74cb638 | |||
| aea17a87af | |||
| 23c649aca9 | |||
| 57d3b785c2 | |||
| d94dc43fb2 | |||
| ed57632576 | |||
| 64faa707a4 | |||
| 1011fa2bbd | |||
| 7a501a39cf | |||
| 4acb2c7d68 | |||
| 7ba4380693 | |||
| 862a130d8c | |||
| c009ecc7f9 | |||
| bf4faddf0f | |||
| 0112693ec3 | |||
| 4dd36953c3 | |||
| 9b34c84d86 | |||
| 08928c5b17 | |||
| 86b4f73ec7 | |||
| 246d982e93 | |||
| 3d2e58c531 | |||
| f966841ced | |||
| 99490f8604 | |||
| 4d8c0ce1a5 | |||
| d063555cd1 | |||
| df179fa579 | |||
| d52ddeaccb | |||
| 3f9429dd2c | |||
| 30444ce296 | |||
| 90d8a299c3 | |||
| 73f8da49bd | |||
| ae8fcd9051 | |||
| 39a252ea66 | |||
| 11c52b8e86 | |||
| c6d2030103 | |||
| a10bda188f | |||
| c4ffcc2138 | |||
| f3ad0940c3 | |||
| a32719d709 | |||
| 6fd110b4ce | |||
| 896b71fc62 | |||
| 62fcab1c86 | |||
| 6786917cee | |||
| 961fc1f3dc | |||
| 183ebec7ba | |||
| 510013fc5a | |||
| 2db546ac12 | |||
| b340ecd3d6 | |||
| 2aef8c8db5 | |||
| b003321562 | |||
| 0995f8d66a | |||
| a530f1b4cb | |||
| efcd6216c1 | |||
| 3a6b5ebae3 | |||
| c99cf54a1f | |||
| 2bc6cc5505 | |||
| 6e3190a44a | |||
| 04c3a83fdb | |||
| c87a16953b | |||
| 5ea36a6064 | |||
| 303ff0a163 | |||
| b948b0b9cf | |||
| a707eb5d62 | |||
| 8b4a847d24 | |||
| 43b0b26736 | |||
| 962ced0712 | |||
| 5a4cf66022 | |||
| d87005577e | |||
| 8b9e8e30b9 | |||
| 747d1a5b1b | |||
| a1c646ae7c | |||
| ba10898801 | |||
| 25454fa89a | |||
| 2eabda5fa3 | |||
| c6938e8473 | |||
| 91f24946a6 | |||
| d5ff0a9df6 | |||
| 2b3a1b7384 | |||
| d2f99efd78 | |||
| 6643f7a81d | |||
| e452d35a27 | |||
| 27f37f5347 | |||
| 2f3b5cc3be | |||
| 2d0ee926c5 | |||
| 21bbd521b3 | |||
| 0b11a523a0 | |||
| 81962485af | |||
| 99dd144576 | |||
| 6c58d3827c | |||
| 16c0639f0c | |||
| 54148cad5b | |||
| e24e7726ce | |||
| 7728151c87 | |||
| 780a176072 | |||
| ed7324115c | |||
| abd2d80f8d | |||
| 3e8c48eefc | |||
| a4d8c40dc2 | |||
| 78c31d1bed | |||
| 4b3227a15f | |||
| 5568596e18 | |||
| 5917d4928f | |||
| 9d822d26aa | |||
| 0d20973a4d | |||
| f628bd5fb7 | |||
| 87b3f383d6 | |||
| 71e46a1ea6 | |||
| 51f64b53c0 | |||
| b919c33dee | |||
| a79606b4ce | |||
| 9fd926ecd8 | |||
| b3ddbea584 | |||
| 278b6afaa4 | |||
| 94cab88278 | |||
| 53b8b6e282 | |||
| 9b11d73273 | |||
| 0d451ac584 | |||
| 8ccb885906 | |||
| ad17915e49 | |||
| 79d705a746 | |||
| a8fb0b8181 | |||
| f0471ba824 | |||
| 2cebb3cca1 | |||
| f33f59ad68 | |||
| 783e40069d | |||
| 6b468824ce | |||
| 81680bbb4b | |||
| 80295eec87 | |||
| 0910ee6d37 | |||
| 62d0fb623e | |||
| 23e5890265 | |||
| 90aa695a02 | |||
| 2fc7a90ec0 | |||
| 490ce28645 | |||
| 59c435fbd5 | |||
| 41d52ec5be | |||
| aeea6a89bb | |||
| 4a9d27fb52 | |||
| 8192e576cf | |||
| 4880fea075 | |||
| ad3b068d47 | |||
| b4613a74c8 | |||
| a0884fa010 | |||
| 4b6f25a32c | |||
| 014e9afa51 | |||
| 8aa480101e | |||
| 5627612d44 | |||
| f3dcee1cb5 | |||
| bdc4c744f2 | |||
| 528e5c9174 | |||
| 323e9ce153 | |||
| 529f302f73 | |||
| 5c40acd288 | |||
| de39415413 | |||
| 827cdc7a6b | |||
| ccf2b46482 |
@@ -0,0 +1,34 @@
|
||||
# EditorConfig: https://editorconfig.org
|
||||
# Keeps indentation and line-endings consistent across IDEs.
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
|
||||
# Rust + Python + most config files use 4-space indents.
|
||||
[*.{rs,py,toml}]
|
||||
indent_size = 4
|
||||
|
||||
# JS / TS / JSON / YAML / Markdown use 2-space indents per ecosystem conventions.
|
||||
[*.{js,ts,jsx,tsx,json,yml,yaml,md}]
|
||||
indent_size = 2
|
||||
|
||||
# Markdown allows trailing whitespace as a hard line break — keep it intact.
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Makefiles must use tabs.
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
# Generated files are not authored by humans; leave them alone.
|
||||
[bindings/node/index.{js,d.ts}]
|
||||
indent_style = unset
|
||||
indent_size = unset
|
||||
trim_trailing_whitespace = unset
|
||||
insert_final_newline = unset
|
||||
@@ -0,0 +1,6 @@
|
||||
# Code owners for Wickra.
|
||||
#
|
||||
# The owner listed here is requested for review automatically on every pull
|
||||
# request. See https://docs.github.com/articles/about-code-owners.
|
||||
|
||||
* @wickra-lib
|
||||
@@ -0,0 +1,6 @@
|
||||
# Funding sources surfaced on the repository "Sponsor" button.
|
||||
# Each platform's value is the username/handle on that platform.
|
||||
# Leave a key empty (e.g. patreon:) to skip a platform.
|
||||
|
||||
github: [kingchenc]
|
||||
custom: ["https://wickra.org/sponsor"]
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report incorrect behaviour in Wickra
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear description of what is wrong. -->
|
||||
|
||||
## Reproduction
|
||||
|
||||
<!-- Minimal code that reproduces the problem. -->
|
||||
|
||||
```rust
|
||||
// or python / javascript
|
||||
```
|
||||
|
||||
## Expected behaviour
|
||||
|
||||
<!-- What you expected to happen, ideally with a reference value
|
||||
(TA-Lib, pandas-ta, hand-computed). -->
|
||||
|
||||
## Actual behaviour
|
||||
|
||||
<!-- What happened instead. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- Wickra version:
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||
- OS and architecture:
|
||||
- Rust / Python / Node version (If relevant):
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Logs, screenshots, anything else. -->
|
||||
@@ -0,0 +1,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,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/wickra-lib/wickra/security/advisories/new
|
||||
about: Report security issues privately — do not open a public issue.
|
||||
- name: Question or discussion
|
||||
url: https://github.com/wickra-lib/wickra/discussions
|
||||
about: Ask usage questions and discuss ideas here.
|
||||
@@ -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. -->
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new indicator or capability for Wickra
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What are you trying to do that Wickra cannot do today? -->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- For a new indicator: its name, formula, and the standard parameters.
|
||||
Link a reference implementation (TA-Lib, pandas-ta) if one exists. -->
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!-- Other approaches and why they fall short. -->
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] Affects the Rust core
|
||||
- [ ] Should be exposed in the Python binding
|
||||
- [ ] Should be exposed in the Node binding
|
||||
- [ ] Should be exposed in the WASM binding
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Anything else that helps. -->
|
||||
@@ -0,0 +1,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 9950X, 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`
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- Thanks for contributing to Wickra. Please fill in the sections below. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change, and why? -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- e.g. Closes #123 -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Indicator addition / change
|
||||
- [ ] Documentation
|
||||
- [ ] CI / build / tooling
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `cargo fmt --all --check` is clean.
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean.
|
||||
- [ ] `cargo test --workspace` passes.
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [project Wiki](https://github.com/wickra-lib/wickra/wiki)
|
||||
and the `README.md` are updated (If applicable). Wiki edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- [ ] An entry was added under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything that needs extra attention, trade-offs, follow-ups. -->
|
||||
@@ -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. -->
|
||||
@@ -0,0 +1,38 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Rust workspace (root Cargo.toml + all member crates).
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(cargo)"
|
||||
|
||||
# Node binding npm dependencies.
|
||||
- package-ecosystem: npm
|
||||
directory: "/bindings/node"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(npm)"
|
||||
|
||||
# Python binding pip dependencies.
|
||||
- package-ecosystem: pip
|
||||
directory: "/bindings/python"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(pip)"
|
||||
|
||||
# GitHub Actions — keeps the SHA-pinned actions current (Dependabot reads
|
||||
# the version comment after each pinned SHA and bumps both together).
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Audit that no file in the repo contains the pre-migration org slug or
|
||||
maintainer email. Driven by `repo-metadata.toml` at the repo root.
|
||||
|
||||
This is the read-only side of the metadata pipeline. It does not patch any
|
||||
files — it just fails CI when drift sneaks in. Pair with a future
|
||||
`--write` mode (auto-fix + signed commit on main) once the migration has
|
||||
settled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
METADATA_PATH = REPO_ROOT / "repo-metadata.toml"
|
||||
|
||||
|
||||
def load_metadata() -> dict:
|
||||
with METADATA_PATH.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def is_allowlisted(rel_path: str, allowlist: list[str]) -> bool:
|
||||
norm = rel_path.replace(os.sep, "/")
|
||||
for entry in allowlist:
|
||||
entry_norm = entry.replace(os.sep, "/")
|
||||
if entry_norm.endswith("/"):
|
||||
if norm.startswith(entry_norm):
|
||||
return True
|
||||
else:
|
||||
if norm == entry_norm:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def tracked_files() -> list[str]:
|
||||
"""List git-tracked files relative to the repo root."""
|
||||
out = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return [line for line in out.stdout.splitlines() if line]
|
||||
|
||||
|
||||
def scan(forbidden: list[str], allowlist: list[str]) -> list[tuple[str, int, str, str]]:
|
||||
"""Return a list of (rel_path, line_no, needle, line_text) findings.
|
||||
|
||||
Only git-tracked files are scanned, so local-only ghost-ignored files
|
||||
(`.claude/`, drafts) never trigger false positives.
|
||||
"""
|
||||
findings: list[tuple[str, int, str, str]] = []
|
||||
for rel_path in tracked_files():
|
||||
if is_allowlisted(rel_path, allowlist):
|
||||
continue
|
||||
abs_path = REPO_ROOT / rel_path
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
try:
|
||||
lines = abs_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
for lineno, line in enumerate(lines, start=1):
|
||||
for needle in forbidden:
|
||||
if needle in line:
|
||||
findings.append((rel_path, lineno, needle, line.strip()))
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true", help="audit-only (default)")
|
||||
args = parser.parse_args()
|
||||
_ = args # currently only --check is supported
|
||||
|
||||
meta = load_metadata()
|
||||
audit = meta.get("audit", {})
|
||||
forbidden: list[str] = list(audit.get("forbidden", []))
|
||||
allowlist: list[str] = list(audit.get("allowlist", []))
|
||||
|
||||
if not forbidden:
|
||||
print("repo-metadata.toml [audit].forbidden is empty — nothing to scan.")
|
||||
return 0
|
||||
|
||||
findings = scan(forbidden, allowlist)
|
||||
if findings:
|
||||
print(f"sync-metadata: {len(findings)} forbidden-substring hits:", file=sys.stderr)
|
||||
for rel_path, lineno, needle, text in findings:
|
||||
print(f" {rel_path}:{lineno}: matched {needle!r}", file=sys.stderr)
|
||||
print(f" {text}", file=sys.stderr)
|
||||
print(
|
||||
"\nUpdate the offending lines to use the values from repo-metadata.toml,",
|
||||
"or add the path to [audit].allowlist if the reference is intentional",
|
||||
"(e.g. historical CHANGELOG entries).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
org = meta["repo"]["org"]
|
||||
email = meta["maintainer"]["email"]
|
||||
print(f"sync-metadata: clean. org={org!r} email={email!r}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Cross-library benchmark
|
||||
|
||||
# Audit finding R10: previously the cross-library benchmark ran on every push
|
||||
# and every pull-request to `main`, adding 5–10 minutes of build + bench time
|
||||
# per CI run with no consumer of the resulting artefact. The benchmark is now
|
||||
# scheduled (nightly at 03:00 UTC) and on-demand via `workflow_dispatch`. The
|
||||
# CI pipeline proper (.github/workflows/ci.yml) still verifies build / tests /
|
||||
# lints on every push and pull-request.
|
||||
on:
|
||||
schedule:
|
||||
# Nightly at 03:00 UTC. Pick a slot well away from common European /
|
||||
# American working-hours pushes to keep this off the critical path.
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
size:
|
||||
description: "Number of bars per indicator (default 20000)"
|
||||
required: false
|
||||
default: "20000"
|
||||
iterations:
|
||||
description: "Batch iterations per indicator (default 10)"
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries \
|
||||
--size ${{ github.event.inputs.size || '20000' }} \
|
||||
--iterations ${{ github.event.inputs.iterations || '10' }} \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
+259
-62
@@ -19,15 +19,15 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -48,9 +48,183 @@ jobs:
|
||||
run: cargo build -p wickra --benches --verbose
|
||||
|
||||
- name: Compile examples
|
||||
run: |
|
||||
cargo build -p wickra --example backtest
|
||||
cargo build -p wickra-data --example live_binance --features live-binance
|
||||
# All runnable examples now live in the dedicated wickra-examples crate
|
||||
# (examples/rust/src/bin/*.rs) which enables the live-binance feature
|
||||
# on its wickra-data dep, so a single --bins build covers backtest,
|
||||
# live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and
|
||||
# streaming.
|
||||
run: cargo build -p wickra-examples --bins
|
||||
|
||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||
# Ubuntu-only is sufficient: the lints are platform-independent.
|
||||
clippy-bindings:
|
||||
name: Clippy bindings
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
|
||||
# Verify the crates still build and test on their declared minimum supported
|
||||
# Rust version. The workspace pins rust-version = "1.86" — that floor is
|
||||
# set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
|
||||
# clap_lex 1.1.0 / edition2024 / Rust 1.85 floor and now needs 1.86; the
|
||||
# earlier rayon-core 1.13.0 (1.80) and clap_lex (1.85) requirements are
|
||||
# subsumed. bindings/node pins rust-version = "1.88" because napi-build
|
||||
# 2.3.2 requires it (and that subsumes the older 1.77 floor needed for
|
||||
# `cargo::` directives). Without this job an accidental use of a newer
|
||||
# API would only surface for downstream users.
|
||||
msrv:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: MSRV workspace (Rust 1.86)
|
||||
toolchain: "1.86"
|
||||
packages: "-p wickra-core -p wickra -p wickra-data"
|
||||
- name: MSRV node binding (Rust 1.88)
|
||||
toolchain: "1.88"
|
||||
packages: "-p wickra-node"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust ${{ matrix.toolchain }}
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
|
||||
- name: Test on MSRV
|
||||
run: cargo test ${{ matrix.packages }} --verbose
|
||||
|
||||
# Code coverage for the pure-Rust crates, uploaded to Codecov.
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
- name: Generate coverage (lcov)
|
||||
run: >
|
||||
cargo llvm-cov
|
||||
-p wickra-core -p wickra -p wickra-data
|
||||
--features wickra-data/live-binance
|
||||
--lcov --output-path lcov.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Supply-chain audit: security advisories, license policy, banned crates,
|
||||
# and source restrictions. Configured by deny.toml at the repo root.
|
||||
supply-chain:
|
||||
name: Supply-chain (cargo-deny)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
|
||||
with:
|
||||
command: check
|
||||
|
||||
# Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic
|
||||
# fails the job. The goal is to catch a regression in the harness (e.g. a
|
||||
# newly added indicator that panics on a particular input shape), not to
|
||||
# discover novel bugs — long fuzz campaigns should be run on dedicated
|
||||
# infrastructure with persistent corpora.
|
||||
fuzz-smoke:
|
||||
name: Fuzz (smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install nightly Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
toolchain: nightly
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: fuzz
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
# Use the prebuilt binary from taiki-e/install-action instead of
|
||||
# `cargo install cargo-fuzz --locked`. The latter resolves the
|
||||
# version graph from cargo-fuzz's own Cargo.lock, which pins to
|
||||
# rustix 0.36.5 — a version that still uses internal `rustc_*`
|
||||
# attributes the modern nightly compiler rejects, so the install
|
||||
# never gets off the ground. The prebuilt binary avoids the entire
|
||||
# transitive-dep compile.
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
|
||||
- name: Fuzz csv_reader (30 s)
|
||||
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu csv_reader -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz binance_envelope (30 s)
|
||||
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu binance_envelope -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz indicator_update (30 s)
|
||||
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz indicator_update_candle (30 s)
|
||||
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update_candle -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Fuzz tick_aggregator (30 s)
|
||||
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu tick_aggregator -- -max_total_time=30
|
||||
working-directory: fuzz
|
||||
|
||||
python:
|
||||
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
@@ -59,18 +233,38 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.9", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# setup-python downloads the interpreter from the Actions tool cache /
|
||||
# nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
|
||||
# Run it with continue-on-error, then retry once after a backoff so a
|
||||
# single CDN flake does not fail the whole job (see also: GitHub
|
||||
# Actions runner-images#7061).
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Wait before Python retry
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Python (retry)
|
||||
if: steps.setup_python.outcome == 'failure'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -86,7 +280,12 @@ jobs:
|
||||
- name: Install wheel
|
||||
shell: bash
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
# --no-index forces pip to ignore PyPI; --no-deps skips re-resolving
|
||||
# numpy (already installed in the previous step). Without --no-index
|
||||
# pip prefers the PyPI 0.2.x wheel over our freshly built one when
|
||||
# platform tags overlap (e.g. macOS arm64), so tests would run
|
||||
# against the released package and miss any new symbols the PR adds.
|
||||
run: python -m pip install --no-index --find-links dist --force-reinstall --no-deps wickra
|
||||
|
||||
- name: Run Python tests
|
||||
working-directory: bindings/python
|
||||
@@ -96,22 +295,34 @@ jobs:
|
||||
name: WASM build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain (with wasm target)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install wasm-pack
|
||||
uses: jetli/wasm-pack-action@v0.4.0
|
||||
# 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@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: wasm-pack
|
||||
|
||||
- name: Build WASM package
|
||||
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
|
||||
- name: Run WASM tests
|
||||
run: wasm-pack test --node bindings/wasm
|
||||
|
||||
- name: Verify generated artefacts
|
||||
run: |
|
||||
test -f bindings/wasm/pkg/wickra_wasm.js
|
||||
@@ -127,16 +338,35 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ["18", "20"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# setup-node downloads Node from nodejs.org and we've seen it fail on
|
||||
# Windows runners with "Attempting to download 18..." followed by a
|
||||
# silent hang or curl error. Retry once after a backoff so a single
|
||||
# CDN flake does not fail the whole job.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Node (retry)
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -146,50 +376,17 @@ jobs:
|
||||
|
||||
- name: Build native module
|
||||
working-directory: bindings/node
|
||||
run: npx napi build --release
|
||||
# --platform puts the target triple into the filename so the loader's
|
||||
# `wickra.<target>.node` lookup finds the freshly built binary instead
|
||||
# of falling back to the per-platform npm subpackage (which doesn't
|
||||
# exist yet for win32-x64-msvc).
|
||||
run: npx napi build --platform --release
|
||||
|
||||
- name: Run Node tests
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries --size 20000 --iterations 10 \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
# the every-push / every-PR critical path.
|
||||
|
||||
+329
-37
@@ -15,10 +15,17 @@ jobs:
|
||||
cargo-publish:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
# The publish jobs run with long-lived registry tokens. Binding them to a
|
||||
# protected GitHub environment lets the org require a reviewer to approve
|
||||
# each release and restrict which tags/branches may deploy, so the secrets
|
||||
# are not reachable from an arbitrary workflow run. The `release`
|
||||
# environment and its protection rules are configured under repo
|
||||
# Settings -> Environments.
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# Idempotent publishing: if the version is already on crates.io we
|
||||
# treat that as success so re-runs of the workflow don't fail.
|
||||
@@ -52,48 +59,106 @@ jobs:
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# Produce .crate files for the GitHub Release attachments. `cargo package`
|
||||
# writes them to target/package/<name>-<version>.crate.
|
||||
#
|
||||
# No --allow-dirty: `actions/checkout` gives a clean tree and nothing
|
||||
# above mutates it. No --no-verify: every crate was just published to
|
||||
# crates.io in the steps above, so the verification build resolves its
|
||||
# workspace dependencies from the registry and confirms each .crate
|
||||
# actually builds before it is attached to the release.
|
||||
- name: Build .crate files for release attachment
|
||||
run: |
|
||||
cargo package -p wickra-core
|
||||
cargo package -p wickra-data
|
||||
cargo package -p wickra
|
||||
|
||||
- name: Upload .crate files
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: crate-files
|
||||
path: target/package/*.crate
|
||||
|
||||
# CycloneDX SBOM per published crate. Attached to the GitHub Release
|
||||
# alongside the .crate / .whl / .tgz artefacts so downstream
|
||||
# consumers can audit the published dependency tree without
|
||||
# re-resolving Cargo.lock.
|
||||
- name: Install cargo-cyclonedx
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
|
||||
- name: Generate CycloneDX SBOMs
|
||||
run: |
|
||||
cargo cyclonedx --format json --top-level -p wickra-core
|
||||
cargo cyclonedx --format json --top-level -p wickra-data
|
||||
cargo cyclonedx --format json --top-level -p wickra
|
||||
mkdir -p sboms
|
||||
find . -name "*.cdx.json" -not -path "./target/*" -exec cp {} sboms/ \;
|
||||
ls -lh sboms/
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sboms
|
||||
path: sboms/*.cdx.json
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PyPI: cross-platform wheels + sdist
|
||||
# --------------------------------------------------------------------------
|
||||
python-wheels:
|
||||
name: Build wheels (${{ matrix.target }} on ${{ matrix.os }})
|
||||
name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# glibc Linux (manylinux)
|
||||
- { os: ubuntu-latest, target: x86_64, manylinux: auto }
|
||||
- { os: ubuntu-latest, target: aarch64, manylinux: auto }
|
||||
# musl Linux (Alpine and other musl distros)
|
||||
- { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
|
||||
- { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
|
||||
# macOS
|
||||
- { os: macos-latest, target: x86_64, manylinux: auto }
|
||||
- { os: macos-latest, target: aarch64, manylinux: auto }
|
||||
# Windows
|
||||
- { os: windows-latest, target: x64, manylinux: auto }
|
||||
- { os: windows-11-arm, target: aarch64, manylinux: auto }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: PyO3/maturin-action@v1
|
||||
- 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
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --strip --out dist
|
||||
manylinux: ${{ matrix.manylinux }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.target }}
|
||||
# Include manylinux in the name so the glibc and musl x86_64/aarch64
|
||||
# builds do not collide on the same artifact name.
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
|
||||
path: bindings/python/dist/*
|
||||
|
||||
python-sdist:
|
||||
name: Build Python sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: PyO3/maturin-action@v1
|
||||
- uses: actions/checkout@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
|
||||
command: sdist
|
||||
args: --out dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels-sdist
|
||||
path: bindings/python/dist/*
|
||||
@@ -102,15 +167,16 @@ jobs:
|
||||
name: Publish to PyPI
|
||||
needs: [python-wheels, python-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
pattern: wheels-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload to PyPI
|
||||
uses: PyO3/maturin-action@v1
|
||||
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
command: upload
|
||||
args: --skip-existing dist/*
|
||||
@@ -127,22 +193,24 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
@@ -153,7 +221,7 @@ jobs:
|
||||
run: npx napi build --platform --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bindings-${{ matrix.target }}
|
||||
path: bindings/node/wickra.*.node
|
||||
@@ -163,10 +231,19 @@ jobs:
|
||||
name: Publish to npm
|
||||
needs: node-build
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# `id-token: write` lets npm publish embed a Sigstore provenance
|
||||
# attestation generated from the GitHub Actions OIDC token. The npm
|
||||
# registry then shows a "Verified provenance" badge and lets
|
||||
# consumers verify the package was built from this exact workflow
|
||||
# run.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -176,7 +253,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: Download all platform binaries
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: bindings/node/artifacts
|
||||
pattern: bindings-*
|
||||
@@ -188,38 +265,155 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: npx napi artifacts --dir artifacts
|
||||
|
||||
- name: Prepublish platform packages to npm
|
||||
# Publish each platform package individually. Skip versions that are
|
||||
# already on npm. A first-attempt 403 from npm's spam filter is
|
||||
# tolerated for a single 30-second retry — that historically clears
|
||||
# rate-limit-driven false positives. Anything that still fails after
|
||||
# the retry is a *real* failure (the platform binary will be missing
|
||||
# from `optionalDependencies` and Windows-style installs will break,
|
||||
# exactly the regression that produced audit finding R20) — fail the
|
||||
# job loudly so the release does not silently land in a half-published
|
||||
# state. Previously this loop swallowed the second-attempt failure with
|
||||
# a `::warning::` and `return 0`; that mask is removed.
|
||||
- name: Publish platform packages (idempotent)
|
||||
working-directory: bindings/node
|
||||
run: npx napi prepublish -t npm --skip-gh-release
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
fail=0
|
||||
publish_dir() {
|
||||
local dir=$1
|
||||
local pkg=$(basename "$dir")
|
||||
local pkgname="wickra-$pkg"
|
||||
local existing
|
||||
existing=$(npm view "$pkgname@$version" version 2>/dev/null)
|
||||
if [ "$existing" = "$version" ]; then
|
||||
echo "::notice::skip $pkgname@$version (already on npm)"
|
||||
return 0
|
||||
fi
|
||||
echo "::group::publish $pkgname@$version"
|
||||
# --ignore-scripts: a per-platform package must never run lifecycle
|
||||
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
|
||||
# the package being published — a malicious or stray script would
|
||||
# execute with the npm token in the environment).
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
local rc=$?
|
||||
echo "::endgroup::"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
rc=$?
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
for dir in npm/*/; do
|
||||
publish_dir "$dir" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Publish main package to npm
|
||||
- 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
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
# GitHub Actions defaults to `bash -e`. `npm view` on a not-yet-
|
||||
# published package returns 1, which would kill the step before
|
||||
# we got to publish. Force a graceful empty result instead.
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
existing=$(npm view "wickra@$version" version 2>/dev/null)
|
||||
if [ "$existing" = "$version" ]; then
|
||||
echo "::notice::skip wickra@$version (already on npm)"
|
||||
exit 0
|
||||
fi
|
||||
# --ignore-scripts so any leftover prepublish hooks (which would
|
||||
# otherwise try to republish the already-published platform
|
||||
# subpackages) can't sabotage the main publish.
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
- name: Pack node tarballs for release attachment
|
||||
working-directory: bindings/node
|
||||
run: |
|
||||
# Main package
|
||||
npm pack --ignore-scripts
|
||||
# Each per-platform package (the binaries were already moved in by
|
||||
# napi artifacts). --ignore-scripts for the same reason as publish:
|
||||
# packing must not execute lifecycle scripts from the packed dir.
|
||||
for d in npm/*/; do
|
||||
(cd "$d" && npm pack --ignore-scripts)
|
||||
done
|
||||
|
||||
- name: Upload Node tarballs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: node-tarballs
|
||||
path: |
|
||||
bindings/node/*.tgz
|
||||
bindings/node/npm/*/*.tgz
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
|
||||
# --------------------------------------------------------------------------
|
||||
# Note: this job's npm publish call uses `--provenance` (see below),
|
||||
# which requires the `id-token: write` permission set at the job level.
|
||||
wasm-publish:
|
||||
name: Publish wickra-wasm to npm
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# `id-token: write` lets npm publish embed a Sigstore provenance
|
||||
# attestation generated from the GitHub Actions OIDC token (same
|
||||
# mechanism as the node-publish job above).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: jetli/wasm-pack-action@v0.4.0
|
||||
- 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@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
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
|
||||
@@ -230,19 +424,117 @@ jobs:
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
||||
pkg.author = 'kingchenc <kingchencp@gmail.com>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/kingchenc/wickra' };
|
||||
pkg.homepage = 'https://github.com/kingchenc/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/kingchenc/wickra/issues' };
|
||||
pkg.license = 'SEE LICENSE IN LICENSE';
|
||||
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||
pkg.license = 'PolyForm-Noncommercial-1.0.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
- name: Pack wickra-wasm for release attachment
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: npm pack
|
||||
|
||||
- name: Upload WASM tarball
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wasm-tarball
|
||||
path: bindings/wasm/pkg/wickra-wasm-*.tgz
|
||||
|
||||
- name: Publish wickra-wasm to npm (idempotent)
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: |
|
||||
out=$(npm publish --access public 2>&1) && echo "$out" \
|
||||
out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
|
||||
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|
||||
|| (echo "$out"; exit 1))
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GitHub Release: attach every built artefact to the tag's release page.
|
||||
# --------------------------------------------------------------------------
|
||||
github-release:
|
||||
name: Attach assets to the GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve target tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
else
|
||||
# workflow_dispatch / non-tag push: attach to the latest v* tag.
|
||||
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::no v* tag found to attach assets to"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::attaching assets to release $tag"
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Stage release assets
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p release-assets
|
||||
# Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
|
||||
find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
|
||||
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
|
||||
# Native Node binaries (one per platform).
|
||||
find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
|
||||
# Node npm-pack tarballs (main + per-platform).
|
||||
find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
|
||||
# Cargo .crate files (one per workspace member).
|
||||
find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
|
||||
# CycloneDX SBOMs (one per published crate).
|
||||
find artifacts -type f -name "*.cdx.json" -exec cp {} release-assets/ \;
|
||||
ls -lh release-assets/
|
||||
echo "asset-count=$(ls release-assets/ | wc -l)"
|
||||
|
||||
- name: Create / update GitHub Release with assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
name: Wickra ${{ steps.tag.outputs.tag }}
|
||||
files: release-assets/*
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
body: |
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cargo add wickra
|
||||
pip install wickra
|
||||
npm install wickra
|
||||
npm install wickra-wasm
|
||||
```
|
||||
|
||||
### Attached assets
|
||||
|
||||
Pre-built artefacts for every supported platform — the same files that
|
||||
were uploaded to crates.io, PyPI, and npm by this workflow run.
|
||||
|
||||
- `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
|
||||
- `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
|
||||
darwin-arm64, win32-x64-msvc)
|
||||
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
|
||||
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
See below; GitHub computes it from the commits since the previous tag.
|
||||
@@ -0,0 +1,185 @@
|
||||
name: Sync indicator count
|
||||
|
||||
# Indicator count appears in four places that must stay in sync with
|
||||
# the number of public indicator types exported from
|
||||
# crates/wickra-core/src/lib.rs (the `pub use indicators::{ ... }` block,
|
||||
# minus the `FAMILIES` constant and any `*Output` companion structs):
|
||||
#
|
||||
# 1. README.md prose — synced on PR branches (this workflow)
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Wiki: Home.md / FAQ.md / Streaming-vs-Batch.md
|
||||
# — synced on push to main / v* tag
|
||||
# 4. site/index.md (local-only marketing site, not synced from CI)
|
||||
#
|
||||
# We count public types (not `mod xxx;` lines) because some modules export
|
||||
# more than one indicator — e.g. `vwap.rs` exposes both `Vwap` and
|
||||
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
|
||||
# single source of truth for what the bindings reach.
|
||||
#
|
||||
# Design: keep README in sync *before* a PR is merged, by pushing a
|
||||
# fix-up commit to the PR head branch. After squash-merge into main
|
||||
# the bot commit is folded into the single signed merge commit, so
|
||||
# main's history never shows an unsigned "sync indicator count" entry.
|
||||
#
|
||||
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
|
||||
# explicitly do NOT trigger downstream workflows (anti-recursion
|
||||
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
|
||||
# — it does, however, re-trigger sync-about.yml on the next PR
|
||||
# `synchronize` event, which is what we want (a no-op if the counter
|
||||
# is now correct).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# `contents: write` is needed so the workflow can push the counter
|
||||
# fix-up commit to the PR head branch via the auto-provided
|
||||
# GITHUB_TOKEN. The wider About / Wiki writes still go through the
|
||||
# fine-grained PAT (ABOUT_SYNC_TOKEN) because they need
|
||||
# `Administration: write` (gh repo edit) which GITHUB_TOKEN lacks.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# On PRs from forks the head ref lives in another repo; pushing
|
||||
# back to it from this workflow is blocked by GitHub. We still
|
||||
# want the PR to surface the missing counter, so the check below
|
||||
# falls back to a hard failure when push isn't possible.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# On PRs we check out the *head* commit (not the merge ref) so
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
|
||||
# push; the About / Wiki steps re-authenticate with the PAT
|
||||
# below where needed.
|
||||
|
||||
- name: Count indicators
|
||||
id: count
|
||||
run: |
|
||||
# Parse the `pub use indicators::{ ... }` block from lib.rs, strip
|
||||
# the FAMILIES constant and any `*Output` companion structs, count
|
||||
# the remaining identifiers. Pure-shell so the workflow doesn't
|
||||
# require a python runtime.
|
||||
n=$(sed -n '/^pub use indicators::{/,/^};/p' crates/wickra-core/src/lib.rs \
|
||||
| tr ',{}' '\n' \
|
||||
| sed 's/[[:space:]]//g' \
|
||||
| grep -E '^[A-Z][A-Za-z0-9_]*$' \
|
||||
| grep -vE '^FAMILIES$|Output$' \
|
||||
| sort -u | wc -l)
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "Indicator count: $n"
|
||||
|
||||
# ----- PR flow ---------------------------------------------------
|
||||
|
||||
- name: Check README counter (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_check
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if grep -qE "^${n} streaming-first indicators" README.md; then
|
||||
echo "matches=true" >> "$GITHUB_OUTPUT"
|
||||
echo "README counter already at ${n}; nothing to do."
|
||||
else
|
||||
echo "matches=false" >> "$GITHUB_OUTPUT"
|
||||
echo "README counter does not match ${n}; will fix up."
|
||||
fi
|
||||
|
||||
- name: Fix counter on fork PR head (read-only, fail loud)
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
echo "::error::README.md says a different indicator count than mod.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please update README.md to '${n} streaming-first indicators' and push again."
|
||||
exit 1
|
||||
|
||||
- name: Patch README on PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
|
||||
id: pr_patch
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md
|
||||
if git diff --quiet; then
|
||||
echo "No README changes after sed (counter regex did not match anything); skipping push."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "chore: sync indicator count to ${{ steps.count.outputs.count }}"
|
||||
git push origin "HEAD:${{ steps.ctx.outputs.head_ref }}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
# After a PR squash-merges, this workflow runs again on the push
|
||||
# to main. README is already correct (it was fixed on the PR
|
||||
# branch before the merge); the only outward syncs left are the
|
||||
# GitHub About description (repo metadata, not a commit) and the
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
|
||||
- name: Update GitHub About description
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
current=$(gh repo view --json description -q .description)
|
||||
if [ "$current" = "$desc" ]; then
|
||||
echo "About unchanged."
|
||||
else
|
||||
gh repo edit --description "$desc"
|
||||
echo "About updated."
|
||||
fi
|
||||
|
||||
- name: Sync Wiki
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki
|
||||
cd wiki
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md FAQ.md Streaming-vs-Batch.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md FAQ.md Streaming-vs-Batch.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
git push
|
||||
@@ -0,0 +1,22 @@
|
||||
name: sync-metadata
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: metadata audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Audit repo-metadata.toml drift
|
||||
run: python .github/scripts/sync-metadata.py --check
|
||||
+3
-2
@@ -42,11 +42,12 @@ tarpaulin-report.html
|
||||
*.local.toml
|
||||
|
||||
# Node binding artifacts
|
||||
bindings/node/node_modules/
|
||||
**/node_modules/
|
||||
bindings/node/*.node
|
||||
bindings/node/index.d.ts
|
||||
bindings/node/npm-debug.log*
|
||||
package-lock.json
|
||||
# package-lock.json is committed (under bindings/node/) so contributors
|
||||
# get reproducible npm installs. Top-level lockfiles still aren't expected.
|
||||
|
||||
# WASM build output
|
||||
bindings/wasm/pkg/
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
# Architecture
|
||||
|
||||
A walkthrough of how Wickra is organised internally — written for new
|
||||
contributors who want to know **where the code lives, why it's split that
|
||||
way, and which invariants they must not break**. Pair it with [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
for the day-to-day workflow.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
Wickra is a Cargo workspace of three Rust crates plus three binding crates.
|
||||
The split is deliberate: every concern that one user might want to disable
|
||||
or replace lives behind a separate crate boundary.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ wickra (facade) │
|
||||
│ re-exports wickra-core::* + wickra-data::* │
|
||||
└──────────────┬──────────────────────────────────┬──────────────────┘
|
||||
│ │
|
||||
┌───────────▼──────────┐ ┌──────────▼─────────┐
|
||||
│ wickra-core │ │ wickra-data │
|
||||
│ indicator engine │ │ i/o + aggregation │
|
||||
│ • 214 indicators │ │ • CSV reader │
|
||||
│ • Indicator trait │ │ • Tick aggregator │
|
||||
│ • BatchExt impl │ │ • Resampler │
|
||||
│ • OHLCV / Candle │ │ • Live feeds │
|
||||
│ no I/O, no deps │ │ optional features │
|
||||
└──────────────────────┘ └────────────────────┘
|
||||
▲
|
||||
│ (every binding wraps the same core)
|
||||
│
|
||||
┌────────────┴───────────┬─────────────────────┐
|
||||
│ │ │
|
||||
┌──▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
|
||||
│ Python │ │ Node │ │ WASM │
|
||||
│ (PyO3) │ │ (napi-rs) │ │ (wasm-bindgen) │
|
||||
└─────────┘ └──────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
| Crate | Path | What it owns | Public deps |
|
||||
|---|---|---|---|
|
||||
| `wickra-core` | `crates/wickra-core` | every indicator, the `Indicator` trait, `BatchExt`, `Candle`/`Tick` types, `Error` | `thiserror`, `rayon` (parallel batch) |
|
||||
| `wickra` | `crates/wickra` | thin facade — re-exports everything user-facing from `wickra-core` and `wickra-data` | both internal crates |
|
||||
| `wickra-data` | `crates/wickra-data` | CSV reader, tick aggregator, resampler, live exchange feeds (feature-gated) | `tokio`, `tokio-tungstenite` (live), `serde_json` |
|
||||
| `wickra-python` | `bindings/python` | `_wickra` PyO3 module + Python package | `pyo3`, `numpy`, depends on `wickra-core` |
|
||||
| `wickra-node` | `bindings/node` | NAPI-RS native binding | `napi`, depends on `wickra-core` |
|
||||
| `wickra-wasm` | `bindings/wasm` | WebAssembly binding | `wasm-bindgen`, depends on `wickra-core` |
|
||||
| `wickra-examples` | `examples/rust` | runnable binary examples | depends on `wickra`, `wickra-data` |
|
||||
|
||||
The `fuzz/` directory is **excluded** from the workspace (it has its own
|
||||
`Cargo.toml`) because the libfuzzer-sys harness requires a nightly
|
||||
toolchain, which would otherwise infect the stable workspace lints.
|
||||
|
||||
## The `Indicator` trait
|
||||
|
||||
Every indicator in Wickra implements one trait, defined in
|
||||
`crates/wickra-core/src/traits.rs`:
|
||||
|
||||
```rust
|
||||
pub trait Indicator {
|
||||
type Input;
|
||||
type Output;
|
||||
|
||||
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
|
||||
fn reset(&mut self);
|
||||
fn warmup_period(&self) -> usize;
|
||||
fn is_ready(&self) -> bool;
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
```
|
||||
|
||||
Four design choices that are non-negotiable:
|
||||
|
||||
1. **Streaming-first.** `update` is the only computation entry point. Each
|
||||
call must be O(1) amortised — no replays over history, no `clone`s of
|
||||
the input window unless absolutely necessary.
|
||||
2. **`Option<Output>` warmup.** A new indicator returns `None` until it has
|
||||
ingested `warmup_period()` inputs. After that it returns `Some(value)`
|
||||
on every call. The `None` → `Some` transition happens exactly once per
|
||||
`reset()`.
|
||||
3. **Reset is mandatory.** Calling `reset()` returns the indicator to the
|
||||
state of a newly constructed one. Tests verify this for every indicator.
|
||||
4. **No interior mutability across `update` calls.** Indicators may hold
|
||||
`VecDeque` / array state, but no `Cell`/`RefCell`/`Mutex` should be
|
||||
needed — `&mut self` is the only mutation channel.
|
||||
|
||||
### Batch is free
|
||||
|
||||
`BatchExt` is a blanket impl over `Indicator`:
|
||||
|
||||
```rust
|
||||
impl<I: Indicator> BatchExt for I {
|
||||
fn batch<'a>(&mut self, input: &'a [I::Input]) -> Vec<Option<I::Output>>
|
||||
where I::Input: Copy
|
||||
{
|
||||
input.iter().map(|x| self.update(*x)).collect()
|
||||
}
|
||||
fn batch_parallel(...) // rayon-based for multi-asset processing
|
||||
}
|
||||
```
|
||||
|
||||
Consequence: **every indicator gets batch and parallel-batch for free** as
|
||||
soon as `Indicator` is implemented. Tests verify `batch == streaming`
|
||||
equivalence on every indicator — this is the `batch_equals_streaming` test
|
||||
that appears in every indicator module.
|
||||
|
||||
## Indicator-module convention
|
||||
|
||||
Each indicator lives in its own file under
|
||||
`crates/wickra-core/src/indicators/`. Naming: snake-case of the struct,
|
||||
e.g. `Sma` → `sma.rs`, `MacdIndicator` → `macd.rs`.
|
||||
|
||||
Layout inside an indicator file is uniform:
|
||||
|
||||
```rust
|
||||
//! Doc-comment with the formula and one-line summary.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Public struct + rustdoc with mathematical definition + a runnable example.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Foo { /* state fields */ }
|
||||
|
||||
impl Foo {
|
||||
/// Constructor with parameter validation.
|
||||
pub fn new(period: usize, ...) -> Result<Self> { ... }
|
||||
/// Const accessors for configured params.
|
||||
pub const fn period(&self) -> usize { ... }
|
||||
}
|
||||
|
||||
impl Indicator for Foo {
|
||||
type Input = f64; // or (f64, f64), or Candle
|
||||
type Output = f64; // or FooOutput { ... }
|
||||
fn update(...) -> ... { ... }
|
||||
fn reset(...) { ... }
|
||||
fn warmup_period(...) -> usize { ... }
|
||||
fn is_ready(...) -> bool { ... }
|
||||
fn name(...) -> &'static str { "Foo" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// mandatory tests (every indicator):
|
||||
// - rejects_invalid_params
|
||||
// - accessors_and_metadata
|
||||
// - reference_value (vs TA-Lib / pandas-ta / hand-calculated)
|
||||
// - ignores_non_finite_input
|
||||
// - reset_clears_state
|
||||
// - batch_equals_streaming
|
||||
// plus indicator-specific edge cases
|
||||
}
|
||||
```
|
||||
|
||||
The `FAMILIES` constant in `mod.rs` (introduced in PR #60) is the
|
||||
machine-readable index of which family every indicator belongs to. It is
|
||||
the canonical taxonomy; README and Wiki tables should be derived from it.
|
||||
|
||||
## Input types
|
||||
|
||||
| Input | Used for | Examples |
|
||||
|---|---|---|
|
||||
| `f64` | Scalar inputs — usually a price or a return | SMA, EMA, RSI, ROC |
|
||||
| `Candle` | OHLCV bar — `{open, high, low, close, volume, timestamp}` | ATR, Bollinger, Ichimoku, all candlestick patterns |
|
||||
| `(f64, f64)` | Two-series indicators — `(asset, benchmark)` or `(x, y)` | PearsonCorrelation, Beta, Alpha, TreynorRatio |
|
||||
|
||||
The `Candle` type lives in `wickra-core::ohlcv` and is the binding
|
||||
contract across bindings — Python's `Candle` namedtuple, Node's
|
||||
`Candle` object, and WASM's `Candle` JS class all map 1:1.
|
||||
|
||||
## Output types
|
||||
|
||||
Most indicators emit `f64`. Multi-output indicators emit a dedicated
|
||||
struct in the same module, named `FooOutput`:
|
||||
|
||||
```rust
|
||||
pub struct BollingerOutput {
|
||||
pub upper: f64,
|
||||
pub middle: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
```
|
||||
|
||||
Bindings flatten these into matrix outputs (NumPy 2-D array for Python,
|
||||
typed object arrays for Node/WASM).
|
||||
|
||||
## Numerical-stability notes
|
||||
|
||||
A handful of indicators need care beyond naive accumulation:
|
||||
|
||||
- **Welford's online variance** is used in `StdDev`, `Variance`, `ZScore`,
|
||||
`BollingerBands`, and several others. Standard sum-of-squares is
|
||||
catastrophically lossy for low-variance inputs; Welford's recurrence
|
||||
keeps O(eps) error.
|
||||
- **Kahan summation** is used wherever rolling sums could span > 1e6
|
||||
elements without resetting — currently only Hurst-exponent's R/S
|
||||
chunks. Most rolling sums are bounded by the window size and don't need
|
||||
it.
|
||||
- **Logarithm bases** matter for some indicators (Hurst, MFI). Wickra
|
||||
uses natural log everywhere unless the reference math explicitly
|
||||
requires `log10` or `log2` — and then it documents the choice in the
|
||||
rustdoc.
|
||||
- **NaN / infinity guards.** Every indicator's `update` rejects
|
||||
non-finite input early (returns `None` without state mutation). Tests
|
||||
cover this with `ignores_non_finite_input`.
|
||||
|
||||
## Cross-crate flow
|
||||
|
||||
A typical full-stack call sequence for a Python live-trading example:
|
||||
|
||||
```
|
||||
[ Python: live_trading.py ]
|
||||
│
|
||||
▼
|
||||
[ binance.AsyncClient WebSocket ] ──── wickra_data live feed ───┐
|
||||
│
|
||||
┌──────────────────┘
|
||||
▼
|
||||
[ Candle struct conversion ]
|
||||
│
|
||||
▼
|
||||
[ PyRsi.update(close) ]
|
||||
│
|
||||
wraps │
|
||||
▼
|
||||
[ wickra_core::Rsi::update(f64) ] <-- the only place math runs
|
||||
│
|
||||
▼
|
||||
[ Option<f64> -> Py<PyFloat> ]
|
||||
│
|
||||
▼
|
||||
[ Python user code ]
|
||||
```
|
||||
|
||||
The same call sequence happens identically for Node (via NAPI),
|
||||
WASM (via wasm-bindgen → JS), and Rust (no FFI overhead, just direct
|
||||
calls).
|
||||
|
||||
## What lives where — the navigation cheat sheet
|
||||
|
||||
| You want to … | Look in |
|
||||
|---|---|
|
||||
| add a new indicator | `crates/wickra-core/src/indicators/<name>.rs` + add to `mod.rs` + add to `FAMILIES` + re-export in `lib.rs` |
|
||||
| change the `Indicator` trait surface | `crates/wickra-core/src/traits.rs` — this affects every indicator, treat as breaking |
|
||||
| add a new Candle field | `crates/wickra-core/src/ohlcv.rs` — also propagates to every binding's `Candle` mapping |
|
||||
| add a new exchange / data source | `crates/wickra-data/src/live/<exchange>.rs`, feature-gated under `live-<exchange>` |
|
||||
| expose a new binding | new crate under `bindings/` + macro-driven boilerplate in `bindings/<lang>/src/lib.rs` |
|
||||
| change benchmark coverage | `crates/wickra/benches/indicators.rs` |
|
||||
| add a new fuzz target | `fuzz/fuzz_targets/<name>.rs` + register in `fuzz/Cargo.toml` |
|
||||
| change CI matrix | `.github/workflows/ci.yml` |
|
||||
| change release pipeline | `.github/workflows/release.yml` (irreversible on `v*` tag — test on a throwaway tag first) |
|
||||
|
||||
## What is **deliberately** not in this repo
|
||||
|
||||
- **Backtest framework.** Wickra is an indicator library, not a backtester.
|
||||
Strategy + PnL + fills logic is for the user (see `examples/` for
|
||||
illustrative scripts).
|
||||
- **Multi-exchange aggregation.** Binance is the demo feed; full
|
||||
exchange-agnostic aggregation is `ccxt`'s job. Wickra's
|
||||
`wickra-data::live` is intentionally minimal.
|
||||
- **Order-book / L2 data.** Wickra works on OHLCV bars and ticks, not
|
||||
full depth. Tick-data variants (cumulative delta, single print) are on
|
||||
the roadmap but require new input types.
|
||||
- **Charting / visualization.** Out of scope for the Rust core. The
|
||||
WASM examples include a `lightweight-charts` integration as a
|
||||
starting point, but no charting code lives in the published packages.
|
||||
- **GPU / SIMD optimisation.** Indicators are O(1) per update — the
|
||||
bottleneck is not vector throughput. SIMD would only help large-batch
|
||||
workloads, which already saturate memory bandwidth via the cache-
|
||||
friendly `VecDeque` window.
|
||||
|
||||
## Performance characteristics
|
||||
|
||||
Every indicator is amortised O(1) per `update`. The constant factor
|
||||
varies:
|
||||
|
||||
| Class | Indicators | Per-`update` cost (approx) |
|
||||
|---|---|---|
|
||||
| Simple rolling | SMA, EMA, WMA, Mom | 1-2 floating-point ops |
|
||||
| Recursive smoothers | KAMA, FRAMA, VIDYA, JMA | 5-15 ops |
|
||||
| Window-sort | OmegaRatio, percentile-based VaR | O(period · log period) per update |
|
||||
| Multi-buffer DSP | MAMA, HilbertDominantCycle, EmpiricalModeDecomposition | 30-80 ops |
|
||||
| Multi-component | MacdIndicator, TtmSqueeze, Alligator | sum of components |
|
||||
|
||||
Benchmarks against real BTCUSDT 1-minute data live in
|
||||
`crates/wickra/benches/indicators.rs`. Cross-library comparison vs
|
||||
TA-Lib / pandas-ta / talipp / finta lives in
|
||||
`bindings/python/benchmarks/compare_libraries.py`.
|
||||
|
||||
## Stability commitments
|
||||
|
||||
- **MSRV.** Workspace: Rust 1.86. Node binding: 1.88 (NAPI-RS pins it).
|
||||
- **`Indicator` trait surface.** Breaking changes here are major-version
|
||||
events. Adding a new method with a default impl is minor.
|
||||
- **Indicator removal.** Once an indicator ships in a release, it stays
|
||||
callable. Renames go through a deprecation period of at least one
|
||||
minor version.
|
||||
- **Output structs.** Adding a field to a `FooOutput` is non-breaking
|
||||
because the binding contracts go through serde and accept extra keys.
|
||||
|
||||
## Open questions / known sharp edges
|
||||
|
||||
These are documented for contributors so you don't waste time
|
||||
re-discovering them.
|
||||
|
||||
- **`Rvi`** (Relative Vigor Index) and `RviVolatility` (Relative
|
||||
Volatility Index) are different indicators with the same short
|
||||
acronym — make sure you import the right one.
|
||||
- **Fuzz coverage of pair indicators** uses `indicator_update_pair.rs`,
|
||||
which is small because pair indicators are simpler — but coverage
|
||||
should grow as more pair indicators land.
|
||||
- **`FAMILIES` (from PR #60) is hand-maintained.** Adding a new
|
||||
indicator requires a separate entry in `FAMILIES`. The
|
||||
`total_count_matches_expected` test will fail if you forget.
|
||||
- **WASM does not have automated tests yet.** Smoke-validated only
|
||||
through the manual examples. Adding `wasm-bindgen-test` coverage is
|
||||
on the roadmap.
|
||||
|
||||
For the high-level project goals see [`ROADMAP.md`](ROADMAP.md); for
|
||||
day-to-day contribution mechanics see [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Wickra are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Family 15 — Risk / Performance metrics (17 new indicators).** Implemented
|
||||
pragmatically as standard `Indicator`s rather than a separate
|
||||
`wickra-metrics` crate; the input is a scalar `f64` per bar (period return,
|
||||
equity sample, or trade P&L depending on the metric).
|
||||
- **Scalar `Indicator<f64>` — 14 metrics:** Sharpe Ratio, Sortino Ratio,
|
||||
Calmar Ratio, Omega Ratio, Max Drawdown (rolling), Average Drawdown,
|
||||
Drawdown Duration (time-under-water), Pain Index, Value at Risk
|
||||
(historical, linear-interpolated percentile), Conditional Value at Risk
|
||||
(Expected Shortfall), Profit Factor, Gain/Loss Ratio, Recovery Factor,
|
||||
Kelly Criterion.
|
||||
- **Two-series `Indicator<(f64, f64)>` — 3 metrics on `(asset_return,
|
||||
benchmark_return)` pairs:** Treynor Ratio, Information Ratio,
|
||||
Jensen's Alpha (CAPM).
|
||||
- **Candlestick patterns family (15 indicators).** A new "Candlestick
|
||||
Patterns" family covers the standard 1- to 3-bar reversal and
|
||||
continuation shapes: `Doji`, `Hammer`, `InvertedHammer`, `HangingMan`,
|
||||
`ShootingStar`, `Engulfing`, `Harami`, `MorningEveningStar`,
|
||||
`ThreeSoldiersOrCrows`, `PiercingDarkCloud`, `Marubozu`, `Tweezer`,
|
||||
`SpinningTop`, `ThreeInside` and `ThreeOutside`. Every detector takes a
|
||||
`Candle` and emits a signed `f64` (`+1.0` bullish, `-1.0` bearish, `0.0`
|
||||
no pattern; `Doji` is direction-less and emits `+1.0`/`0.0`). The MVP is
|
||||
a pattern-shape check only — no trend filter is applied. Available
|
||||
across Rust, Python, Node and WASM bindings. Harmonic and chart
|
||||
patterns remain out of scope and will follow once the pattern-detection
|
||||
framework (pivot detector + multi-bar state machines) lands.
|
||||
- **Market Profile family** (3 new indicators, opens family #9 across the
|
||||
catalogue):
|
||||
- `ValueArea(period, bin_count, value_area_pct)` — rolling
|
||||
bin-approximation volume profile over the last `period` candles.
|
||||
Outputs `{poc, vah, val}`: Point of Control is the bin with the highest
|
||||
cumulative volume; the Value Area expands symmetrically from POC and
|
||||
always absorbs the higher-volume neighbour next, until the configured
|
||||
percentage of total volume (default 70%) is enclosed. Each candle's
|
||||
volume is spread uniformly across its `[low, high]` range; single-print
|
||||
bars (`low == high`) drop their entire volume into one bin.
|
||||
- `InitialBalance(period)` — first-N-bar session high / low, frozen
|
||||
once `period` bars have been ingested. Outputs `{high, low}`. Default
|
||||
`period = 12` (one-hour IB on 5-minute bars for US equities). Callers
|
||||
MUST invoke `reset()` at every session boundary, otherwise the IB
|
||||
locks and stays fixed for the lifetime of the instance.
|
||||
- `OpeningRange(period)` — same lock-after-N-bars semantics as IB but
|
||||
with a smaller default window (`period = 6`, 30 min on 5-minute
|
||||
bars) and a third output `breakout_distance` = `close - or_mid`,
|
||||
signed (positive above the range, negative below).
|
||||
- Histogram-output Market Profile variants (Volume Profile / VPVR /
|
||||
Composite Profile) and tick-data-only variants (TPO / Single Print /
|
||||
Cumulative Delta / Order Flow Delta / Volume-Weighted Open) are
|
||||
deliberately out of scope of this PR: the former need a new
|
||||
histogram-output API layer, the latter need tick / L2 data which
|
||||
`wickra-data` does not yet expose.
|
||||
- **Family 12 — Statistik / Regression (13 indicators).** A complete
|
||||
statistical toolkit for analysing rolling price distributions and
|
||||
cross-series relationships. Every indicator ships in the Rust core
|
||||
plus all three bindings (Python, Node, WASM), with full streaming +
|
||||
batch parity, fuzz coverage, and benches against the BTCUSDT
|
||||
dataset:
|
||||
- **Variance** — rolling population variance (`StdDev` squared).
|
||||
- **CoefficientOfVariation** — `StdDev / Mean`, dimensionless dispersion.
|
||||
- **Skewness** — rolling third standardised moment (Pearson skewness).
|
||||
- **Kurtosis** — rolling excess kurtosis (fourth moment minus `3`).
|
||||
- **StandardError** — standard error of estimate for the rolling OLS
|
||||
fit, with `n − 2` residual degrees of freedom.
|
||||
- **DetrendedStdDev** — population standard deviation of OLS
|
||||
residuals (the StdDev that remains after subtracting the linear
|
||||
trend).
|
||||
- **RSquared** — coefficient of determination of the rolling OLS
|
||||
fit; the trend-quality filter.
|
||||
- **MedianAbsoluteDeviation** — robust dispersion measure that
|
||||
survives outliers (median of absolute deviations from the median).
|
||||
- **Autocorrelation** — rolling lag-`k` Pearson autocorrelation;
|
||||
detects periodicity and tests for white-noise behaviour.
|
||||
- **HurstExponent** — R/S-analysis estimator of trend-persistence
|
||||
vs. mean-reversion regime (`0.5` is random walk).
|
||||
- **PearsonCorrelation** — rolling correlation between two
|
||||
synchronised series; takes `(x, y)` pairs.
|
||||
- **Beta** — rolling OLS slope of an asset on a benchmark; the CAPM
|
||||
sensitivity coefficient.
|
||||
- **SpearmanCorrelation** — rolling rank correlation (monotone,
|
||||
outlier-robust analogue of Pearson).
|
||||
|
||||
Indicator count: 71 → 84.
|
||||
- **Family 13 — Ichimoku & alternative charts.** Two new indicators:
|
||||
- `Ichimoku` (Ichimoku Kinko Hyo) — the full five-line cloud system
|
||||
(Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span) with the
|
||||
classic `(9, 26, 52, 26)` defaults and configurable periods. Forward
|
||||
displacement is handled in a streaming ring buffer so the
|
||||
currently-visible Senkou A/B at bar *n* are the values computed
|
||||
from bar *n − displacement*.
|
||||
- `HeikinAshi` — the candle smoothing transform that recursively
|
||||
averages OHLC into a four-component output (`ha_open`, `ha_high`,
|
||||
`ha_low`, `ha_close`). Seeds `ha_open` from the first bar's
|
||||
`(open + close) / 2`.
|
||||
|
||||
Exposed in all four bindings (Rust, Python, Node, WASM). Renko,
|
||||
Kagi, and Point & Figure from the family ideas list are deferred:
|
||||
they are custom bar generators rather than indicators and belong in
|
||||
`wickra-data`.
|
||||
- **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new
|
||||
streaming-first indicators implementing John Ehlers'
|
||||
digital-signal-processing school of cycle analytics — a strong
|
||||
differentiation feature versus TA-Lib and pandas-ta, which only
|
||||
ship fragments of this catalogue:
|
||||
- **MAMA / FAMA** (MESA Adaptive Moving Average + Following
|
||||
Adaptive Moving Average) — phase-rate-adaptive smoothing pair
|
||||
from the 2001 MESA paper, exposed both jointly via `Mama` (multi-
|
||||
output) and as a scalar `Fama` wrapper.
|
||||
- **Fisher Transform** and **Inverse Fisher Transform** — Gaussian
|
||||
normalisation of price (Ehlers 2002) and its tanh-based bounded
|
||||
counterpart for oscillators.
|
||||
- **SuperSmoother**, **Roofing Filter**, **Decycler** and **Decycler
|
||||
Oscillator** — 2-pole Butterworth lowpass, bandpass and
|
||||
high-pass complement building blocks from *Cycle Analytics for
|
||||
Traders* (2013).
|
||||
- **Hilbert Dominant Cycle**, **Sine Wave** and **Adaptive Cycle**
|
||||
— Hilbert-transform-based period estimation from *Rocket Science
|
||||
for Traders* (2001).
|
||||
- **Center of Gravity**, **Cybernetic Cycle Component**,
|
||||
**Instantaneous Trendline**, **Ehlers Stochastic** and
|
||||
**Empirical Mode Decomposition** — EasyLanguage classics from
|
||||
Ehlers' published catalogue.
|
||||
- All sixteen are exposed across Rust, Python, Node.js and WASM
|
||||
bindings, fuzz-tested, benchmarked against real BTCUSDT
|
||||
1-minute data, and pass `batch == streaming` equivalence.
|
||||
- Indicator count rises from 71 to **87** across **nine** families.
|
||||
- **DeMark family (family 11) — 12 new indicators.** TD Setup (9-bar
|
||||
buy/sell setup counter with parameterised lookback and target), TD
|
||||
Sequential (Setup + Countdown phase machine emitting setup count,
|
||||
countdown count and active countdown direction), TD DeMarker
|
||||
(bounded [0, 1] range oscillator built from high/low expansions),
|
||||
TD REI (Range Expansion Index — bounded ±100 oscillator with the
|
||||
classic 5-bar default), TD Pressure (volume-weighted buying /
|
||||
selling pressure normalised to ±100), TD Combo (aggressive
|
||||
countdown variant with extra monotone-low / monotone-close
|
||||
strictness conditions on top of the classic countdown rule), TD
|
||||
Countdown (standalone 13-bar countdown phase machine emitting
|
||||
only the signed countdown count and direction — smaller streaming
|
||||
payload than the full TD Sequential), TD Lines (TDST horizontal
|
||||
support / resistance levels derived from the highs and lows of
|
||||
the most-recently-completed setup), TD Range Projection (next-bar
|
||||
high / low projection from the current bar's OHLC via DeMark's
|
||||
open-vs-close-weighted pivot), TD Differential (2-bar
|
||||
buying-pressure-vs-selling-pressure reversal pattern emitting
|
||||
+1 / -1 / 0), TD Open (gap-and-fade reversal pattern emitting
|
||||
+1 / -1 / 0 when the open prints outside the prior bar's range
|
||||
but the subsequent action recovers back into it), and TD Risk
|
||||
Level (protective stop levels derived from the lowest-low / highest-
|
||||
high setup bar's true range). All twelve are exposed through the
|
||||
Rust, Python, Node, and WASM bindings with `batch == streaming`
|
||||
equivalence tests, candle-stream fuzz coverage, and benchmark
|
||||
entries on the BTCUSDT 1-minute dataset.
|
||||
- **Family 08 — Pivots & Support/Resistance.** Seven new indicators land
|
||||
the previously empty pivot family: Classic (Floor-Trader) Pivot Points
|
||||
with three resistance and support tiers, Fibonacci Pivots spaced by
|
||||
0.382 / 0.618 / 1.000 of the prior range, Camarilla Pivots
|
||||
(Nick Stott's four-tier `(H − L) · 1.1 / {12, 6, 4, 2}` levels),
|
||||
Woodie Pivots with the close-weighted `PP = (H + L + 2·C) / 4`,
|
||||
DeMark Pivots whose conditional `X` depends on whether the bar closed
|
||||
up, down or flat, Williams Fractals as a five-bar swing detector and
|
||||
ZigZag as a percent-threshold swing tracker. Every level/swing is
|
||||
exposed across Rust, Python, Node and WASM with the standard
|
||||
`update` / `batch` / `reset` / `is_ready` / `warmup_period` surface
|
||||
and matching streaming-vs-batch and reference-value tests. The fuzz
|
||||
candle target now covers all seven.
|
||||
- **Family 09 — Trailing Stops, seven new indicators.** Rounds out the
|
||||
trailing-stop family from 5 to 12: `HiLoActivator` (Crabel's
|
||||
SMA-of-high / SMA-of-low trail), `VoltyStop` (Cynthia Kase's
|
||||
extreme-anchor ATR stop), `YoyoExit` (long-only ATR trail with a
|
||||
re-entry trigger), `DonchianStop` (the original Turtle exit, lowest
|
||||
low / highest high), `PercentageTrailingStop` (fixed-percent trail),
|
||||
`StepTrailingStop` (round-number grid trail) and `RenkoTrailingStop`
|
||||
(block-anchored Renko-style trail). All wired into the four bindings
|
||||
(Rust, Python, Node, WASM), the streaming + batch fuzz targets, and
|
||||
the bench harness.
|
||||
- **Klinger Volume Oscillator (KVO).** Stephen J. Klinger's trend-aware
|
||||
volume-force oscillator: `EMA(vf, fast) − EMA(vf, slow)` over a daily
|
||||
volume force scaled by cumulative-measurement ratio. Classic
|
||||
`(fast, slow) = (34, 55)` exposed via `Kvo::classic()`.
|
||||
- **Volume Oscillator (VO).** Percent difference between a fast and a
|
||||
slow SMA of bar volume: `100 · (SMA(vol, fast) − SMA(vol, slow)) /
|
||||
SMA(vol, slow)`. Default `(14, 28)`.
|
||||
- **Negative Volume Index (NVI).** Paul Dysart's cumulative index that
|
||||
only updates on volume-contraction bars (`volume_t < volume_{t−1}`),
|
||||
absorbing the percent close change on those quiet days. Fosback
|
||||
baseline `1000.0`, configurable via `Nvi::with_baseline`.
|
||||
- **Positive Volume Index (PVI).** The complementary index that
|
||||
updates on volume-expansion bars (`volume_t > volume_{t−1}`).
|
||||
- **Williams Accumulation/Distribution.** Larry Williams' volume-less
|
||||
cumulative flow that anchors to the previous close (true high/low) and
|
||||
classifies each bar as accumulation, distribution, or neutral by the
|
||||
sign of the close-to-close change.
|
||||
- **Anchored VWAP.** A cumulative VWAP whose accumulation begins at a
|
||||
user-chosen anchor bar rather than the session open. Re-anchor at
|
||||
runtime via `AnchoredVwap::set_anchor` for click-to-anchor trader
|
||||
workflows.
|
||||
- **Demand Index (Sibbet).** James Sibbet's smoothed buying-vs-selling
|
||||
pressure ratio in the streaming-friendly textbook form
|
||||
`EMA(volume · close-return · (1 + range/close), period)`.
|
||||
- **Time Segmented Volume (TSV).** Don Worden's rolling sum of signed
|
||||
volume weighted by the close-to-close move: a window-sum measure of
|
||||
net accumulation/distribution.
|
||||
- **Volume Zone Oscillator (VZO).** Walid Khalil's normalised
|
||||
volume-flow oscillator bounded in `[−100, 100]`, defined as
|
||||
`100 · EMA(signed_volume) / EMA(volume)`.
|
||||
- **Market Facilitation Index (Bill Williams).** Per-bar
|
||||
`(high − low) / volume` — how much price movement the market produces
|
||||
per unit of volume.
|
||||
- **ADXR (Average Directional Movement Index Rating)** in the Trend &
|
||||
Directional family. Wilder's directional-strength smoother: the
|
||||
average of the current `ADX` and the `ADX` from `period - 1` bars
|
||||
ago. Warmup is `3 * period - 1` (e.g. 41 for the default `period =
|
||||
14`). Shipped across all four bindings (Rust core, Python, Node,
|
||||
WASM) plus fuzz/test/bench coverage.
|
||||
- **Random Walk Index (RWI)** in the Trend & Directional family. Mike
|
||||
Poulos' trend-vs.-random-walk gauge: for each lookback `i ∈ [2,
|
||||
period]` the ratio of actual displacement to the random-walk
|
||||
expectation `ATR_i * sqrt(i)` is taken; the per-bar output is the
|
||||
maximum across lookbacks for both the high (`RWI_High`) and low
|
||||
(`RWI_Low`) directions. Multi-output `(high, low)` across all four
|
||||
bindings; warmup `= period`.
|
||||
- **Trend Intensity Index (TII)** in the Trend & Directional family.
|
||||
M.H. Pee's `[0, 100]` oscillator: the share of the most recent
|
||||
`dev_period` SMA-deviations that are positive, scaled to
|
||||
`[0, 100]`. Saturates at 100 on a pure uptrend, at 0 on a pure
|
||||
downtrend, and returns the neutral 50 on a perfectly flat market.
|
||||
Canonical Python defaults `(sma_period=60, dev_period=30)`; warmup
|
||||
`= sma_period + dev_period − 1`.
|
||||
- **Wave Trend Oscillator (LazyBear)** in the Trend & Directional
|
||||
family. Two-line mean-reverting momentum gauge built from the
|
||||
typical price and three cascaded EMAs:
|
||||
`esa = EMA(ap, channel)`, `d = EMA(|ap − esa|, channel)`,
|
||||
`ci = (ap − esa) / (0.015 · d)`, `wt1 = EMA(ci, average)`,
|
||||
`wt2 = SMA(wt1, signal)`. `WaveTrend::classic()` exposes the
|
||||
LazyBear defaults `(channel = 10, average = 21, signal = 4)`;
|
||||
warmup `= 2 · channel + average + signal − 3` (42 for the classic
|
||||
defaults). Includes a sub-ULP flat-tolerance guard on `ci` so a
|
||||
perfectly flat market reports `(0, 0)` instead of the
|
||||
mathematically indeterminate `−1 / 0.015 = −66.67`. Multi-output
|
||||
`(wt1, wt2)` across all four bindings.
|
||||
- **Family 05 — Bands & Channels (11 new indicators).** Eleven additional
|
||||
price-envelope overlays organised into the new "Bands & Channels"
|
||||
family, exposed across all four bindings (Rust, Python, Node, WASM):
|
||||
- `MaEnvelope` — SMA centerline with fixed-percent envelope (the oldest
|
||||
band overlay still in use).
|
||||
- `AccelerationBands` — Price Headley's momentum-biased bands that widen
|
||||
with the bar's relative range `(H − L) / (H + L)`.
|
||||
- `StarcBands` — Stoller Average Range Channel: SMA(close) ± k·ATR
|
||||
(Keltner's SMA-centerline sibling).
|
||||
- `AtrBands` — Close-anchored envelope of width `k · ATR`, the standard
|
||||
volatility-targeting stop/target band.
|
||||
- `HurstChannel` — SMA centerline wrapped by the rolling high-low range
|
||||
(Brian Millard / Hurst-cycle channel).
|
||||
- `LinRegChannel` — Linear-regression endpoint ± k·σ of the residuals,
|
||||
measuring dispersion about the *trend* rather than the mean.
|
||||
- `StandardErrorBands` — Linear regression with the OLS standard error
|
||||
(denominator `n − 2`) for prediction-interval bands.
|
||||
- `DoubleBollinger` — Kathy Lien's `±1σ` plus `±2σ` zone-partition setup.
|
||||
- `TtmSqueeze` — John Carter's BB-inside-KC squeeze flag paired with a
|
||||
detrended-close momentum reading.
|
||||
- `FractalChaosBands` — Bill Williams 5-bar fractal high/low envelope.
|
||||
- `VwapStdDevBands` — Cumulative VWAP with volume-weighted standard
|
||||
deviation bands.
|
||||
Indicator count rises from 71 to 82 across nine families; the README
|
||||
family table and the wiki overview/sidebar/warmup pages were updated to
|
||||
match.
|
||||
- **Yang-Zhang Volatility.** Yang & Zhang (2000) gold-standard OHLC
|
||||
estimator: a convex blend of overnight (close-to-open), open-to-close
|
||||
and Rogers-Satchell variances. The blending factor
|
||||
`k = 0.34 / (1.34 + (n+1)/(n-1))` is the one that minimises
|
||||
estimator variance under driftless GBM with overnight gaps. The
|
||||
overnight and open-to-close pieces use sample variance (Bessel's
|
||||
correction, divisor `n−1`), so the indicator needs `period + 1` bars
|
||||
to emit. Output annualised to a percent. Defaults: `period = 20`,
|
||||
`trading_periods = 252`. The recommended OHLC estimator for equities,
|
||||
futures, and any asset with material close-to-open gaps.
|
||||
- **Rogers-Satchell Volatility.** Drift-free OHLC realised-volatility
|
||||
estimator from Rogers, Satchell & Yoon (1994). Per-bar sample is
|
||||
`ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O)`; every term is non-negative by
|
||||
construction (high >= open, close; low <= open, close), so the
|
||||
rolling mean is exact, not biased, under arbitrary drift. The
|
||||
algebraic drift-cancellation is what differentiates it from
|
||||
Garman-Klass. Output annualised to a percent. Defaults:
|
||||
`period = 20`, `trading_periods = 252`.
|
||||
- **Garman-Klass Volatility.** Garman & Klass (1980) OHLC realised
|
||||
volatility estimator: per-bar sample is
|
||||
`0.5·(ln H/L)² − (2·ln2 − 1)·(ln C/O)²`, then take the annualised
|
||||
square root of the rolling mean. Roughly 7.4× more statistically
|
||||
efficient than close-to-close stddev under driftless GBM. Output
|
||||
annualised to a percent. Defaults: `period = 20`,
|
||||
`trading_periods = 252`.
|
||||
- **Parkinson Volatility.** Michael Parkinson's (1980) high-low realised
|
||||
volatility estimator: `sigma² = (1 / (4n·ln2)) · Σ (ln(H/L))²`. Output
|
||||
annualised to a percent in the same style as `HistoricalVolatility`
|
||||
(pass `trading_periods = 1` for the raw per-bar `sigma·100` figure).
|
||||
Roughly 5× more statistically efficient than close-to-close stddev
|
||||
under a driftless-GBM assumption. Defaults: `period = 20`,
|
||||
`trading_periods = 252`.
|
||||
- **RVIVolatility (Relative Volatility Index).** Donald Dorsey's
|
||||
RSI-shaped volatility gauge: partition the rolling standard
|
||||
deviation of close into "up" (close rose) and "down" (close fell)
|
||||
samples, Wilder-smooth each side, and compute
|
||||
`100 · AvgUp / (AvgUp + AvgDown)`. Bounded on `[0, 100]`; saturates
|
||||
at `100` in pure uptrends, `0` in pure downtrends, and falls back to
|
||||
`50` on a completely flat series (same undefined-RS convention as
|
||||
`RSI`). Single `period` parameter (default `10`) drives both the
|
||||
stddev window and the Wilder smoothing. Named `RVIVolatility` rather
|
||||
than plain `RVI` to disambiguate from Relative Vigor Index, which
|
||||
ships in Family 02 under the shorter `RVI` name.
|
||||
- **Family 03 — MACD & Price Oscillators.** `Stc` (Schaff Trend Cycle,
|
||||
Doug Schaff): doubly-`Stochastic`-smoothed MACD producing a bounded
|
||||
`[0, 100]` reading that reacts faster than `MACD` itself. Four
|
||||
parameters `(fast = 23, slow = 50, schaff_period = 10, factor = 0.5)`.
|
||||
Output is clamped to `[0, 100]` to absorb floating-point rounding.
|
||||
Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `ElderImpulse` (Alexander
|
||||
Elder's Impulse System): tri-state momentum gauge combining `EMA`
|
||||
trend slope with `MACD` histogram slope. Returns `+1` (green/buy)
|
||||
when both rise, `−1` (red/sell) when both fall, `0` (blue/neutral)
|
||||
on disagreement. Four parameters
|
||||
`(ema_period, macd_fast, macd_slow, macd_signal)`; defaults
|
||||
`(13, 12, 26, 9)` track *Come Into My Trading Room*. Exposed in all
|
||||
four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `ZeroLagMacd`: classic
|
||||
MACD topology with `ZLEMA` substituted for `EMA` everywhere — faster
|
||||
reaction to trend changes at the cost of slightly noisier readings.
|
||||
Multi-output `ZeroLagMacdOutput { macd, signal, histogram }`. Three
|
||||
parameters `(fast = 12, slow = 26, signal = 9)`; `fast` must be
|
||||
strictly less than `slow`. Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `CFO` (Chande Forecast
|
||||
Oscillator): `100 · (close − LinReg(close, period)) / close`. Positive
|
||||
when the close overshoots the linear forecast, negative when it
|
||||
undershoots. Holds the previous value if the close is zero. Default
|
||||
period 14. Exposed in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `AwesomeOscillatorHistogram`:
|
||||
`AO − SMA(AO, sma_period)`. A configurable variant of the existing
|
||||
`AcceleratorOscillator` (which fixes `(fast, slow, sma) = (5, 34, 5)`).
|
||||
Three parameters; defaults match Bill Williams' Accelerator. Exposed
|
||||
in all four bindings.
|
||||
- **Family 03 — MACD & Price Oscillators.** `APO` (Absolute Price
|
||||
Oscillator): `EMA(close, fast) − EMA(close, slow)`. Like MACD's line
|
||||
without the signal EMA. Default `(fast = 12, slow = 26)`. `fast` must
|
||||
be strictly less than `slow`. Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a
|
||||
`LinearRegression` smoothing of the `RVI` series — preserves trend
|
||||
direction while damping the underlying ratio. Candle input, two
|
||||
parameters `(rvi_period, linreg_period)` (defaults 14 / 20). Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `ConnorsRsi`: Larry Connors'
|
||||
3-component aggregate — `RSI(close)`, `RSI(streak)`, and the
|
||||
percentile rank of the 1-bar return over the recent `period_rank`
|
||||
returns. Bounded in `[0, 100]`. Three parameters
|
||||
`(period_rsi, period_streak, period_rank)` (defaults 3 / 2 / 100).
|
||||
Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `LaguerreRsi` (Ehlers):
|
||||
four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
|
||||
accumulator. Single parameter `gamma` in `[0, 1]` (default 0.5) trades
|
||||
lag for smoothness. State is seeded to the first input so a constant
|
||||
series stays at the neutral 50. Output clamped to `[0, 100]`. Exposed
|
||||
in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `SMI` (Stochastic Momentum
|
||||
Index, Blau): doubly-`EMA`-smoothed bounded oscillator measuring the
|
||||
close's displacement from the centre of the recent high-low range,
|
||||
scaled by the smoothed range. Candle input, three parameters
|
||||
`(period, d_period, d2_period)` (defaults 5 / 3 / 3). Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `KST` (Know Sure Thing, Pring):
|
||||
weighted sum of four `SMA`-smoothed `ROC` series with Pring's fixed
|
||||
weights `1, 2, 3, 4`, plus an `SMA` signal line. Nine parameters
|
||||
(four ROC periods, four SMA periods, signal period); `Kst::classic()`
|
||||
uses Pring's recommended defaults. Multi-output indicator emitting
|
||||
`KstOutput { kst, signal }`. Exposed in all four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `PGO` (Pretty Good Oscillator,
|
||||
Mark Johnson): `(close − SMA(close, period)) / EMA(TR, period)`.
|
||||
Candle input, single parameter `period` (default 14). Roughly counts
|
||||
how many ATR-equivalents the close is from its mean. Exposed in all
|
||||
four bindings.
|
||||
- **Family 02 — Momentum Oscillators.** `RVI` (Relative Vigor Index,
|
||||
Dorsey): per-bar ratio `SMA(close - open, period) / SMA(high - low,
|
||||
period)`. Candle input, single parameter `period` (default 10).
|
||||
Positive on average-bullish windows, negative on average-bearish.
|
||||
Holds previous value if the entire window has zero range. Exposed in
|
||||
all four bindings.
|
||||
- **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average):
|
||||
Gaussian-weighted moving average with configurable centre (`offset` in
|
||||
`[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults
|
||||
`(period = 9, offset = 0.85, sigma = 6.0)` available via `Alma::classic()`.
|
||||
Exposed in all four bindings (Rust, Python, Node, WASM).
|
||||
- **Family 01 — Moving Averages.** `EVWMA` (Elastic Volume-Weighted
|
||||
Moving Average, Fries 2001): an "elastic" recurrence whose smoothing
|
||||
weight is the bar's volume relative to the running window-volume.
|
||||
Candle input (uses close + volume), single parameter `period`
|
||||
(default 20). Holds its previous value if the entire window has zero
|
||||
volume. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `Alligator` (Bill Williams): three
|
||||
SMMA lines (Jaw / Teeth / Lips) of the median price `(high + low) / 2`
|
||||
with default periods 13 / 8 / 5. Multi-output indicator emitting
|
||||
`AlligatorOutput { jaw, teeth, lips }`. Visual chart shift is left to
|
||||
the consumer. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `JMA` (Jurik Moving Average):
|
||||
three-stage filter reconstruction of Mark Jurik's adaptive MA.
|
||||
Three parameters: `period` (14), `phase` in `[-100, 100]` (0), `power`
|
||||
in `1..=4` (2). State is seeded to the first input so a constant series
|
||||
is reproduced exactly. Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `VIDYA` (Variable Index Dynamic
|
||||
Average, Chande 1992): EMA whose smoothing factor is scaled by the
|
||||
absolute Chande Momentum Oscillator. Two parameters `period` and
|
||||
`cmo_period` (defaults 14 / 9). Exposed in all four bindings.
|
||||
- **Family 01 — Moving Averages.** `FRAMA` (Fractal Adaptive Moving
|
||||
Average, Ehlers 2005): adapts its smoothing constant to the fractal
|
||||
dimension of the recent window — fast in trends, slow in chop. Single
|
||||
parameter `period` (must be even, default 16). Exposed in all four
|
||||
bindings.
|
||||
- **Family 01 — Moving Averages.** `McGinleyDynamic`: John McGinley's
|
||||
self-adjusting MA. Single parameter `period`; the recurrence
|
||||
`MD + (price - MD) / (0.6 * period * (price / MD)^4)` speeds up when price
|
||||
falls below the indicator and damps when price runs above. Seeded with the
|
||||
simple average of the first `period` inputs. Exposed in all four bindings.
|
||||
|
||||
## [0.2.7] - 2026-05-24
|
||||
|
||||
### Added
|
||||
- **Windows ARM64 is back.** npm Support unblocked the
|
||||
`wickra-win32-arm64-msvc` sub-package name (same path
|
||||
`wickra-win32-x64-msvc` took through 0.1.4) and transferred write
|
||||
access to @kingchenc. 0.2.7 ships the binding for
|
||||
`aarch64-pc-windows-msvc` alongside the existing five platforms:
|
||||
the `napi.triples.additional` entry, the `optionalDependencies`
|
||||
pin, the `bindings/node/npm/win32-arm64-msvc/` sub-package and the
|
||||
`windows-11-arm` row of the release.yml node-build matrix are all
|
||||
restored from 8aa74cb. `npm install wickra` on Windows ARM64 now
|
||||
resolves to a native build instead of failing the loader's
|
||||
optional-dep lookup. PyPI's `win_arm64` wheel was unaffected and
|
||||
carries through as before.
|
||||
|
||||
### Changed
|
||||
- **Benchmark CPU renamed.** The "Reproduced on" line in every
|
||||
README listed an AMD Ryzen 9 7950X3D; the canonical machine is
|
||||
actually a Ryzen 9 9950X. Speedup ratios in the tables are
|
||||
unchanged (they're relative across libraries on the same machine),
|
||||
only the labelling is corrected. The performance-regression issue
|
||||
template's CPU example was updated for consistency.
|
||||
|
||||
## [0.2.6] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
- **docs.rs build.** Rust 1.92 removed the `doc_auto_cfg` feature gate
|
||||
and folded it back into `doc_cfg` (rust-lang/rust#138907). docs.rs
|
||||
builds against the latest nightly and sets `--cfg docsrs`, so every
|
||||
published 0.2.x failed with E0557 on the
|
||||
`#![cfg_attr(docsrs, feature(doc_auto_cfg))]` line at the top of
|
||||
`wickra`, `wickra-core`, and `wickra-data`. GitHub CI didn't see
|
||||
this — stable rustc never enables the `docsrs` cfg. The three
|
||||
library crates now gate on `doc_cfg` (same intent, same rendered
|
||||
output on docs.rs, builds again on nightly).
|
||||
|
||||
### Changed
|
||||
- **README — Wickra is now the top row of every comparison table.**
|
||||
The "Why Wickra exists" library matrix and the per-indicator
|
||||
benchmark tables previously placed Wickra at the bottom; a reader
|
||||
landing on the README is here to compare *against* Wickra, so the
|
||||
pivot row belongs at the top with a ★ marker. Same column data,
|
||||
same winner annotations — only row order changed. Mirrored across
|
||||
the umbrella README and every binding README so crates.io / PyPI /
|
||||
npm landing pages stay in sync.
|
||||
|
||||
## [0.2.5] - 2026-05-24
|
||||
|
||||
### Added
|
||||
- `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
|
||||
- **MSRV bumped.** Workspace minimum supported Rust version is now **1.86**
|
||||
(was 1.75) and the Node binding (`wickra-node`) is now **1.88** (was 1.77).
|
||||
The bumps are driven by transitive-dependency floors that were lifted in
|
||||
recent updates: `criterion 0.8.2` (the bench dev-dep) requires Rust 1.86,
|
||||
and `napi-build >= 2.3.2` requires Rust 1.88. Pinning those deps to the
|
||||
older versions would have frozen us out of future security fixes from
|
||||
those upstreams, so lifting the MSRV is the cleaner path for a young 0.x
|
||||
library. Downstream consumers on older Rust toolchains can stay on
|
||||
Wickra 0.2.0.
|
||||
- Bumped the bench dev-dep `criterion` from 0.5 to 0.8 and migrated
|
||||
`bindings/wickra/benches/indicators.rs` from the deprecated
|
||||
`criterion::black_box` re-export to the stable `std::hint::black_box`.
|
||||
- Bumped `tokio-tungstenite` from 0.24 to 0.29. `WebSocketConfig` became
|
||||
`#[non_exhaustive]` upstream, so the struct-literal construction in
|
||||
`crates/wickra-data/src/live/binance.rs` is rewritten to the
|
||||
builder-style `WebSocketConfig::default().max_message_size(..).max_frame_size(..)`.
|
||||
Same caps, same semantics, same default carry-over.
|
||||
- Bumped every committed CI/release GitHub Action to its latest pinned
|
||||
SHA: `actions/checkout` 4 → 6, `actions/setup-node` 4 → 6,
|
||||
`actions/setup-python` 5 → 6, `actions/upload-artifact` 4 → 7,
|
||||
`actions/download-artifact` 4 → 8, `softprops/action-gh-release` 2 → 3,
|
||||
`codecov/codecov-action` 5 → 6, `taiki-e/install-action` patch.
|
||||
|
||||
### Fixed
|
||||
- `tick_aggregator` gap-fill no longer allocates an unbounded number of
|
||||
placeholder candles. The new `MAX_GAP_FILL_CANDLES = 1_000_000` cap
|
||||
surfaces an adversarial timestamp jump (e.g. a clock-glitch tick years
|
||||
in the future) as `Error::Malformed` instead of an OOM panic. Found by
|
||||
the new `tick_aggregator` fuzz target.
|
||||
- `HistoricalVolatility::geometric_series_yields_zero` now uses an `1e-6`
|
||||
tolerance instead of `1e-9`. The mathematical result on a perfectly
|
||||
geometric price series is exactly zero, but the underlying
|
||||
`1.01_f64.powi(i)` + log-return + std-dev cascade accumulates
|
||||
platform-sensitive FP drift on the order of 1e-7 on x86_64 Linux and
|
||||
macOS. The widened tolerance stays four decimal places below any
|
||||
realistic annualised volatility value while absorbing the drift across
|
||||
every supported platform.
|
||||
- Replaced every `(high + low) / 2.0` test-helper and three real call
|
||||
sites (`Ohlcv::median_price`, `Donchian.middle`, `EaseOfMovement.mid`,
|
||||
`SuperTrend.hl2`) with `f64::midpoint(high, low)`. The change satisfies
|
||||
clippy 1.95's new `manual_midpoint` lint without affecting values
|
||||
(`f64::midpoint` matches the naive average to better than 1 ULP for the
|
||||
inputs used here).
|
||||
- Replaced `i.is_multiple_of(2)` (unstable on Rust 1.85) with `i % 2 == 0`
|
||||
in the SMA / Bollinger long-stream-drift tests so the workspace MSRV
|
||||
job builds cleanly on Rust 1.86.
|
||||
- The `Compile examples` CI step now invokes
|
||||
`cargo build -p wickra-examples --bins` instead of the now-deleted
|
||||
`cargo build -p wickra --example backtest` / `-p wickra-data --example
|
||||
live_binance` (the Z5 reorganisation moved every runnable example into
|
||||
the dedicated `wickra-examples` crate, but the CI step had not been
|
||||
updated).
|
||||
- The `Fuzz (smoke)` CI job installs `cargo-fuzz` from a prebuilt binary
|
||||
via `taiki-e/install-action` instead of `cargo install cargo-fuzz`.
|
||||
The source install resolved against `rustix 0.36.5`, which uses
|
||||
internal `#[rustc_*]` attributes the current nightly compiler rejects.
|
||||
- The fuzz targets now build with an explicit
|
||||
`--target x86_64-unknown-linux-gnu`; cargo-fuzz was defaulting to
|
||||
`x86_64-unknown-linux-musl`, which is not installed on the standard
|
||||
GitHub-hosted Ubuntu runner.
|
||||
|
||||
### Removed
|
||||
- **`wickra-win32-arm64-msvc` is temporarily omitted from this release.**
|
||||
The npm spam-detection filter blocks the first publish of this brand-new
|
||||
package name (same situation that affected `wickra-win32-x64-msvc`
|
||||
through 0.1.4 until npm Support unblocked it). A support ticket is open;
|
||||
once the new name is unblocked the
|
||||
`aarch64-pc-windows-msvc` triple will be restored in
|
||||
`bindings/node/package.json` (`napi.triples.additional` +
|
||||
`optionalDependencies`), in the `release.yml` `node-build` matrix, and
|
||||
as a fresh `bindings/node/npm/win32-arm64-msvc/` template. Until then,
|
||||
`npm install wickra@0.2.1` on Windows ARM64 will surface the loader's
|
||||
standard `Cannot find module 'wickra-win32-arm64-msvc'` error; every
|
||||
other platform (Linux x64 / Linux ARM64 / macOS x64 / macOS ARM64 /
|
||||
Windows x64) ships normally. The PyPI wheel for Windows ARM64 is
|
||||
unaffected and still published.
|
||||
|
||||
## [0.2.0] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- `HistoricalVolatility::update` no longer substitutes a `0.0` log-return on
|
||||
non-positive prices (audit finding R13). Negative or zero prices are
|
||||
semantically invalid for a log-return calculation; silently treating them as
|
||||
"no movement" underreported realised volatility. They are now skipped — the
|
||||
previous valid value is returned and the indicator's state (`prev_price`,
|
||||
window, sums) is left untouched — matching how every other indicator handles
|
||||
invalid inputs.
|
||||
- `Tick::new` now returns the new `Error::InvalidTick` variant for negative
|
||||
volume instead of `Error::InvalidCandle` (audit finding R14). A tick is not
|
||||
a candle, and downstream tick-stream pipelines should be able to match on a
|
||||
semantically-correct error. The Python binding's `map_err` was extended to
|
||||
forward the new variant as a `ValueError`; the Node and WASM bindings format
|
||||
via `Error::to_string()` and pick the new variant up automatically.
|
||||
- `Psar::is_ready` now matches the convention shared by every other indicator:
|
||||
`is_ready() == true` iff a real value has been produced (audit finding R6).
|
||||
The previous implementation returned `self.initialised`, which flipped to
|
||||
`true` after the seed candle even though the seed candle itself returns
|
||||
`None`. A streaming consumer that wrote
|
||||
`if ind.is_ready() { use(ind.update(c)?) }` would hit an unexpected `None`
|
||||
on the first post-seed update. The fix introduces a `has_emitted` gate set
|
||||
when the first `Some` value is returned.
|
||||
- `Psar::reset` now restores the compute fields (`prev_high`, `prev_low`,
|
||||
`sar`, `ep`) to `f64::NAN` sentinels instead of `0.0` (audit Opus-Bonus 1).
|
||||
The fields are gated by `initialised` today, so the `0.0` sentinel never
|
||||
leaked into output — but a future refactor that read them pre-init would
|
||||
have silently treated `0.0` as a real price. A `debug_assert!` at the read
|
||||
site makes the invariant explicit.
|
||||
|
||||
### Changed
|
||||
- `Sma` and `BollingerBands` now reseed their incremental `sum` (and `sum_sq`
|
||||
for Bollinger) from the live window every `16 · period` finite updates,
|
||||
capping floating-point drift on long-running streams (audit findings R7 and
|
||||
L2-Rust). Previously the incremental single-subtract `sum -= old` could
|
||||
accumulate catastrophic-cancellation error on streams with alternating
|
||||
large/small magnitudes; the misleading `sma.rs` comment that claimed the
|
||||
drift was already bounded "by recomputing the sum after each pop" is
|
||||
replaced with an accurate description of the new reseed strategy. Amortised
|
||||
cost stays at O(1) (`O(period)` work amortised over `O(period)` updates),
|
||||
values are bit-identical on inputs that did not drift to begin with, and
|
||||
two new `long_stream_drift_stays_bounded` tests stress the recompute by
|
||||
alternating `1e9` / `1.0` (SMA) and `1e6` / `1.0` (Bollinger) for several
|
||||
recompute cycles and verify the reported values track a fresh from-scratch
|
||||
computation over the live window.
|
||||
- `LinearRegression`, `LinRegSlope` and `LinRegAngle` (via composition over
|
||||
`LinRegSlope`) now run their rolling ordinary-least-squares fit
|
||||
**incrementally** in O(1) per update (audit finding R2). Previously every
|
||||
tick refit the line from scratch in O(period). The OLS denominators (`Σx`
|
||||
and `Σxx`) depend only on `period`, so they were already precomputed; this
|
||||
release adds running `Σy` and `Σxy` accumulators and slides them in closed
|
||||
form via the identity
|
||||
`new_Σxy = old_Σxy − old_Σy + popped_y₀` (then `Σxy += (n − 1) · new_value`
|
||||
and `Σy += new_value`). New per-bar equivalence tests compare the O(1)
|
||||
output against a fresh O(n) refit on noisy ramps, step functions, and
|
||||
constants — values agree to within 1e-9.
|
||||
- Fuzz suite expanded from 2 indicators to the full catalogue (audit finding
|
||||
R9). The existing `indicator_update` target now exercises every scalar-input
|
||||
indicator (~33 classes including MACD and Bollinger Bands); a new
|
||||
`indicator_update_candle` target exercises every candle-input indicator (~37
|
||||
classes, including ATR, ADX, Stochastic, PSAR, Keltner, SuperTrend,
|
||||
ChandelierExit, AwesomeOscillator, OBV, MFI, VWAP, RollingVWAP, and the rest
|
||||
of the volume / volatility / trailing-stop / price-statistics families). Each
|
||||
iteration sweeps every indicator through both the streaming `update` loop
|
||||
and a full `batch` call so any state-mutation bug surfaces on either path.
|
||||
CI gains a `fuzz-smoke` job that runs each of the five targets for 30 s on
|
||||
every push and pull-request.
|
||||
- `UlcerIndex::update` now tracks the trailing maximum with a monotonically-
|
||||
decreasing deque of `(index, price)` pairs instead of scanning the whole
|
||||
trailing window on every tick. The indicator now honours the `Indicator`
|
||||
trait's O(1)-per-tick contract; values and warmup semantics are unchanged
|
||||
(verified by a new adversarial-input test that compares the deque output
|
||||
bar-by-bar against a naive O(n) trailing-max scan on strictly increasing,
|
||||
strictly decreasing, constant, and sawtooth inputs). The doc comment on
|
||||
`warmup_period()` is also corrected: the two windows overlap by one bar, so
|
||||
the formula is `2 * period - 1`.
|
||||
|
||||
### Added
|
||||
- `RollingVWAP` is now exposed in Python, Node and WASM under that name
|
||||
(previously the rolling-window VWAP existed only in the Rust core, even
|
||||
though the README's volume-family table already advertised
|
||||
`VWAP (cumulative + rolling)`). All four bindings now ship the same
|
||||
cumulative `VWAP` plus the finite-window `RollingVWAP(period)`. The wiki page
|
||||
`Indicator-Vwap.md` adds Python, Node and WASM examples and drops the
|
||||
"Rust-only" caveat.
|
||||
- WASM binding now exposes the streaming `update()` method on every candle-input
|
||||
indicator: `Adx`, `WilliamsR`, `Cci`, `Mfi`, `Psar`, `Keltner`, `Donchian`,
|
||||
`Vwap`, `AwesomeOscillator`, `Aroon`, `Stochastic`, and `Obv`. Multi-output
|
||||
indicators (`Adx`, `Keltner`, `Donchian`, `Aroon`, `Stochastic`) return a
|
||||
named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`,
|
||||
`{ up, down }`, `{ k, d }`) once warm, or `null` during warmup — matching the
|
||||
existing `SuperTrend` convention. Each class also gains `reset()`, `isReady()`
|
||||
and `warmupPeriod()`, bringing the WASM surface to full parity with Python
|
||||
and Node so browser-side streaming code no longer has to replay `batch()`
|
||||
on every tick. `WasmKama` gains the previously missing `warmupPeriod()`.
|
||||
- New `wasm-bindgen` integration test exercises `update == batch` plus the full
|
||||
lifecycle (`reset` / `isReady` / `warmupPeriod`) for all twelve newly wired
|
||||
classes against a deterministic 40-bar synthetic OHLCV stream.
|
||||
|
||||
### Security
|
||||
- Upgrade `pyo3` (0.22 → 0.28) and `numpy` (0.22 → 0.28) in the Python binding.
|
||||
Fixes [RUSTSEC-2025-0020](https://rustsec.org/advisories/RUSTSEC-2025-0020) —
|
||||
a buffer overflow in `PyString::from_object` that affected the published
|
||||
Python wheels. The `cargo-deny` ignore entry that previously suppressed the
|
||||
advisory has been removed; `cargo deny check` is now clean without
|
||||
suppression. Migrated `into_pyarray_bound` to `into_pyarray`,
|
||||
`downcast::<PyDict>` to `cast::<PyDict>`, and opted every `#[pyclass]` out of
|
||||
the deprecated automatic `FromPyObject` derive via `skip_from_py_object`.
|
||||
|
||||
### Added
|
||||
- 46 new technical indicators, taking the library from 25 to 71 and
|
||||
reorganising the catalogue into **eight families**, each with at least five
|
||||
members. Every indicator is implemented once in the Rust core and wired
|
||||
through the Python, Node and WASM bindings, with reference-value tests and a
|
||||
dedicated wiki page:
|
||||
- Moving Averages: `Smma`, `Trima`, `Zlema`, `T3`, `Vwma`.
|
||||
- Momentum Oscillators: `Mom`, `Cmo`, `Tsi`, `Pmo`, `StochRsi`,
|
||||
`UltimateOscillator`.
|
||||
- Trend & Directional: `AroonOscillator`, `Vortex`, `MassIndex`,
|
||||
`ChoppinessIndex`, `VerticalHorizontalFilter`.
|
||||
- Price Oscillators: `Ppo`, `Dpo`, `Coppock`, `AcceleratorOscillator`,
|
||||
`BalanceOfPower`.
|
||||
- Volatility & Bands: `Natr`, `StdDev`, `UlcerIndex`,
|
||||
`HistoricalVolatility`, `BollingerBandwidth`, `PercentB`, `TrueRange`,
|
||||
`ChaikinVolatility`.
|
||||
- Trailing Stops: `SuperTrend`, `ChandelierExit`, `ChandeKrollStop`,
|
||||
`AtrTrailingStop`.
|
||||
- Volume: `Adl`, `VolumePriceTrend`, `ChaikinMoneyFlow`,
|
||||
`ChaikinOscillator`, `ForceIndex`, `EaseOfMovement`.
|
||||
- Price Statistics: `TypicalPrice`, `MedianPrice`, `WeightedClose`,
|
||||
`LinearRegression`, `LinRegSlope`, `ZScore`, `LinRegAngle`.
|
||||
- `TickAggregator::with_gap_fill` — opt-in mode that emits a flat placeholder
|
||||
candle for every empty bucket between two ticks, keeping the candle series
|
||||
evenly spaced for downstream indicators.
|
||||
- CSV reader: a leading UTF-8 byte-order mark is stripped, fields are trimmed,
|
||||
and the header is validated against the required OHLCV columns.
|
||||
- CI: an `msrv` job that builds and tests the workspace on Rust 1.75 and the
|
||||
node binding on Rust 1.77.
|
||||
- Community health files: `CONTRIBUTING.md`, `SECURITY.md`,
|
||||
`CODE_OF_CONDUCT.md`, issue / pull-request templates, `CODEOWNERS`, and a
|
||||
Dependabot configuration.
|
||||
- Seven example OHLCV datasets under `examples/data/`, one per timeframe
|
||||
(1m / 5m / 15m / 1h / 12h / 1d / 1month), holding real BTCUSDT spot klines,
|
||||
alongside the `fetch_btcusdt` example that regenerates them from the
|
||||
Binance REST API.
|
||||
- `Timeframe::minutes`, `Timeframe::hours` and `Timeframe::days` convenience
|
||||
constructors, each building on seconds with a checked-multiplication
|
||||
overflow guard.
|
||||
|
||||
### Changed
|
||||
- The indicator wiki is reorganised into eight family folders under
|
||||
`docs/wiki/indicators/` (`moving-averages/`, `momentum-oscillators/`,
|
||||
`trend-directional/`, `price-oscillators/`, `volatility-bands/`,
|
||||
`trailing-stops/`, `volume/`, `price-statistics/`); `Indicators-Overview.md`,
|
||||
`Home.md` and the README indicator table follow the same eight families.
|
||||
- `TickAggregator::push` returns `Result<Vec<Candle>>` (was
|
||||
`Result<Option<Candle>>`) so a single tick can yield a closed bar plus gap
|
||||
fillers.
|
||||
- `Resampler::push` returns `Result<Option<Candle>>`: a candle in a bucket
|
||||
earlier than the open bar is now rejected as out of order.
|
||||
- Aggregated candles are finalised through the validating `Candle::new`, so a
|
||||
volume that overflows to a non-finite value is surfaced as an error instead
|
||||
of producing a poisoned candle.
|
||||
- All GitHub Actions are pinned to commit SHAs; the four publish jobs run in a
|
||||
protected `release` environment.
|
||||
- The indicator benchmarks (`crates/wickra/benches/indicators.rs`) now run
|
||||
against the checked-in real BTCUSDT 1-minute dataset instead of a synthetic
|
||||
price series.
|
||||
- Every language's examples now live under a uniform `examples/<lang>/`
|
||||
tree: Rust moved into a new `examples/rust/` workspace member crate
|
||||
(`wickra-examples`, run via `cargo run -p wickra-examples --bin <name>`),
|
||||
Node into `examples/node/` with its own `package.json` linking `wickra` via
|
||||
`file:../../bindings/node`, and the WASM browser demos into
|
||||
`examples/wasm/`. The bundled BTCUSDT datasets move alongside them at
|
||||
`examples/data/`. Six new examples close the cross-language parity matrix:
|
||||
streaming demos for Python and Rust; multi-timeframe and parallel-assets
|
||||
demos for both Rust and Node.
|
||||
- Cross-language data-generator parity: `examples/python/fetch_btcusdt.py`
|
||||
(stdlib only: `urllib` + `json` + `csv`) and `examples/node/fetch_btcusdt.js`
|
||||
(Node 18+ built-in `fetch`) mirror the Rust `fetch_btcusdt` binary —
|
||||
byte-for-byte identical CSV output on the same Binance snapshot.
|
||||
- Four additional WebAssembly browser demos under `examples/wasm/`
|
||||
alongside the original `index.html`: `backtest.html` (fetch + basket of
|
||||
indicators), `live_trading.html` (browser-native `WebSocket` to
|
||||
Binance), `multi_timeframe.html` (in-page resample) and
|
||||
`parallel_assets.html` + `parallel_worker.js` (module-Worker pool with
|
||||
serial-vs-parallel speedup). The cross-language matrix is now closed
|
||||
for every cell where the pattern makes sense.
|
||||
- Three new wiki pages: `TA-Lib-Migration.md` (full mapping table from
|
||||
`talib.X(...)` calls to Wickra), `Cookbook.md` (seven concrete
|
||||
strategy recipes — RSI mean reversion, MACD crossover, Bollinger
|
||||
breakout, ADX-gated trend, multi-timeframe confirmation, SuperTrend,
|
||||
chained indicators) and `FAQ.md`. All three linked from `Home.md`.
|
||||
|
||||
### Fixed
|
||||
- `Timeframe::floor` no longer overflows for timestamps near `i64::MIN`.
|
||||
- The aggregator rejects same-bucket ticks that arrive out of order instead of
|
||||
silently overwriting the bar's close with a stale price.
|
||||
- The Binance live stream reconnects with exponential backoff, skips non-kline
|
||||
frames, applies a read timeout and message-size limits, and tracks a closed
|
||||
flag.
|
||||
- Example scripts: `live_trading.py` skips non-kline frames and validates the
|
||||
symbol/interval; `backtest.py` and `multi_timeframe.py` report clear errors
|
||||
for malformed CSV input.
|
||||
|
||||
## [0.1.4] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- GitHub Release runs now attach every built artefact (wheels, sdist, native
|
||||
Node binaries, npm-pack tarballs, cargo `.crate` files) to the tag's
|
||||
release page.
|
||||
|
||||
## [0.1.3] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- npm package ships the napi-generated loader and is built with `--platform`
|
||||
so the per-platform binary is resolved correctly.
|
||||
|
||||
## [0.1.2] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- Release pipeline: per-platform idempotent npm publishing with a spam-filter
|
||||
retry, and committed `npm/<platform>/` package templates.
|
||||
|
||||
## [0.1.1] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- Node publish step and coordinated version bump across all bindings.
|
||||
|
||||
## [0.1.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
- Initial release: a streaming-first technical-analysis library with 25
|
||||
indicators (SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, RSI, MACD, ROC, Stochastic,
|
||||
CCI, Williams %R, ADX, MFI, TRIX, Aroon, Awesome Oscillator, Bollinger Bands,
|
||||
ATR, Keltner Channels, Donchian Channels, Parabolic SAR, OBV, VWAP).
|
||||
- Rust core (`wickra-core`), umbrella crate (`wickra`), and a data layer
|
||||
(`wickra-data`) with a CSV reader, tick aggregator, resampler, and an
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.2.7...HEAD
|
||||
[0.2.7]: https://github.com/wickra-lib/wickra/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/wickra-lib/wickra/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/wickra-lib/wickra/compare/v0.2.1...v0.2.5
|
||||
[0.2.1]: https://github.com/wickra-lib/wickra/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/wickra-lib/wickra/compare/v0.1.4...v0.2.0
|
||||
[0.1.4]: https://github.com/wickra-lib/wickra/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/wickra-lib/wickra/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/wickra-lib/wickra/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/wickra-lib/wickra/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/wickra-lib/wickra/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,29 @@
|
||||
cff-version: 1.2.0
|
||||
title: Wickra
|
||||
message: >-
|
||||
If you use Wickra in academic work, please cite it using the metadata
|
||||
below.
|
||||
type: software
|
||||
authors:
|
||||
- alias: kingchenc
|
||||
email: wickra.lib@gmail.com
|
||||
repository-code: "https://github.com/wickra-lib/wickra"
|
||||
url: "https://wickra.org"
|
||||
abstract: >-
|
||||
Wickra is a streaming-first technical-analysis library implemented in
|
||||
Rust with bindings for Python, Node.js and WebAssembly. Each indicator
|
||||
is a state machine that updates in constant time per new input, so
|
||||
identical code paths serve live-trading workloads and historical
|
||||
back-testing. The library covers 214 indicators across 16 families
|
||||
(moving averages, momentum, volatility, volume, statistics, Ehlers
|
||||
digital-signal-processing cycles, pivots, DeMark, Ichimoku, candlestick
|
||||
patterns, market profile, and risk/performance metrics).
|
||||
keywords:
|
||||
- technical-analysis
|
||||
- technical-indicators
|
||||
- streaming
|
||||
- algorithmic-trading
|
||||
- quantitative-finance
|
||||
- rust
|
||||
- time-series
|
||||
license: PolyForm-Noncommercial-1.0.0
|
||||
@@ -0,0 +1,47 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in
|
||||
the Wickra project a respectful and welcoming experience for everyone,
|
||||
regardless of background or identity.
|
||||
|
||||
## Our standards
|
||||
|
||||
Behaviour that helps build a positive community includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Respecting differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility for mistakes and learning from them.
|
||||
- Focusing on what is best for the project and the community.
|
||||
|
||||
Behaviour that is not acceptable includes:
|
||||
|
||||
- Personal attacks, insults, or derogatory comments.
|
||||
- Harassment of any kind, public or private.
|
||||
- Publishing others' private information without explicit permission.
|
||||
- Other conduct that would reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies in all project spaces — the repository, issues,
|
||||
pull requests, and discussions — and when an individual is representing the
|
||||
project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||
at **wickra.lib@gmail.com**. All reports will be reviewed and investigated
|
||||
promptly and fairly, and the maintainer will respect the privacy and security
|
||||
of the reporter.
|
||||
|
||||
Maintainers may take any action they deem appropriate, including warnings,
|
||||
temporary bans, or permanent removal from the project, for behaviour that
|
||||
violates this Code of Conduct.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Contributing to Wickra
|
||||
|
||||
Thanks for your interest in improving Wickra. This document explains how to
|
||||
build the project, the standards a change must meet, and how to get it merged.
|
||||
|
||||
## License of contributions
|
||||
|
||||
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
|
||||
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
|
||||
licensed to the project under those same terms. The Noncommercial license
|
||||
permits use for any purpose **other than** a commercial one; keep that in mind
|
||||
when proposing features or depending on Wickra elsewhere.
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `crates/wickra-core` | The indicator engine — every indicator lives here. |
|
||||
| `crates/wickra` | Thin umbrella crate re-exporting `wickra-core`. |
|
||||
| `crates/wickra-data` | CSV reader, tick aggregator, resampler, Binance feed. |
|
||||
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/` | Pointer to the project Wiki, which holds all documentation. |
|
||||
|
||||
## Building and testing
|
||||
|
||||
### Rust
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo test -p wickra-data --features live-binance
|
||||
```
|
||||
|
||||
The minimum supported Rust version is **1.75** for the workspace crates and
|
||||
**1.77** for `bindings/node`; the `msrv` CI job enforces both.
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
cd bindings/python
|
||||
python -m maturin build --release --out dist
|
||||
python -m pip install --force-reinstall --no-deps dist/wickra-*.whl
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```bash
|
||||
cd bindings/node
|
||||
npm install
|
||||
npx napi build --platform --release
|
||||
node --test __tests__/
|
||||
```
|
||||
|
||||
### WASM
|
||||
|
||||
```bash
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
wasm-pack test --node bindings/wasm
|
||||
```
|
||||
|
||||
## Standards for a change
|
||||
|
||||
- **Formatting & lints.** `cargo fmt` must leave the tree unchanged and
|
||||
`cargo clippy ... -D warnings` must be clean. CI gates both.
|
||||
- **Tests.** New behaviour needs tests; bug fixes need a regression test.
|
||||
- **Indicator correctness.** A new or changed indicator must have a
|
||||
reference-value test against a known-good source (TA-Lib, pandas-ta, or a
|
||||
hand-computed value) and a `reset` test.
|
||||
- **Streaming parity.** An indicator's `batch` output must equal the sequence
|
||||
of `update` calls.
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
- **Docs.** Update the relevant page on the
|
||||
[project Wiki](https://github.com/wickra-lib/wickra/wiki) and the
|
||||
`README.md` when behaviour or the public API changes. The Wiki lives in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra.wiki.git`.
|
||||
- **Changelog.** Add an entry under `## [Unreleased]` in `CHANGELOG.md`.
|
||||
|
||||
## Commit and pull-request workflow
|
||||
|
||||
1. Branch off `main`.
|
||||
2. Keep commits focused — one logical change per commit, with an imperative
|
||||
subject line and a body explaining *why*.
|
||||
3. Open a pull request against `main` and fill in the template.
|
||||
4. CI must be green before review.
|
||||
|
||||
## Reporting bugs and proposing features
|
||||
|
||||
Use the issue templates under
|
||||
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
|
||||
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
|
||||
Generated
+182
-166
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
@@ -11,6 +11,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
@@ -38,6 +47,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -80,12 +100,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -212,25 +226,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@@ -238,9 +251,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
@@ -468,17 +481,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -536,12 +538,6 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -679,31 +675,11 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -748,6 +724,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -783,12 +765,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -891,6 +874,15 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -916,13 +908,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "0.22.1"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
|
||||
checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"ndarray",
|
||||
@@ -930,6 +923,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pyo3",
|
||||
"pyo3-build-config",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
@@ -988,6 +982,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1096,8 +1100,8 @@ dependencies = [
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
@@ -1107,37 +1111,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
|
||||
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
|
||||
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
|
||||
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
@@ -1145,9 +1144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
|
||||
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
@@ -1157,9 +1156,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.22.6"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
|
||||
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -1195,35 +1194,14 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1233,16 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1260,7 +1229,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1320,9 +1289,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
@@ -1451,9 +1420,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -1531,9 +1500,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
@@ -1548,33 +1517,13 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1646,9 +1595,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -1660,21 +1609,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.6",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1707,12 +1654,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1725,12 +1666,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -1805,6 +1740,16 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
@@ -1837,6 +1782,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
@@ -1883,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1894,17 +1878,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1912,17 +1896,26 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"wickra",
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"wickra",
|
||||
"wickra-data",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1932,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1941,16 +1934,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -1960,6 +1970,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
+11
-10
@@ -7,23 +7,24 @@ members = [
|
||||
"bindings/python",
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"examples/rust",
|
||||
]
|
||||
exclude = []
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
authors = ["Wickra Contributors"]
|
||||
version = "0.3.0"
|
||||
authors = ["kingchenc <wickra.lib@gmail.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
rust-version = "1.86"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
repository = "https://github.com/kingchenc/wickra"
|
||||
homepage = "https://github.com/kingchenc/wickra"
|
||||
repository = "https://github.com/wickra-lib/wickra"
|
||||
homepage = "https://github.com/wickra-lib/wickra"
|
||||
readme = "README.md"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.1.2" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.3.0" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
@@ -31,11 +32,11 @@ rayon = "1.10"
|
||||
# Testing
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
# Python binding
|
||||
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.22"
|
||||
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.28"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -35,7 +35,7 @@ URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
@@ -133,4 +133,4 @@ of your licenses.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Wickra
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
@@ -35,49 +36,63 @@ 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.
|
||||
|
||||
## Benchmark: how much faster is "streaming-first"?
|
||||
|
||||
Reproduced on this machine with `python -m benchmarks.compare_libraries`.
|
||||
The numbers below were measured on a single developer workstation and are not
|
||||
guaranteed to reproduce identically on different hardware — absolute µs values
|
||||
depend on CPU, memory clock and OS scheduler. Read them as **relative
|
||||
speedups** between libraries on identical input, not as a universal
|
||||
performance contract.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries`. The script auto-detects every
|
||||
installed peer library and runs them on the same generated inputs as
|
||||
Wickra. The CI job `cross-library-bench` runs the same script on every
|
||||
push and uploads the raw report as a build artefact.
|
||||
|
||||
Lower µs/op = faster. Wickra wins every batch category outright, and the
|
||||
streaming gap widens linearly with how much history a batch-only library has
|
||||
to recompute on every tick.
|
||||
|
||||
### Batch — single full pass over a 5 000-bar series
|
||||
### Batch — single full pass over a 20 000-bar series
|
||||
|
||||
Reading the table: each cell shows that library's runtime, plus how many times
|
||||
slower it is than Wickra in parentheses. **★** marks the winner per row.
|
||||
|
||||
| Indicator | Wickra | finta | talipp |
|
||||
|---------------------|---------------------|------------------------|------------------------------|
|
||||
| SMA(20) | **26.0 µs ★** | 295.3 µs (11.4× slower) | 1 812.8 µs (69.7× slower) |
|
||||
| EMA(20) | **16.8 µs ★** | 205.5 µs (12.2× slower) | 2 534.4 µs (150.9× slower) |
|
||||
| RSI(14) | **31.2 µs ★** | 714.1 µs (22.9× slower) | 3 751.7 µs (120.2× slower) |
|
||||
| MACD(12, 26, 9) | **30.8 µs ★** | 359.5 µs (11.7× slower) | 11 642.2 µs (378.0× slower) |
|
||||
| Bollinger(20, 2.0) | **26.7 µs ★** | 690.6 µs (25.9× slower) | 27 482.4 µs (1 030.1× slower) |
|
||||
| ATR(14) | **40.6 µs ★** | 1 120.3 µs (27.6× slower) | 3 760.2 µs (92.7× slower) |
|
||||
| Indicator | **★ Wickra** | finta | talipp |
|
||||
|---------------------|---------------------|-----------------------------|-------------------------------|
|
||||
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
|
||||
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
|
||||
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
|
||||
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
|
||||
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
|
||||
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
|
||||
|
||||
### Streaming — per-tick latency after seeding with 2 000 historical bars
|
||||
### Streaming — per-tick latency after seeding with 5 000 historical bars
|
||||
|
||||
A batch-only library has to re-run its full indicator over the entire history on
|
||||
every new tick; Wickra updates state in O(1).
|
||||
|
||||
| Indicator | Wickra (per tick) | talipp (per tick) |
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|-----------|---------------------|---------------------------|
|
||||
| RSI(14) | **0.07 µs ★** | 1.16 µs (17.5× slower) |
|
||||
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
|
||||
|
||||
> TA-Lib and pandas-ta are not included here because both fail to install
|
||||
> cleanly on Windows without C build tooling — which is precisely the install
|
||||
@@ -92,18 +107,30 @@ pip install -e bindings/python[bench]
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
|
||||
## Indicators in 0.1.0
|
||||
## Indicators
|
||||
|
||||
25 streaming-first indicators across four families. Every one passes the
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|-------------|-----------|
|
||||
| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA |
|
||||
| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon |
|
||||
| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR |
|
||||
| Volume | OBV, VWAP (cumulative + rolling) |
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -113,9 +140,12 @@ inherit it automatically.
|
||||
| Binding | Install | Example |
|
||||
|-------------------|-----------------------------------------------|---------|
|
||||
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `bindings/node/__tests__/smoke.test.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `bindings/wasm/examples/index.html` |
|
||||
| Rust | `cargo add wickra` | `crates/wickra/examples/backtest.rs` |
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
@@ -173,20 +203,26 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 25 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io)
|
||||
│ ├── wickra-core/ core engine + all 214 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
├── examples/
|
||||
│ └── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ (Rust examples live inside their crate at crates/<name>/examples/)
|
||||
├── benches/ cargo bench targets
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
|
||||
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
|
||||
in the workspace member crate at `examples/rust/`. There is no top-level
|
||||
`benches/` directory.
|
||||
|
||||
## Building everything from source
|
||||
|
||||
```bash
|
||||
@@ -207,20 +243,28 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Test counts
|
||||
## Testing
|
||||
|
||||
- `wickra-core`: 171 unit tests + 2 doctests, including textbook-value tests
|
||||
for Wilder RSI, Bollinger Bands, MACD, ATR, and Stochastic.
|
||||
- `wickra-data`: 11 unit tests + 1 doctest, covers CSV decoding, the tick
|
||||
aggregator, the resampler, and the Binance payload parser.
|
||||
- `bindings/python`: 56 pytest tests covering smoke checks, streaming==batch
|
||||
equivalence, reference values, lifecycle, and dict/tuple candle inputs.
|
||||
- `bindings/node`: 7 Node test-runner cases via `node --test`.
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -251,17 +295,27 @@ 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">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
# Roadmap
|
||||
|
||||
What Wickra is heading toward, what is explicitly out of scope, and what
|
||||
contributors can expect across the next 0.x versions. Roadmap items are
|
||||
**aspirations, not commitments** — order may shift based on real
|
||||
user-feedback and bug-priority.
|
||||
|
||||
For "what shipped already" see [`CHANGELOG.md`](CHANGELOG.md). For the
|
||||
internal structure that the roadmap items will plug into, see
|
||||
[`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
## North star
|
||||
|
||||
> A streaming-first technical-analysis core that is the obvious default
|
||||
> for anyone writing a Rust, Python, Node or browser-based trading
|
||||
> system — drop-in fast, drop-in correct, drop-in tested.
|
||||
|
||||
Three measurable proxies for "obvious default":
|
||||
|
||||
1. **Coverage.** Every textbook indicator from TA-Lib, pandas-ta and
|
||||
talipp is in Wickra and produces matching reference values.
|
||||
2. **Performance.** Streaming `update` is the fastest published number
|
||||
across Rust / Python / Node / WASM for any technical indicator.
|
||||
3. **Trust.** Releases are reproducible, signed, SBOM-attached, with a
|
||||
public 100% test/branch coverage badge.
|
||||
|
||||
## 0.3.0 — target window: Q3 2026
|
||||
|
||||
The first release after the org migration to `wickra-lib` lands and the
|
||||
project portal at `wickra.org` is live.
|
||||
|
||||
### Headline goals
|
||||
|
||||
- **WASM has automated tests.** `wasm-bindgen-test` job in CI exercising
|
||||
a representative subset (~30 indicators across all families). Today
|
||||
WASM is only smoke-validated through manual examples.
|
||||
- **Release-pipeline trust.** SBOM (CycloneDX) generated per release,
|
||||
Sigstore cosign signatures attached to every published artifact,
|
||||
npm `--provenance` flag enabled, PyPI Trusted Publishers configured.
|
||||
- **Hosted documentation portal.** `wickra.org` (Cloudflare Pages,
|
||||
VitePress) replaces the GitHub Wiki as the canonical doc surface.
|
||||
Per-indicator deep-dives, quickstarts, FAQ, search.
|
||||
- **End-to-end strategy examples.** 3 runnable examples that wire
|
||||
Wickra indicators into a full mean-reversion / trend-following /
|
||||
breakout strategy with PnL and equity-curve output.
|
||||
- **`ARCHITECTURE.md` + `ROADMAP.md` + `CITATION.cff`** governance
|
||||
baseline (this is it).
|
||||
|
||||
### Stretch goals (might slip to 0.4.0)
|
||||
|
||||
- **Per-binding hosted API reference.** TypeDoc for Node/WASM,
|
||||
Sphinx for Python, both hosted on `wickra.org/api/*`. Rust stays on
|
||||
`docs.rs`.
|
||||
- **Property-tests (`proptest`) for mathematical invariants.** Bound
|
||||
checks on RSI ∈ [0, 100], Bollinger ordering, batch-streaming
|
||||
equivalence on random inputs.
|
||||
- **Nightly long-fuzz workflow.** Each fuzz target gets ~1h overnight,
|
||||
findings auto-converted to issues.
|
||||
|
||||
## 0.4.0 — target window: Q4 2026
|
||||
|
||||
### Indicator catalogue expansion
|
||||
|
||||
The current 214 indicators cover the textbook canon. The next wave is
|
||||
the "stuff people actually ask for but skip because it's painful in
|
||||
other libs":
|
||||
|
||||
- **Anchored indicators.** AnchoredVwap is already in, but anchored
|
||||
variants of common indicators (AnchoredATR, AnchoredVolatility) are
|
||||
on the wishlist for explicit session-based analysis.
|
||||
- **Multi-timeframe (MTF) chaining helpers.** A `Mtf<Indicator>` wrapper
|
||||
that runs an indicator on a different timeframe of the same input
|
||||
stream — solves the most common reason people drop down to ad-hoc
|
||||
buffering code.
|
||||
- **Order-flow primitives** (gated on tick-data ingestion landing in
|
||||
`wickra-data`): CumulativeDelta, BidAskImbalance, VolumeAtPrice.
|
||||
- **Pivot-confirmation patterns.** WilliamsFractals is already in;
|
||||
ZigZag is in. Next: PivotHigh/PivotLow with configurable
|
||||
left/right-bar confirmation, used as a feature for higher-level
|
||||
pattern detectors.
|
||||
- **Harmonic-chart patterns** (Gartley, Bat, Butterfly, Crab, Shark).
|
||||
These need the pivot-detector + ratio-matcher framework first; the
|
||||
candlestick-pattern family from 0.2.8 is the precedent.
|
||||
|
||||
### Live-data layer
|
||||
|
||||
- **Tick-data ingestion.** Extend `wickra-data` from OHLCV-only to
|
||||
also accept raw ticks; the existing `Aggregator` already aggregates
|
||||
ticks into bars but isn't exposed in the public live-feed API yet.
|
||||
- **Generic exchange trait.** `LiveFeed { fn subscribe(...) -> impl
|
||||
Stream<Item = Candle> }` so the Binance adapter is one implementor
|
||||
among many. **Note**: Wickra will not aggregate exchanges itself
|
||||
(use `ccxt` if you need that) — the trait exists so user code can
|
||||
swap feeds without touching indicator code.
|
||||
|
||||
### Performance & reliability
|
||||
|
||||
- **Performance-regression tracking.** `bench.yml` outputs deployed to
|
||||
a `gh-pages` branch, `github-action-benchmark` plot over time,
|
||||
threshold-based alerts on regression.
|
||||
- **Indicator parity test suite.** Every indicator that has a TA-Lib
|
||||
/ pandas-ta / talipp equivalent must pass a golden-value test
|
||||
against that reference. Currently most do but the test names are
|
||||
scattered — consolidate into one `parity_tests` module.
|
||||
|
||||
## 0.5.0 — target window: 2027 H1
|
||||
|
||||
### Possible new bindings
|
||||
|
||||
Open questions, prioritised by demand signals (≈ GitHub stars + issue
|
||||
requests + community polls):
|
||||
|
||||
- **Java / Kotlin** binding via UniFFI — interest from Android-side
|
||||
trading-app developers and the JVM-quant community.
|
||||
- **Go** binding — interest from algorithmic-trading shops running on
|
||||
Linux + Go infra.
|
||||
- **C / C++** header export (`cbindgen`) — drops Wickra into existing
|
||||
C/C++ trading stacks (e.g. older Bloomberg / FIX-protocol shops).
|
||||
- **Swift** — niche but real, iOS / macOS native trading apps.
|
||||
- **.NET** — closes the Windows-native gap that NAPI-Node doesn't fill
|
||||
(some shops are still on .NET Framework).
|
||||
|
||||
None of these are committed — each is a 1-2-month project on its own,
|
||||
and bindings without active maintenance are a liability. Priority will
|
||||
be driven by which language community shows up with PRs.
|
||||
|
||||
### `wickra-data` widening
|
||||
|
||||
- **Historical fetch from > 1 source.** Today only Binance REST/WS.
|
||||
Add Coinbase and Kraken as reference implementations — explicitly
|
||||
not exchange-aggregation, just "here are two more adapters using
|
||||
the trait".
|
||||
|
||||
## Beyond — long-term aspirations
|
||||
|
||||
- **`wickra-backtest` sub-crate** (decision pending). A minimal
|
||||
event-driven backtester wrapping signal generation + position
|
||||
sizing + fees + slippage. Decision factor: do users keep building
|
||||
these ad-hoc from `examples/`? If yes, codifying it saves the
|
||||
ecosystem time. If most users plug Wickra into existing backtesters
|
||||
(vectorbt, backtrader, Lean, Hummingbot), keep Wickra focused.
|
||||
- **`wickra-plot` companion** (low priority). `plotters`-backed Rust
|
||||
rendering of indicator outputs. Mainly useful for static report
|
||||
generation; live charting belongs in the JS/web layer.
|
||||
- **Academic adoption.** Citable `CITATION.cff`; targeting at least
|
||||
one peer-reviewed paper using Wickra as the reference indicator
|
||||
engine.
|
||||
|
||||
## What is explicitly **not** on the roadmap
|
||||
|
||||
These are recurring requests that Wickra will decline so contributors
|
||||
don't waste time on them:
|
||||
|
||||
| Feature | Why declined |
|
||||
|---|---|
|
||||
| Exchange aggregation across N venues | That's `ccxt`'s job. Wickra ships *one* feed implementor (Binance) for tests and demo; users plug their preferred exchange. |
|
||||
| Full backtesting framework (à la Lean, vectorbt) | Scope creep. May land as `wickra-backtest` *if* a clear minimal API surfaces from user demand, but Wickra core stays an indicator library. |
|
||||
| Strategy auto-tuning / hyperparameter search | Wrong abstraction layer — belongs in user-side ML/backtester. |
|
||||
| Order-management / broker integration | Even further out of scope. |
|
||||
| GUI / web-based studio | Marketing-site demos are fine; full IDE is not. |
|
||||
| Indicator implementations that require optional Python deps (e.g. scipy KDE) | Bindings must work on a clean install. Pure-Rust math only. |
|
||||
| Proprietary indicator implementations (e.g. paywalled vendor formulas) | License conflicts + audit burden. |
|
||||
| GPU / CUDA acceleration | O(1) per update means the bottleneck is API overhead, not compute. SIMD-batch is a maybe; GPU adds no value for streaming. |
|
||||
|
||||
## How indicator wishlist requests are handled
|
||||
|
||||
1. **Open an issue** using the `feature_request` template, naming the
|
||||
indicator, citing one of:
|
||||
- TA-Lib reference
|
||||
- pandas-ta reference
|
||||
- peer-reviewed paper
|
||||
- widely-published trading book
|
||||
2. Maintainer triages within ~1 week. Acceptance criteria:
|
||||
- Formula has at least one written reference (no random
|
||||
YouTuber-only indicators)
|
||||
- Implementation can be O(1) streaming
|
||||
- Test vectors are obtainable (from reference lib, hand-computed,
|
||||
or paper-provided)
|
||||
3. Once accepted, the indicator gets added to the next family-batch
|
||||
PR. Family-batches typically ship 5-20 indicators at a time.
|
||||
|
||||
## Versioning
|
||||
|
||||
Wickra follows [SemVer](https://semver.org/). The promise:
|
||||
|
||||
- **0.x.0 (minor):** new indicators, new bindings, new optional features,
|
||||
new optional config knobs. Adding methods to `Indicator` with default
|
||||
impls is minor.
|
||||
- **0.x.y (patch):** bug fixes, performance improvements, doc fixes.
|
||||
- **Major (1.0.0 and later):** changes to the `Indicator` trait
|
||||
signature, removal of indicators, breaking changes to `Candle` /
|
||||
`OHLCV` field order.
|
||||
|
||||
The 1.0 milestone is reserved for when the indicator catalogue is
|
||||
considered "stable enough" and the bindings API has stabilised — likely
|
||||
~2027 once 2-3 more bindings have shipped and stress-tested the trait.
|
||||
|
||||
## Discussion
|
||||
|
||||
For roadmap discussion, open a [Discussions](https://github.com/wickra-lib/wickra/discussions)
|
||||
thread tagged `roadmap`. Specific indicator wishlist items go in
|
||||
[Issues](https://github.com/wickra-lib/wickra/issues) via the
|
||||
`feature_request` template.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
|
||||
version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| 0.1.x (latest) | :white_check_mark: |
|
||||
| older 0.1.x | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue for a security vulnerability.**
|
||||
|
||||
Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||
("Report a vulnerability" under the repository's *Security* tab), or
|
||||
- email to **wickra.lib@gmail.com** with a subject line starting with
|
||||
`[wickra security]`.
|
||||
|
||||
Please include:
|
||||
|
||||
- the affected version(s) and platform / language binding,
|
||||
- a description of the issue and its impact,
|
||||
- steps to reproduce, ideally a minimal proof of concept.
|
||||
|
||||
## What to expect
|
||||
|
||||
- An acknowledgement within **5 working days**.
|
||||
- An assessment and, if confirmed, a planned fix with a target release.
|
||||
- Coordinated disclosure: we will agree on a disclosure date with you and
|
||||
credit you in the release notes unless you prefer to stay anonymous.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the published crates (`wickra-core`, `wickra-data`, `wickra`), the
|
||||
PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
|
||||
|
||||
Out of scope: vulnerabilities in third-party dependencies (report those
|
||||
upstream; we track them via Dependabot and `cargo-deny`).
|
||||
@@ -4,9 +4,11 @@ description = "Node.js bindings for the Wickra streaming-first technical indicat
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
# napi-build emits `cargo::` directives that require Rust >= 1.77; the rest of
|
||||
# the workspace stays at 1.75 because the core crate has no such dependency.
|
||||
rust-version = "1.77"
|
||||
# napi-build 2.3.2 requires Rust >= 1.88 (newer than the workspace 1.80
|
||||
# minimum, which itself was lifted to satisfy rayon-core 1.13.0). napi-build
|
||||
# also emits `cargo::` directives that require >= 1.77 — that older floor is
|
||||
# subsumed by the 1.88 requirement now.
|
||||
rust-version = "1.88"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
+296
-27
@@ -1,45 +1,314 @@
|
||||
# @wickra/wickra
|
||||
# Wickra
|
||||
|
||||
Node.js bindings for the Wickra streaming-first technical indicators library.
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](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/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 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`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
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
```js
|
||||
import { SMA, RSI, MACD, version } from '@wickra/wickra';
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
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/wickra-lib/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/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch
|
||||
// equivalence, reference values, and lifecycle methods across all 71
|
||||
// indicators. Ported from the Python test_streaming_vs_batch / test_known_values
|
||||
// suites.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// Synthetic OHLCV series long enough to warm up every indicator.
|
||||
const N = 120;
|
||||
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.2) * 10 + i * 0.1);
|
||||
const high = close.map((c) => c + 1.5);
|
||||
const low = close.map((c) => c - 1.5);
|
||||
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 7) * 50);
|
||||
const open = close.map((c) => c - 0.5);
|
||||
|
||||
function eq(a, b) {
|
||||
if (Number.isNaN(a)) return Number.isNaN(b);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
return v === null || v === undefined ? NaN : v;
|
||||
}
|
||||
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
SMA: () => new wickra.SMA(14),
|
||||
EMA: () => new wickra.EMA(14),
|
||||
WMA: () => new wickra.WMA(14),
|
||||
RSI: () => new wickra.RSI(14),
|
||||
DEMA: () => new wickra.DEMA(10),
|
||||
TEMA: () => new wickra.TEMA(10),
|
||||
HMA: () => new wickra.HMA(9),
|
||||
ROC: () => new wickra.ROC(12),
|
||||
TRIX: () => new wickra.TRIX(9),
|
||||
KAMA: () => new wickra.KAMA(10, 2, 30),
|
||||
ALMA: () => new wickra.ALMA(9, 0.85, 6.0),
|
||||
McGinleyDynamic: () => new wickra.McGinleyDynamic(10),
|
||||
FRAMA: () => new wickra.FRAMA(16),
|
||||
VIDYA: () => new wickra.VIDYA(14, 9),
|
||||
JMA: () => new wickra.JMA(14, 0, 2),
|
||||
SMMA: () => new wickra.SMMA(14),
|
||||
TRIMA: () => new wickra.TRIMA(20),
|
||||
ZLEMA: () => new wickra.ZLEMA(14),
|
||||
T3: () => new wickra.T3(5, 0.7),
|
||||
MOM: () => new wickra.MOM(10),
|
||||
CMO: () => new wickra.CMO(14),
|
||||
TSI: () => new wickra.TSI(25, 13),
|
||||
PMO: () => new wickra.PMO(35, 20),
|
||||
TII: () => new wickra.TII(20, 10),
|
||||
StochRSI: () => new wickra.StochRSI(14, 14),
|
||||
PPO: () => new wickra.PPO(12, 26),
|
||||
APO: () => new wickra.APO(12, 26),
|
||||
CFO: () => new wickra.CFO(14),
|
||||
ElderImpulse: () => new wickra.ElderImpulse(13, 12, 26, 9),
|
||||
STC: () => new wickra.STC(23, 50, 10, 0.5),
|
||||
DPO: () => new wickra.DPO(20),
|
||||
Coppock: () => new wickra.Coppock(14, 11, 10),
|
||||
StdDev: () => new wickra.StdDev(20),
|
||||
UlcerIndex: () => new wickra.UlcerIndex(14),
|
||||
HistoricalVolatility: () => new wickra.HistoricalVolatility(20, 252),
|
||||
BollingerBandwidth: () => new wickra.BollingerBandwidth(20, 2),
|
||||
PercentB: () => new wickra.PercentB(20, 2),
|
||||
LinearRegression: () => new wickra.LinearRegression(14),
|
||||
LinRegSlope: () => new wickra.LinRegSlope(14),
|
||||
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
|
||||
ZScore: () => new wickra.ZScore(20),
|
||||
LinRegAngle: () => new wickra.LinRegAngle(14),
|
||||
PercentageTrailingStop: () => new wickra.PercentageTrailingStop(5),
|
||||
StepTrailingStop: () => new wickra.StepTrailingStop(1),
|
||||
RenkoTrailingStop: () => new wickra.RenkoTrailingStop(1),
|
||||
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
|
||||
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
|
||||
RVIVolatility: () => new wickra.RVIVolatility(10),
|
||||
// Family 10 — Ehlers / Cycle
|
||||
SuperSmoother: () => new wickra.SuperSmoother(10),
|
||||
FisherTransform: () => new wickra.FisherTransform(10),
|
||||
InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0),
|
||||
Decycler: () => new wickra.Decycler(20),
|
||||
DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30),
|
||||
RoofingFilter: () => new wickra.RoofingFilter(10, 48),
|
||||
CenterOfGravity: () => new wickra.CenterOfGravity(10),
|
||||
CyberneticCycle: () => new wickra.CyberneticCycle(10),
|
||||
InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20),
|
||||
EhlersStochastic: () => new wickra.EhlersStochastic(20),
|
||||
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
|
||||
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
|
||||
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
|
||||
SineWave: () => new wickra.SineWave(),
|
||||
FAMA: () => new wickra.FAMA(0.5, 0.05),
|
||||
// Family 12 — Statistik / Regression
|
||||
Variance: () => new wickra.Variance(20),
|
||||
CoefficientOfVariation: () => new wickra.CoefficientOfVariation(20),
|
||||
Skewness: () => new wickra.Skewness(20),
|
||||
Kurtosis: () => new wickra.Kurtosis(20),
|
||||
StandardError: () => new wickra.StandardError(14),
|
||||
DetrendedStdDev: () => new wickra.DetrendedStdDev(14),
|
||||
RSquared: () => new wickra.RSquared(14),
|
||||
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
|
||||
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
|
||||
HurstExponent: () => new wickra.HurstExponent(40, 4),
|
||||
// Family 15 — Risk / Performance metrics (scalar f64 input).
|
||||
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
|
||||
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
|
||||
CalmarRatio: () => new wickra.CalmarRatio(20),
|
||||
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
|
||||
MaxDrawdown: () => new wickra.MaxDrawdown(20),
|
||||
AverageDrawdown: () => new wickra.AverageDrawdown(20),
|
||||
DrawdownDuration: () => new wickra.DrawdownDuration(),
|
||||
PainIndex: () => new wickra.PainIndex(20),
|
||||
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
|
||||
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
|
||||
ProfitFactor: () => new wickra.ProfitFactor(20),
|
||||
GainLossRatio: () => new wickra.GainLossRatio(20),
|
||||
RecoveryFactor: () => new wickra.RecoveryFactor(),
|
||||
KellyCriterion: () => new wickra.KellyCriterion(20),
|
||||
};
|
||||
|
||||
// --- Two-series (asset, benchmark) ratio indicators ---
|
||||
|
||||
const ratioPairFactories = {
|
||||
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
|
||||
InformationRatio: () => new wickra.InformationRatio(20),
|
||||
Alpha: () => new wickra.Alpha(20, 0),
|
||||
};
|
||||
|
||||
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
|
||||
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
|
||||
|
||||
for (const [name, make] of Object.entries(ratioPairFactories)) {
|
||||
test(`${name}: streaming update matches batch (pair)`, () => {
|
||||
const batch = make().batch(asset, bench);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(asset[i], bench[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, make] of Object.entries(scalarFactories)) {
|
||||
test(`${name}: streaming update matches batch`, () => {
|
||||
const batch = make().batch(close);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(close[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
|
||||
|
||||
const candleScalar = {
|
||||
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
EVWMA: { make: () => new wickra.EVWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
NATR: { make: () => new wickra.NATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MassIndex: { make: () => new wickra.MassIndex(9, 25), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ADL: { make: () => new wickra.ADL(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VolumePriceTrend: { make: () => new wickra.VolumePriceTrend(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
ChaikinMoneyFlow: { make: () => new wickra.ChaikinMoneyFlow(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
KVO: { make: () => new wickra.KVO(34, 55), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) },
|
||||
NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HiLoActivator: { make: () => new wickra.HiLoActivator(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VoltyStop: { make: () => new wickra.VoltyStop(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
YoyoExit: { make: () => new wickra.YoyoExit(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AwesomeOscillatorHistogram: { make: () => new wickra.AwesomeOscillatorHistogram(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ADXR: { make: () => new wickra.ADXR(7), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDSetup: { make: () => new wickra.TDSetup(4, 9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDDeMarker: { make: () => new wickra.TDDeMarker(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
TDREI: { make: () => new wickra.TDREI(5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
TDPressure: { make: () => new wickra.TDPressure(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(open, high, low, close, volume) },
|
||||
TDCombo: { make: () => new wickra.TDCombo(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCountdown: { make: () => new wickra.TDCountdown(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDDifferential: { make: () => new wickra.TDDifferential(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDOpen: { make: () => new wickra.TDOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
// Family 14 — Candlestick patterns
|
||||
Doji: { make: () => new wickra.Doji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Hammer: { make: () => new wickra.Hammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
InvertedHammer: { make: () => new wickra.InvertedHammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HangingMan: { make: () => new wickra.HangingMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ShootingStar: { make: () => new wickra.ShootingStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Engulfing: { make: () => new wickra.Engulfing(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Harami: { make: () => new wickra.Harami(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
MorningEveningStar: { make: () => new wickra.MorningEveningStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeSoldiersOrCrows: { make: () => new wickra.ThreeSoldiersOrCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
PiercingDarkCloud: { make: () => new wickra.PiercingDarkCloud(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Marubozu: { make: () => new wickra.Marubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Tweezer: { make: () => new wickra.Tweezer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
SpinningTop: { make: () => new wickra.SpinningTop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeInside: { make: () => new wickra.ThreeInside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeOutside: { make: () => new wickra.ThreeOutside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
test(`${name}: streaming update matches batch`, () => {
|
||||
const batch = d.batch(d.make());
|
||||
const streaming = d.make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(d.step(streaming, i));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Multi-output indicators: object update vs interleaved batch ---
|
||||
|
||||
const multi = {
|
||||
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Keltner: { make: () => new wickra.Keltner(20, 10, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
RWI: { make: () => new wickra.RWI(14), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WaveTrend: { make: () => wickra.WaveTrend.classic(), fields: ['wt1', 'wt2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
// Family 16: Market Profile
|
||||
ValueArea: { make: () => new wickra.ValueArea(20, 50, 0.70), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
InitialBalance: { make: () => new wickra.InitialBalance(12), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
OpeningRange: { make: () => new wickra.OpeningRange(6), fields: ['high', 'low', 'breakoutDistance'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
// Family 05: bands & channels
|
||||
MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
StarcBands: { make: () => new wickra.StarcBands(6, 15, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
AtrBands: { make: () => new wickra.AtrBands(14, 3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HurstChannel: { make: () => new wickra.HurstChannel(10, 0.5), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
LinRegChannel: { make: () => new wickra.LinRegChannel(20, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
StandardErrorBands: { make: () => new wickra.StandardErrorBands(21, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
DoubleBollinger: { make: () => new wickra.DoubleBollinger(20, 1, 2), fields: ['upperOuter', 'upperInner', 'middle', 'lowerInner', 'lowerOuter'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
TtmSqueeze: { make: () => new wickra.TtmSqueeze(20, 2, 1.5), fields: ['squeeze', 'momentum'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FractalChaosBands: { make: () => new wickra.FractalChaosBands(2), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VwapStdDevBands: { make: () => new wickra.VwapStdDevBands(2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
// Family 08: Pivots & Support/Resistance
|
||||
ClassicPivots: { make: () => new wickra.ClassicPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FibonacciPivots: { make: () => new wickra.FibonacciPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Camarilla: { make: () => new wickra.Camarilla(), fields: ['pp', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
WoodiePivots: { make: () => new wickra.WoodiePivots(), fields: ['pp', 'r1', 'r2', 's1', 's2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
// Family 11: DeMark
|
||||
TDSequential: { make: () => new wickra.TDSequential(4, 9, 2, 13), fields: ['setup', 'countdown', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
// Family 10: Ehlers / Cycle (multi-output)
|
||||
MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
// Family 13: Ichimoku & alternative charts
|
||||
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
test(`${name}: streaming update matches interleaved batch`, () => {
|
||||
const k = d.fields.length;
|
||||
const batch = d.batch(d.make());
|
||||
const streaming = d.make();
|
||||
assert.equal(batch.length, N * k);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const o = d.step(streaming, i);
|
||||
d.fields.forEach((field, j) => {
|
||||
const s = o === null || o === undefined ? NaN : o[field];
|
||||
assert.ok(eq(s, batch[i * k + j]), `${name}.${field} mismatch at ${i}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lifecycle: every indicator exposes reset / isReady / warmupPeriod ---
|
||||
|
||||
test('every indicator exposes reset, isReady and warmupPeriod', () => {
|
||||
const all = [
|
||||
...Object.values(scalarFactories).map((f) => f()),
|
||||
...Object.values(candleScalar).map((d) => d.make()),
|
||||
...Object.values(multi).map((d) => d.make()),
|
||||
];
|
||||
for (const ind of all) {
|
||||
assert.equal(typeof ind.reset, 'function');
|
||||
assert.equal(typeof ind.isReady, 'function');
|
||||
assert.equal(typeof ind.warmupPeriod, 'function');
|
||||
assert.equal(ind.isReady(), false);
|
||||
assert.ok(ind.warmupPeriod() >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('reset returns an indicator to its un-warmed state', () => {
|
||||
const sma = new wickra.SMA(5);
|
||||
sma.batch([1, 2, 3, 4, 5]);
|
||||
assert.equal(sma.isReady(), true);
|
||||
sma.reset();
|
||||
assert.equal(sma.isReady(), false);
|
||||
assert.equal(sma.update(10), null);
|
||||
});
|
||||
|
||||
// --- Reference values ---
|
||||
|
||||
test('SMA(3) reference values', () => {
|
||||
const out = new wickra.SMA(3).batch([2, 4, 6, 8, 10]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.equal(out[2], 4);
|
||||
assert.equal(out[3], 6);
|
||||
assert.equal(out[4], 8);
|
||||
});
|
||||
|
||||
test('MFI(2) reference value equals 1200/23', () => {
|
||||
// Candle 1 seeds; candle 2 (tp 12 > 10) +mf 1200; candle 3 (tp 11 < 12) -mf 1100.
|
||||
const mfi = new wickra.MFI(2);
|
||||
assert.equal(mfi.update(10, 10, 10, 100), null);
|
||||
assert.equal(mfi.update(12, 12, 12, 100), null);
|
||||
const v = mfi.update(11, 11, 11, 100);
|
||||
assert.ok(Math.abs(v - 1200 / 23) < 1e-9);
|
||||
});
|
||||
|
||||
test('RSI pure uptrend yields 100', () => {
|
||||
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
|
||||
const out = new wickra.RSI(14).batch(prices);
|
||||
for (let i = 14; i < out.length; i++) {
|
||||
assert.equal(out[i], 100);
|
||||
}
|
||||
});
|
||||
|
||||
test('MACD histogram equals macd minus signal', () => {
|
||||
const macd = new wickra.MACD(12, 26, 9);
|
||||
let v = null;
|
||||
for (let i = 1; i <= 60; i++) v = macd.update(i);
|
||||
assert.ok(v);
|
||||
assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9);
|
||||
});
|
||||
|
||||
test('TypicalPrice reference value', () => {
|
||||
// (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
|
||||
assert.equal(new wickra.TypicalPrice().update(12, 6, 9), 9);
|
||||
});
|
||||
|
||||
test('ChaikinMoneyFlow(2) reference value equals 0.5', () => {
|
||||
// Bar 1 closes at the high (MFV +100); bar 2 closes mid-range (MFV 0).
|
||||
const cmf = new wickra.ChaikinMoneyFlow(2);
|
||||
assert.equal(cmf.update(10, 8, 10, 100), null);
|
||||
assert.ok(Math.abs(cmf.update(12, 8, 10, 100) - 0.5) < 1e-9);
|
||||
});
|
||||
|
||||
test('LinearRegression(3) reference values', () => {
|
||||
// Least-squares line through [1, 2, 9] is y = 4x; endpoint 4·2 = 8.
|
||||
const out = new wickra.LinearRegression(3).batch([1, 2, 9]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.ok(Math.abs(out[2] - 8) < 1e-9);
|
||||
});
|
||||
|
||||
test('SuperTrend flat market holds the lower band and an uptrend', () => {
|
||||
// Flat candles: ATR 2, hl2 10, lower band 10 - 3·2 = 4.
|
||||
const n = 20;
|
||||
const out = new wickra.SuperTrend(5, 3).batch(
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
Array(n).fill(10),
|
||||
);
|
||||
assert.ok(Math.abs(out[2 * n - 2] - 4) < 1e-9); // value
|
||||
assert.equal(out[2 * n - 1], 1); // direction
|
||||
});
|
||||
|
||||
test('BalanceOfPower reference value', () => {
|
||||
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
||||
assert.ok(Math.abs(new wickra.BalanceOfPower().update(10, 14, 10, 12) - 0.5) < 1e-9);
|
||||
});
|
||||
|
||||
test('TrueRange reference values', () => {
|
||||
const tr = new wickra.TrueRange();
|
||||
assert.equal(tr.update(12, 8, 11), 4); // no prev close -> high - low
|
||||
assert.equal(tr.update(10, 9, 9.5), 2); // prev close 11 -> max(1, 1, 2)
|
||||
});
|
||||
|
||||
test('LinRegAngle of a unit-slope series is 45 degrees', () => {
|
||||
const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]);
|
||||
assert.ok(Math.abs(out[4] - 45) < 1e-9);
|
||||
});
|
||||
|
||||
test('InitialBalance(2) locks after period and ignores subsequent bars', () => {
|
||||
const ib = new wickra.InitialBalance(2);
|
||||
let v = ib.update(102, 100);
|
||||
assert.equal(v.high, 102);
|
||||
assert.equal(v.low, 100);
|
||||
v = ib.update(103, 99);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 99);
|
||||
assert.equal(ib.isLocked(), true);
|
||||
// Extreme bar after lock must not modify the IB.
|
||||
v = ib.update(200, 50);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 99);
|
||||
});
|
||||
|
||||
test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
|
||||
const or = new wickra.OpeningRange(2);
|
||||
or.update(102, 100, 101);
|
||||
or.update(103, 101, 102);
|
||||
// OR locked at high 103 / low 100 / mid 101.5. Close 105 -> +3.5.
|
||||
const v = or.update(110, 102, 105);
|
||||
assert.equal(v.high, 103);
|
||||
assert.equal(v.low, 100);
|
||||
assert.ok(Math.abs(v.breakoutDistance - 3.5) < 1e-9);
|
||||
});
|
||||
|
||||
// --- Family 12: two-series indicators (Pearson / Beta / Spearman) ---
|
||||
|
||||
const pairFactories = {
|
||||
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
||||
Beta: () => new wickra.Beta(14),
|
||||
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
test(`${name}: streaming update matches batch over a pair of series`, () => {
|
||||
const xs = Array.from({ length: N }, (_, i) => Math.sin(i * 0.2) + 0.05 * i);
|
||||
const ys = Array.from({ length: N }, (_, i) => Math.cos(i * 0.3) + 0.02 * i);
|
||||
const batch = make().batch(xs, ys);
|
||||
const streaming = make();
|
||||
assert.equal(batch.length, N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = num(streaming.update(xs[i], ys[i]));
|
||||
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('PearsonCorrelation perfect positive is 1', () => {
|
||||
const x = Array.from({ length: 10 }, (_, i) => i);
|
||||
const y = x.map((v) => 2 * v + 3);
|
||||
const out = new wickra.PearsonCorrelation(5).batch(x, y);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('Beta perfect two-to-one', () => {
|
||||
const bench = Array.from({ length: 10 }, (_, i) => i);
|
||||
const asset = bench.map((v) => 2 * v);
|
||||
const out = new wickra.Beta(5).batch(asset, bench);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('SpearmanCorrelation monotone non-linear is 1', () => {
|
||||
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
||||
const y = x.map((v) => v ** 3);
|
||||
const out = new wickra.SpearmanCorrelation(5).batch(x, y);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('Variance(3) of [2, 4, 6] equals 8/3', () => {
|
||||
const out = new wickra.Variance(3).batch([2, 4, 6]);
|
||||
assert.ok(Math.abs(out[2] - 8 / 3) < 1e-12);
|
||||
});
|
||||
|
||||
test('RSquared on a perfect line is 1', () => {
|
||||
const xs = Array.from({ length: 20 }, (_, i) => 2 * i + 5);
|
||||
const out = new wickra.RSquared(5).batch(xs);
|
||||
for (let i = 5; i < out.length; i++) {
|
||||
assert.ok(Math.abs(out[i] - 1) < 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('MedianAbsoluteDeviation ignores a single huge outlier', () => {
|
||||
const xs = Array(9).fill(5).concat([1000]);
|
||||
const out = new wickra.MedianAbsoluteDeviation(10).batch(xs);
|
||||
assert.ok(Math.abs(out[9]) < 1e-12);
|
||||
});
|
||||
|
||||
test('Autocorrelation of an alternating series is strongly negative at lag 1', () => {
|
||||
const xs = Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? -1 : 1));
|
||||
const out = new wickra.Autocorrelation(10, 1).batch(xs);
|
||||
assert.ok(out[out.length - 1] < -0.5);
|
||||
});
|
||||
|
||||
test('HurstExponent of a monotone ramp is above 0.5', () => {
|
||||
const xs = Array.from({ length: 200 }, (_, i) => i);
|
||||
const out = new wickra.HurstExponent(100, 4).batch(xs);
|
||||
assert.ok(out[out.length - 1] > 0.5);
|
||||
});
|
||||
|
||||
test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => {
|
||||
const ichi = new wickra.Ichimoku(9, 26, 52, 26);
|
||||
assert.equal(ichi.warmupPeriod(), 77);
|
||||
const n = 30;
|
||||
const h = Array.from({ length: n }, (_, i) => 100 + i + 2);
|
||||
const l = Array.from({ length: n }, (_, i) => 100 + i - 2);
|
||||
const c = Array.from({ length: n }, (_, i) => 100 + i + 1);
|
||||
const out = ichi.batch(h, l, c);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
assert.ok(Number.isNaN(out[i * 5]), `tenkan should be NaN at bar ${i}`);
|
||||
}
|
||||
assert.ok(!Number.isNaN(out[8 * 5]), 'tenkan should be defined at bar 9');
|
||||
});
|
||||
|
||||
test('HeikinAshi first bar seeds from real open and close', () => {
|
||||
const ha = new wickra.HeikinAshi();
|
||||
const out = ha.update(10, 12, 9, 11);
|
||||
assert.ok(Math.abs(out.open - (10 + 11) / 2) < 1e-12);
|
||||
assert.ok(Math.abs(out.close - (10 + 12 + 9 + 11) / 4) < 1e-12);
|
||||
});
|
||||
|
||||
test('PercentageTrailingStop seeds and ratchets', () => {
|
||||
const s = new wickra.PercentageTrailingStop(10);
|
||||
assert.ok(Math.abs(s.update(100) - 90) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(110) - 99) < 1e-9);
|
||||
});
|
||||
|
||||
test('RenkoTrailingStop only advances after a full block', () => {
|
||||
const s = new wickra.RenkoTrailingStop(1);
|
||||
assert.ok(Math.abs(s.update(100) - 99) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(100.5) - 99) < 1e-9);
|
||||
assert.ok(Math.abs(s.update(101) - 100) < 1e-9);
|
||||
});
|
||||
|
||||
test('DonchianStop window extremes', () => {
|
||||
const out = new wickra.DonchianStop(5).batch([1, 2, 3, 4, 5], [0, 1, 2, 3, 4]);
|
||||
// [long0, short0, long1, short1, ...]: idx 8 (=4*2) = long_5th, idx 9 = short_5th.
|
||||
assert.ok(Math.abs(out[8] - 0) < 1e-9);
|
||||
assert.ok(Math.abs(out[9] - 5) < 1e-9);
|
||||
});
|
||||
|
||||
test('MaEnvelope reference values', () => {
|
||||
// SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18.
|
||||
const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[3]));
|
||||
assert.ok(Math.abs(out[2 * 3 + 0] - 22) < 1e-9); // upper
|
||||
assert.ok(Math.abs(out[2 * 3 + 1] - 20) < 1e-9); // middle
|
||||
assert.ok(Math.abs(out[2 * 3 + 2] - 18) < 1e-9); // lower
|
||||
});
|
||||
|
||||
test('AccelerationBands single-bar reference', () => {
|
||||
// high=12, low=8, close=10, factor=0.5, period=1.
|
||||
// ratio=0.2, raw_up=13.2, raw_lo=7.2.
|
||||
const v = new wickra.AccelerationBands(1, 0.5).update(12, 8, 10);
|
||||
assert.ok(Math.abs(v.upper - 13.2) < 1e-9);
|
||||
assert.ok(Math.abs(v.middle - 10) < 1e-9);
|
||||
assert.ok(Math.abs(v.lower - 7.2) < 1e-9);
|
||||
});
|
||||
|
||||
test('LinRegChannel reference values for [1, 2, 9]', () => {
|
||||
// Line y=4x, endpoint=8, residuals=[1,-2,1], sigma=sqrt(2).
|
||||
const out = new wickra.LinRegChannel(3, 2).batch([1, 2, 9]);
|
||||
const s = Math.sqrt(2);
|
||||
const i = 2;
|
||||
assert.ok(Math.abs(out[i * 3 + 0] - (8 + 2 * s)) < 1e-9);
|
||||
assert.ok(Math.abs(out[i * 3 + 1] - 8) < 1e-9);
|
||||
assert.ok(Math.abs(out[i * 3 + 2] - (8 - 2 * s)) < 1e-9);
|
||||
});
|
||||
|
||||
test('VwapStdDevBands two-bar reference', () => {
|
||||
const v = new wickra.VwapStdDevBands(1.5);
|
||||
v.update(8, 8, 8, 1);
|
||||
const o = v.update(12, 12, 12, 1);
|
||||
assert.ok(Math.abs(o.upper - 13) < 1e-9);
|
||||
assert.ok(Math.abs(o.middle - 10) < 1e-9);
|
||||
assert.ok(Math.abs(o.lower - 7) < 1e-9);
|
||||
assert.ok(Math.abs(o.stddev - 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('RVIVolatility pure uptrend saturates at 100', () => {
|
||||
const prices = Array.from({ length: 40 }, (_, i) => i + 1);
|
||||
const out = new wickra.RVIVolatility(5).batch(prices);
|
||||
for (let i = 9; i < out.length; i++) {
|
||||
assert.ok(Math.abs(out[i] - 100) < 1e-9, `RVIVolatility[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ParkinsonVolatility zero-range bars yield zero', () => {
|
||||
const n = 30;
|
||||
const h = Array(n).fill(10);
|
||||
const l = Array(n).fill(10);
|
||||
const out = new wickra.ParkinsonVolatility(14, 252).batch(h, l);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `Parkinson[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('GarmanKlassVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.GarmanKlassVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `GK[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('RogersSatchellVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.RogersSatchellVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 13; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `RS[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('YangZhangVolatility zero-movement bars yield zero', () => {
|
||||
const n = 30;
|
||||
const flat = Array(n).fill(10);
|
||||
const out = new wickra.YangZhangVolatility(14, 252).batch(flat, flat, flat, flat);
|
||||
for (let i = 14; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i]) < 1e-12, `YZ[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ZeroLagMACD on a flat series converges to zero', () => {
|
||||
const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42));
|
||||
// Last interleaved row: macd, signal, histogram all 0.
|
||||
const n = 60;
|
||||
assert.ok(Math.abs(out[(n - 1) * 3]) < 1e-12);
|
||||
assert.ok(Math.abs(out[(n - 1) * 3 + 1]) < 1e-12);
|
||||
assert.ok(Math.abs(out[(n - 1) * 3 + 2]) < 1e-12);
|
||||
});
|
||||
|
||||
test('AwesomeOscillatorHistogram on a flat median converges to zero', () => {
|
||||
const n = 50;
|
||||
const out = new wickra.AwesomeOscillatorHistogram(3, 5, 3).batch(
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
);
|
||||
// warmup = 5 + 3 - 1 = 7.
|
||||
for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('STC on a flat series stays at zero', () => {
|
||||
const out = new wickra.STC(3, 5, 4, 0.5).batch(Array(60).fill(42));
|
||||
// Latest values must be exactly zero.
|
||||
for (let i = out.length - 5; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.equal(out[i], 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('ElderImpulse on a flat series stays neutral (0)', () => {
|
||||
const out = new wickra.ElderImpulse(13, 12, 26, 9).batch(Array(120).fill(42));
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.equal(out[i], 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('CFO(5) on a perfectly linear series yields zero', () => {
|
||||
const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2);
|
||||
const out = new wickra.CFO(5).batch(prices);
|
||||
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9);
|
||||
});
|
||||
|
||||
test('APO(3, 5) on a flat series converges to zero', () => {
|
||||
const out = new wickra.APO(3, 5).batch(Array(30).fill(42));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
|
||||
const n = 60;
|
||||
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
|
||||
const out = new wickra.Inertia(3, 4).batch(
|
||||
Array(n).fill(10),
|
||||
Array(n).fill(11),
|
||||
Array(n).fill(9),
|
||||
Array(n).fill(10.5),
|
||||
);
|
||||
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
|
||||
});
|
||||
|
||||
test('ConnorsRSI stays bounded in [0, 100]', () => {
|
||||
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
|
||||
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (Number.isNaN(out[i])) continue;
|
||||
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
|
||||
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
|
||||
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
|
||||
});
|
||||
|
||||
test('SMI with close at range centre emits zero after warmup', () => {
|
||||
const n = 60;
|
||||
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
// warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
||||
});
|
||||
|
||||
test('KST on a flat series emits zero after warmup', () => {
|
||||
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
|
||||
const n = 80;
|
||||
const out = kst.batch(Array(n).fill(42));
|
||||
const warmup = kst.warmupPeriod();
|
||||
for (let i = warmup - 1; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
|
||||
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PGO(5) on a flat close emits zero after warmup', () => {
|
||||
const n = 20;
|
||||
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
|
||||
});
|
||||
|
||||
test('RVI(2) reference value on two bars', () => {
|
||||
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
|
||||
});
|
||||
|
||||
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
|
||||
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
|
||||
assert.ok(Number.isNaN(out[0]));
|
||||
assert.ok(Math.abs(out[1] - 20) < 1e-12);
|
||||
assert.ok(Math.abs(out[2] - 22.5) < 1e-12);
|
||||
});
|
||||
|
||||
test('Alligator on a flat median price seeds to that median', () => {
|
||||
const n = 30;
|
||||
const out = new wickra.Alligator(13, 8, 5).batch(Array(n).fill(11), Array(n).fill(9));
|
||||
// All three SMMAs see median (11 + 9) / 2 = 10 every bar.
|
||||
for (let i = 12; i < n; i++) {
|
||||
assert.ok(Math.abs(out[i * 3] - 10) < 1e-12, `jaw at ${i}: ${out[i * 3]}`);
|
||||
assert.ok(Math.abs(out[i * 3 + 1] - 10) < 1e-12);
|
||||
assert.ok(Math.abs(out[i * 3 + 2] - 10) < 1e-12);
|
||||
}
|
||||
});
|
||||
|
||||
test('JMA on a flat series reproduces the constant', () => {
|
||||
const out = new wickra.JMA(14, 0, 2).batch(Array(30).fill(42));
|
||||
for (let i = 0; i < 30; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
||||
});
|
||||
|
||||
test('VIDYA on a flat series holds the seed', () => {
|
||||
const out = new wickra.VIDYA(14, 4).batch(Array(20).fill(42));
|
||||
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
||||
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
||||
});
|
||||
|
||||
test('FRAMA pure uptrend hugs the latest close', () => {
|
||||
const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05);
|
||||
});
|
||||
|
||||
test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => {
|
||||
// Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8.
|
||||
const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
assert.ok(Math.abs(out[2] - 20) < 1e-12);
|
||||
const expected = 20 + 20 / (0.6 * 3 * 16);
|
||||
assert.ok(Math.abs(out[3] - expected) < 1e-12);
|
||||
});
|
||||
|
||||
test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
|
||||
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]);
|
||||
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
||||
const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5));
|
||||
const s = w[0] + w[1] + w[2];
|
||||
const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s;
|
||||
assert.ok(Math.abs(out[2] - expected) < 1e-12);
|
||||
// The heavy offset toward the newest sample lifts the average above the
|
||||
// simple mean of 20.
|
||||
assert.ok(out[2] > 20);
|
||||
});
|
||||
+520
-40
@@ -1,50 +1,530 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// Platform-aware loader for the Wickra Node native binding.
|
||||
//
|
||||
// In production (after `npm install wickra`) the actual `.node` binary
|
||||
// lives inside a per-platform subpackage that npm installs as an
|
||||
// optional dependency — e.g. `wickra-linux-x64-gnu`. In development
|
||||
// (after `napi build --platform`) the binary sits next to this file.
|
||||
// We try the local path first and fall back to the subpackage so the
|
||||
// same loader works in both modes.
|
||||
|
||||
const { platform, arch } = process;
|
||||
const { join } = require('node:path');
|
||||
const { existsSync } = require('node:fs');
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
const TARGETS = {
|
||||
'linux-x64': 'linux-x64-gnu',
|
||||
'darwin-x64': 'darwin-x64',
|
||||
'darwin-arm64': 'darwin-arm64',
|
||||
'win32-x64': 'win32-x64-msvc',
|
||||
};
|
||||
const { existsSync, readFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
function load() {
|
||||
const key = `${platform}-${arch}`;
|
||||
const target = TARGETS[key];
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`wickra: this platform/architecture combination is not supported (${key}). ` +
|
||||
'Open an issue at https://github.com/kingchenc/wickra/issues with your platform details.'
|
||||
);
|
||||
}
|
||||
const { platform, arch } = process
|
||||
|
||||
const localBinary = join(__dirname, `wickra.${target}.node`);
|
||||
if (existsSync(localBinary)) {
|
||||
return require(localBinary);
|
||||
}
|
||||
let nativeBinding = null
|
||||
let localFileExisted = false
|
||||
let loadError = null
|
||||
|
||||
try {
|
||||
return require(`wickra-${target}`);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`wickra: failed to load the native binding for ${key}. ` +
|
||||
`Expected either ${localBinary} or the wickra-${target} package to be installed. ` +
|
||||
`Run \`npm install\` again, or build from source with \`npm run build\`. ` +
|
||||
`Underlying error: ${err.message}`
|
||||
);
|
||||
function isMusl() {
|
||||
// For Node 10
|
||||
if (!process.report || typeof process.report.getReport !== 'function') {
|
||||
try {
|
||||
const lddPath = require('child_process').execSync('which ldd').toString().trim()
|
||||
return readFileSync(lddPath, 'utf8').includes('musl')
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const { glibcVersionRuntime } = process.report.getReport().header
|
||||
return !glibcVersionRuntime
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = load();
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
switch (arch) {
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.android-arm64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.android-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-android-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.android-arm-eabi.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.android-arm-eabi.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-android-arm-eabi')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Android ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'win32':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-x64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-x64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-x64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'ia32':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-ia32-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-ia32-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-ia32-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-arm64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-arm64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-arm64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Windows: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'darwin':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.darwin-universal.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-universal.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-universal')
|
||||
}
|
||||
break
|
||||
} catch {}
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.darwin-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.darwin-arm64.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on macOS: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'freebsd':
|
||||
if (arch !== 'x64') {
|
||||
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
|
||||
}
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.freebsd-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.freebsd-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-freebsd-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'linux':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-x64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-x64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-x64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-x64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-x64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-x64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm-musleabihf.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm-musleabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm-musleabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm-gnueabihf.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm-gnueabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm-gnueabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'riscv64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-riscv64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-riscv64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-riscv64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-riscv64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-riscv64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-riscv64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 's390x':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-s390x-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-s390x-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-s390x-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Linux: ${arch}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadError) {
|
||||
throw loadError
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, SpearmanCorrelation, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
module.exports.EMA = EMA
|
||||
module.exports.WMA = WMA
|
||||
module.exports.RSI = RSI
|
||||
module.exports.DEMA = DEMA
|
||||
module.exports.TEMA = TEMA
|
||||
module.exports.HMA = HMA
|
||||
module.exports.ROC = ROC
|
||||
module.exports.TRIX = TRIX
|
||||
module.exports.SMMA = SMMA
|
||||
module.exports.TRIMA = TRIMA
|
||||
module.exports.ZLEMA = ZLEMA
|
||||
module.exports.MOM = MOM
|
||||
module.exports.CMO = CMO
|
||||
module.exports.DPO = DPO
|
||||
module.exports.StdDev = StdDev
|
||||
module.exports.UlcerIndex = UlcerIndex
|
||||
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
||||
module.exports.ZScore = ZScore
|
||||
module.exports.MACD = MACD
|
||||
module.exports.BollingerBands = BollingerBands
|
||||
module.exports.ATR = ATR
|
||||
module.exports.Stochastic = Stochastic
|
||||
module.exports.OBV = OBV
|
||||
module.exports.ADX = ADX
|
||||
module.exports.ADXR = ADXR
|
||||
module.exports.CCI = CCI
|
||||
module.exports.WilliamsR = WilliamsR
|
||||
module.exports.MFI = MFI
|
||||
module.exports.PSAR = PSAR
|
||||
module.exports.Keltner = Keltner
|
||||
module.exports.Donchian = Donchian
|
||||
module.exports.VWAP = VWAP
|
||||
module.exports.RollingVWAP = RollingVWAP
|
||||
module.exports.AwesomeOscillator = AwesomeOscillator
|
||||
module.exports.Aroon = Aroon
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.RVI = RVI
|
||||
module.exports.PGO = PGO
|
||||
module.exports.KST = KST
|
||||
module.exports.SMI = SMI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.Inertia = Inertia
|
||||
module.exports.ALMA = ALMA
|
||||
module.exports.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
module.exports.VIDYA = VIDYA
|
||||
module.exports.JMA = JMA
|
||||
module.exports.Alligator = Alligator
|
||||
module.exports.EVWMA = EVWMA
|
||||
module.exports.APO = APO
|
||||
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
||||
module.exports.CFO = CFO
|
||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
||||
module.exports.ElderImpulse = ElderImpulse
|
||||
module.exports.STC = STC
|
||||
module.exports.T3 = T3
|
||||
module.exports.TSI = TSI
|
||||
module.exports.PMO = PMO
|
||||
module.exports.TII = TII
|
||||
module.exports.ADL = ADL
|
||||
module.exports.VolumePriceTrend = VolumePriceTrend
|
||||
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
|
||||
module.exports.ChaikinOscillator = ChaikinOscillator
|
||||
module.exports.ForceIndex = ForceIndex
|
||||
module.exports.EaseOfMovement = EaseOfMovement
|
||||
module.exports.KVO = KVO
|
||||
module.exports.VolumeOscillator = VolumeOscillator
|
||||
module.exports.NVI = NVI
|
||||
module.exports.PVI = PVI
|
||||
module.exports.WilliamsAD = WilliamsAD
|
||||
module.exports.AnchoredVWAP = AnchoredVWAP
|
||||
module.exports.DemandIndex = DemandIndex
|
||||
module.exports.TSV = TSV
|
||||
module.exports.VZO = VZO
|
||||
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
||||
module.exports.SuperTrend = SuperTrend
|
||||
module.exports.ChandelierExit = ChandelierExit
|
||||
module.exports.ChandeKrollStop = ChandeKrollStop
|
||||
module.exports.AtrTrailingStop = AtrTrailingStop
|
||||
module.exports.HiLoActivator = HiLoActivator
|
||||
module.exports.VoltyStop = VoltyStop
|
||||
module.exports.YoyoExit = YoyoExit
|
||||
module.exports.DonchianStop = DonchianStop
|
||||
module.exports.PercentageTrailingStop = PercentageTrailingStop
|
||||
module.exports.StepTrailingStop = StepTrailingStop
|
||||
module.exports.RenkoTrailingStop = RenkoTrailingStop
|
||||
module.exports.TypicalPrice = TypicalPrice
|
||||
module.exports.MedianPrice = MedianPrice
|
||||
module.exports.WeightedClose = WeightedClose
|
||||
module.exports.LinearRegression = LinearRegression
|
||||
module.exports.LinRegSlope = LinRegSlope
|
||||
module.exports.AcceleratorOscillator = AcceleratorOscillator
|
||||
module.exports.BalanceOfPower = BalanceOfPower
|
||||
module.exports.ChoppinessIndex = ChoppinessIndex
|
||||
module.exports.TrueRange = TrueRange
|
||||
module.exports.ChaikinVolatility = ChaikinVolatility
|
||||
module.exports.LinRegAngle = LinRegAngle
|
||||
module.exports.BollingerBandwidth = BollingerBandwidth
|
||||
module.exports.PercentB = PercentB
|
||||
module.exports.NATR = NATR
|
||||
module.exports.HistoricalVolatility = HistoricalVolatility
|
||||
module.exports.AroonOscillator = AroonOscillator
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.RWI = RWI
|
||||
module.exports.WaveTrend = WaveTrend
|
||||
module.exports.MassIndex = MassIndex
|
||||
module.exports.StochRSI = StochRSI
|
||||
module.exports.UltimateOscillator = UltimateOscillator
|
||||
module.exports.PPO = PPO
|
||||
module.exports.Coppock = Coppock
|
||||
module.exports.VWMA = VWMA
|
||||
module.exports.RVIVolatility = RVIVolatility
|
||||
module.exports.ParkinsonVolatility = ParkinsonVolatility
|
||||
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
|
||||
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
|
||||
module.exports.YangZhangVolatility = YangZhangVolatility
|
||||
module.exports.MaEnvelope = MaEnvelope
|
||||
module.exports.AccelerationBands = AccelerationBands
|
||||
module.exports.StarcBands = StarcBands
|
||||
module.exports.AtrBands = AtrBands
|
||||
module.exports.HurstChannel = HurstChannel
|
||||
module.exports.LinRegChannel = LinRegChannel
|
||||
module.exports.StandardErrorBands = StandardErrorBands
|
||||
module.exports.DoubleBollinger = DoubleBollinger
|
||||
module.exports.TtmSqueeze = TtmSqueeze
|
||||
module.exports.FractalChaosBands = FractalChaosBands
|
||||
module.exports.VwapStdDevBands = VwapStdDevBands
|
||||
module.exports.ClassicPivots = ClassicPivots
|
||||
module.exports.FibonacciPivots = FibonacciPivots
|
||||
module.exports.Camarilla = Camarilla
|
||||
module.exports.WoodiePivots = WoodiePivots
|
||||
module.exports.DemarkPivots = DemarkPivots
|
||||
module.exports.WilliamsFractals = WilliamsFractals
|
||||
module.exports.ZigZag = ZigZag
|
||||
module.exports.TDSetup = TDSetup
|
||||
module.exports.TDSequential = TDSequential
|
||||
module.exports.TDDeMarker = TDDeMarker
|
||||
module.exports.TDREI = TDREI
|
||||
module.exports.TDPressure = TDPressure
|
||||
module.exports.TDCombo = TDCombo
|
||||
module.exports.TDCountdown = TDCountdown
|
||||
module.exports.TDLines = TDLines
|
||||
module.exports.TDRangeProjection = TDRangeProjection
|
||||
module.exports.TDDifferential = TDDifferential
|
||||
module.exports.TDOpen = TDOpen
|
||||
module.exports.TDRiskLevel = TDRiskLevel
|
||||
module.exports.SuperSmoother = SuperSmoother
|
||||
module.exports.FisherTransform = FisherTransform
|
||||
module.exports.InverseFisherTransform = InverseFisherTransform
|
||||
module.exports.Decycler = Decycler
|
||||
module.exports.DecyclerOscillator = DecyclerOscillator
|
||||
module.exports.RoofingFilter = RoofingFilter
|
||||
module.exports.CenterOfGravity = CenterOfGravity
|
||||
module.exports.CyberneticCycle = CyberneticCycle
|
||||
module.exports.InstantaneousTrendline = InstantaneousTrendline
|
||||
module.exports.EhlersStochastic = EhlersStochastic
|
||||
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
|
||||
module.exports.HilbertDominantCycle = HilbertDominantCycle
|
||||
module.exports.AdaptiveCycle = AdaptiveCycle
|
||||
module.exports.SineWave = SineWave
|
||||
module.exports.MAMA = MAMA
|
||||
module.exports.FAMA = FAMA
|
||||
module.exports.Ichimoku = Ichimoku
|
||||
module.exports.HeikinAshi = HeikinAshi
|
||||
module.exports.Variance = Variance
|
||||
module.exports.CoefficientOfVariation = CoefficientOfVariation
|
||||
module.exports.Skewness = Skewness
|
||||
module.exports.Kurtosis = Kurtosis
|
||||
module.exports.StandardError = StandardError
|
||||
module.exports.DetrendedStdDev = DetrendedStdDev
|
||||
module.exports.RSquared = RSquared
|
||||
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
|
||||
module.exports.Autocorrelation = Autocorrelation
|
||||
module.exports.HurstExponent = HurstExponent
|
||||
module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.ValueArea = ValueArea
|
||||
module.exports.InitialBalance = InitialBalance
|
||||
module.exports.OpeningRange = OpeningRange
|
||||
module.exports.Doji = Doji
|
||||
module.exports.Hammer = Hammer
|
||||
module.exports.InvertedHammer = InvertedHammer
|
||||
module.exports.HangingMan = HangingMan
|
||||
module.exports.ShootingStar = ShootingStar
|
||||
module.exports.Engulfing = Engulfing
|
||||
module.exports.Harami = Harami
|
||||
module.exports.MorningEveningStar = MorningEveningStar
|
||||
module.exports.ThreeSoldiersOrCrows = ThreeSoldiersOrCrows
|
||||
module.exports.PiercingDarkCloud = PiercingDarkCloud
|
||||
module.exports.Marubozu = Marubozu
|
||||
module.exports.Tweezer = Tweezer
|
||||
module.exports.SpinningTop = SpinningTop
|
||||
module.exports.ThreeInside = ThreeInside
|
||||
module.exports.ThreeOutside = ThreeOutside
|
||||
// Family 15: Risk / Performance metrics
|
||||
module.exports.SharpeRatio = SharpeRatio
|
||||
module.exports.SortinoRatio = SortinoRatio
|
||||
module.exports.CalmarRatio = CalmarRatio
|
||||
module.exports.OmegaRatio = OmegaRatio
|
||||
module.exports.MaxDrawdown = MaxDrawdown
|
||||
module.exports.AverageDrawdown = AverageDrawdown
|
||||
module.exports.DrawdownDuration = DrawdownDuration
|
||||
module.exports.PainIndex = PainIndex
|
||||
module.exports.ValueAtRisk = ValueAtRisk
|
||||
module.exports.ConditionalValueAtRisk = ConditionalValueAtRisk
|
||||
module.exports.ProfitFactor = ProfitFactor
|
||||
module.exports.GainLossRatio = GainLossRatio
|
||||
module.exports.RecoveryFactor = RecoveryFactor
|
||||
module.exports.KellyCriterion = KellyCriterion
|
||||
module.exports.TreynorRatio = TreynorRatio
|
||||
module.exports.InformationRatio = InformationRatio
|
||||
module.exports.Alpha = Alpha
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
"wickra.darwin-arm64.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
"wickra.darwin-x64.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-arm64-msvc.node"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra"
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.3.0",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
"version": "2.18.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
|
||||
"integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"napi": "scripts/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.3.0.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.3.0.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.3.0.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.3.0.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.3.0.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.3.0.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-13
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <kingchencp@gmail.com>",
|
||||
"author": "kingchenc <wickra.lib@gmail.com>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"keywords": [
|
||||
"trading",
|
||||
"indicators",
|
||||
@@ -17,12 +17,12 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kingchenc/wickra/issues"
|
||||
"url": "https://github.com/wickra-lib/wickra/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra",
|
||||
"homepage": "https://github.com/wickra-lib/wickra",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
@@ -35,26 +35,29 @@
|
||||
"defaults": false,
|
||||
"additional": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc"
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc"
|
||||
]
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.1.2",
|
||||
"wickra-darwin-x64": "0.1.2",
|
||||
"wickra-darwin-arm64": "0.1.2",
|
||||
"wickra-win32-x64-msvc": "0.1.2"
|
||||
"wickra-linux-x64-gnu": "0.3.0",
|
||||
"wickra-linux-arm64-gnu": "0.3.0",
|
||||
"wickra-darwin-x64": "0.3.0",
|
||||
"wickra-darwin-arm64": "0.3.0",
|
||||
"wickra-win32-x64-msvc": "0.3.0",
|
||||
"wickra-win32-arm64-msvc": "0.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
"build:debug": "napi build --platform",
|
||||
"artifacts": "napi artifacts",
|
||||
"prepublishOnly": "napi prepublish -t npm",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
|
||||
+8673
-276
File diff suppressed because it is too large
Load Diff
+293
-33
@@ -1,54 +1,314 @@
|
||||
# Wickra — Python bindings
|
||||
# Wickra
|
||||
|
||||
Streaming-first technical indicators powered by a Rust core.
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](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
|
||||
|
||||
25 streaming-first indicators across four 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:
|
||||
|
||||
- **Trend** — SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA
|
||||
- **Momentum** — RSI (Wilder), MACD, Stochastic, CCI, ROC, WilliamsR, ADX,
|
||||
MFI, TRIX, AwesomeOscillator, Aroon
|
||||
- **Volatility** — BollingerBands, ATR, Keltner, Donchian, PSAR
|
||||
- **Volume** — OBV, VWAP
|
||||
| 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 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`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
|
||||
|
||||
214 streaming-first indicators across sixteen families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
|
||||
| Family | Indicators |
|
||||
|--------|-----------|
|
||||
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
|
||||
## 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/wickra-lib/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/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
|
||||
@@ -4,10 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
authors = [{ name = "kingchenc", email = "wickra.lib@gmail.com" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
@@ -20,6 +21,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Rust",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
"Topic :: Scientific/Engineering :: Mathematics",
|
||||
@@ -45,9 +47,9 @@ bench = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kingchenc/wickra"
|
||||
Repository = "https://github.com/kingchenc/wickra"
|
||||
Issues = "https://github.com/kingchenc/wickra/issues"
|
||||
Homepage = "https://github.com/wickra-lib/wickra"
|
||||
Repository = "https://github.com/wickra-lib/wickra"
|
||||
Issues = "https://github.com/wickra-lib/wickra/issues"
|
||||
|
||||
[tool.maturin]
|
||||
manifest-path = "Cargo.toml"
|
||||
|
||||
@@ -25,58 +25,464 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
ADX,
|
||||
ATR,
|
||||
Aroon,
|
||||
AwesomeOscillator,
|
||||
BollingerBands,
|
||||
CCI,
|
||||
DEMA,
|
||||
Donchian,
|
||||
# Trend
|
||||
SMA,
|
||||
EMA,
|
||||
WMA,
|
||||
DEMA,
|
||||
TEMA,
|
||||
HMA,
|
||||
KAMA,
|
||||
Keltner,
|
||||
MACD,
|
||||
MFI,
|
||||
OBV,
|
||||
PSAR,
|
||||
ROC,
|
||||
SMMA,
|
||||
TRIMA,
|
||||
ZLEMA,
|
||||
T3,
|
||||
VWMA,
|
||||
ALMA,
|
||||
McGinleyDynamic,
|
||||
FRAMA,
|
||||
VIDYA,
|
||||
JMA,
|
||||
Alligator,
|
||||
EVWMA,
|
||||
# Momentum
|
||||
RSI,
|
||||
SMA,
|
||||
MACD,
|
||||
Stochastic,
|
||||
TEMA,
|
||||
TRIX,
|
||||
VWAP,
|
||||
CCI,
|
||||
ROC,
|
||||
WilliamsR,
|
||||
WMA,
|
||||
ADX,
|
||||
ADXR,
|
||||
MFI,
|
||||
TRIX,
|
||||
AwesomeOscillator,
|
||||
Aroon,
|
||||
MOM,
|
||||
CMO,
|
||||
TSI,
|
||||
PMO,
|
||||
TII,
|
||||
KST,
|
||||
StochRSI,
|
||||
UltimateOscillator,
|
||||
RVI,
|
||||
PGO,
|
||||
KST,
|
||||
SMI,
|
||||
LaguerreRSI,
|
||||
ConnorsRSI,
|
||||
Inertia,
|
||||
APO,
|
||||
AwesomeOscillatorHistogram,
|
||||
CFO,
|
||||
ZeroLagMACD,
|
||||
ElderImpulse,
|
||||
STC,
|
||||
PPO,
|
||||
DPO,
|
||||
Coppock,
|
||||
AroonOscillator,
|
||||
Vortex,
|
||||
RWI,
|
||||
WaveTrend,
|
||||
MassIndex,
|
||||
AcceleratorOscillator,
|
||||
BalanceOfPower,
|
||||
ChoppinessIndex,
|
||||
VerticalHorizontalFilter,
|
||||
# Volatility
|
||||
BollingerBands,
|
||||
ATR,
|
||||
Keltner,
|
||||
Donchian,
|
||||
PSAR,
|
||||
NATR,
|
||||
StdDev,
|
||||
UlcerIndex,
|
||||
HistoricalVolatility,
|
||||
BollingerBandwidth,
|
||||
PercentB,
|
||||
SuperTrend,
|
||||
ChandelierExit,
|
||||
ChandeKrollStop,
|
||||
AtrTrailingStop,
|
||||
HiLoActivator,
|
||||
VoltyStop,
|
||||
YoyoExit,
|
||||
DonchianStop,
|
||||
PercentageTrailingStop,
|
||||
StepTrailingStop,
|
||||
RenkoTrailingStop,
|
||||
TrueRange,
|
||||
ChaikinVolatility,
|
||||
RVIVolatility,
|
||||
ParkinsonVolatility,
|
||||
GarmanKlassVolatility,
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
ADL,
|
||||
VolumePriceTrend,
|
||||
ChaikinMoneyFlow,
|
||||
ChaikinOscillator,
|
||||
ForceIndex,
|
||||
KVO,
|
||||
VolumeOscillator,
|
||||
NVI,
|
||||
PVI,
|
||||
WilliamsAD,
|
||||
AnchoredVWAP,
|
||||
DemandIndex,
|
||||
TSV,
|
||||
VZO,
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
TypicalPrice,
|
||||
MedianPrice,
|
||||
WeightedClose,
|
||||
LinearRegression,
|
||||
LinRegSlope,
|
||||
ZScore,
|
||||
LinRegAngle,
|
||||
Variance,
|
||||
CoefficientOfVariation,
|
||||
Skewness,
|
||||
Kurtosis,
|
||||
StandardError,
|
||||
DetrendedStdDev,
|
||||
RSquared,
|
||||
Autocorrelation,
|
||||
MedianAbsoluteDeviation,
|
||||
HurstExponent,
|
||||
PearsonCorrelation,
|
||||
Beta,
|
||||
SpearmanCorrelation,
|
||||
# Ehlers / Cycle
|
||||
SuperSmoother,
|
||||
FisherTransform,
|
||||
InverseFisherTransform,
|
||||
Decycler,
|
||||
DecyclerOscillator,
|
||||
RoofingFilter,
|
||||
CenterOfGravity,
|
||||
CyberneticCycle,
|
||||
InstantaneousTrendline,
|
||||
EhlersStochastic,
|
||||
EmpiricalModeDecomposition,
|
||||
HilbertDominantCycle,
|
||||
AdaptiveCycle,
|
||||
SineWave,
|
||||
MAMA,
|
||||
FAMA,
|
||||
# Bands & Channels
|
||||
MaEnvelope,
|
||||
AccelerationBands,
|
||||
StarcBands,
|
||||
AtrBands,
|
||||
HurstChannel,
|
||||
LinRegChannel,
|
||||
StandardErrorBands,
|
||||
DoubleBollinger,
|
||||
TtmSqueeze,
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
WoodiePivots,
|
||||
DemarkPivots,
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
TDREI,
|
||||
TDPressure,
|
||||
TDCombo,
|
||||
TDCountdown,
|
||||
TDLines,
|
||||
TDRangeProjection,
|
||||
TDDifferential,
|
||||
TDOpen,
|
||||
TDRiskLevel,
|
||||
# Ichimoku & alternative charts
|
||||
Ichimoku,
|
||||
HeikinAshi,
|
||||
# Market Profile
|
||||
ValueArea,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Candlestick patterns
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
HangingMan,
|
||||
ShootingStar,
|
||||
Engulfing,
|
||||
Harami,
|
||||
MorningEveningStar,
|
||||
ThreeSoldiersOrCrows,
|
||||
PiercingDarkCloud,
|
||||
Marubozu,
|
||||
Tweezer,
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
CalmarRatio,
|
||||
OmegaRatio,
|
||||
MaxDrawdown,
|
||||
AverageDrawdown,
|
||||
DrawdownDuration,
|
||||
PainIndex,
|
||||
ValueAtRisk,
|
||||
ConditionalValueAtRisk,
|
||||
ProfitFactor,
|
||||
GainLossRatio,
|
||||
RecoveryFactor,
|
||||
KellyCriterion,
|
||||
TreynorRatio,
|
||||
InformationRatio,
|
||||
Alpha,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
# Trend
|
||||
"SMA",
|
||||
"EMA",
|
||||
"WMA",
|
||||
"RSI",
|
||||
"MACD",
|
||||
"BollingerBands",
|
||||
"ATR",
|
||||
"Stochastic",
|
||||
"OBV",
|
||||
"DEMA",
|
||||
"TEMA",
|
||||
"HMA",
|
||||
"KAMA",
|
||||
"SMMA",
|
||||
"TRIMA",
|
||||
"ZLEMA",
|
||||
"T3",
|
||||
"VWMA",
|
||||
"ALMA",
|
||||
"McGinleyDynamic",
|
||||
"FRAMA",
|
||||
"VIDYA",
|
||||
"JMA",
|
||||
"Alligator",
|
||||
"EVWMA",
|
||||
# Momentum
|
||||
"RSI",
|
||||
"MACD",
|
||||
"Stochastic",
|
||||
"CCI",
|
||||
"ROC",
|
||||
"WilliamsR",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"MFI",
|
||||
"TRIX",
|
||||
"PSAR",
|
||||
"Keltner",
|
||||
"Donchian",
|
||||
"VWAP",
|
||||
"AwesomeOscillator",
|
||||
"Aroon",
|
||||
"MOM",
|
||||
"CMO",
|
||||
"TSI",
|
||||
"PMO",
|
||||
"TII",
|
||||
"KST",
|
||||
"StochRSI",
|
||||
"UltimateOscillator",
|
||||
"RVI",
|
||||
"PGO",
|
||||
"KST",
|
||||
"SMI",
|
||||
"LaguerreRSI",
|
||||
"ConnorsRSI",
|
||||
"Inertia",
|
||||
"APO",
|
||||
"AwesomeOscillatorHistogram",
|
||||
"CFO",
|
||||
"ZeroLagMACD",
|
||||
"ElderImpulse",
|
||||
"STC",
|
||||
"PPO",
|
||||
"DPO",
|
||||
"Coppock",
|
||||
"AroonOscillator",
|
||||
"Vortex",
|
||||
"RWI",
|
||||
"WaveTrend",
|
||||
"MassIndex",
|
||||
"AcceleratorOscillator",
|
||||
"BalanceOfPower",
|
||||
"ChoppinessIndex",
|
||||
"VerticalHorizontalFilter",
|
||||
# Volatility
|
||||
"BollingerBands",
|
||||
"ATR",
|
||||
"Keltner",
|
||||
"Donchian",
|
||||
"PSAR",
|
||||
"NATR",
|
||||
"StdDev",
|
||||
"UlcerIndex",
|
||||
"HistoricalVolatility",
|
||||
"BollingerBandwidth",
|
||||
"PercentB",
|
||||
"SuperTrend",
|
||||
"ChandelierExit",
|
||||
"ChandeKrollStop",
|
||||
"AtrTrailingStop",
|
||||
"HiLoActivator",
|
||||
"VoltyStop",
|
||||
"YoyoExit",
|
||||
"DonchianStop",
|
||||
"PercentageTrailingStop",
|
||||
"StepTrailingStop",
|
||||
"RenkoTrailingStop",
|
||||
"TrueRange",
|
||||
"ChaikinVolatility",
|
||||
"RVIVolatility",
|
||||
"ParkinsonVolatility",
|
||||
"GarmanKlassVolatility",
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
"ADL",
|
||||
"VolumePriceTrend",
|
||||
"ChaikinMoneyFlow",
|
||||
"ChaikinOscillator",
|
||||
"ForceIndex",
|
||||
"KVO",
|
||||
"VolumeOscillator",
|
||||
"NVI",
|
||||
"PVI",
|
||||
"WilliamsAD",
|
||||
"AnchoredVWAP",
|
||||
"DemandIndex",
|
||||
"TSV",
|
||||
"VZO",
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"TypicalPrice",
|
||||
"MedianPrice",
|
||||
"WeightedClose",
|
||||
"LinearRegression",
|
||||
"LinRegSlope",
|
||||
"ZScore",
|
||||
"LinRegAngle",
|
||||
"Variance",
|
||||
"CoefficientOfVariation",
|
||||
"Skewness",
|
||||
"Kurtosis",
|
||||
"StandardError",
|
||||
"DetrendedStdDev",
|
||||
"RSquared",
|
||||
"Autocorrelation",
|
||||
"MedianAbsoluteDeviation",
|
||||
"HurstExponent",
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"SpearmanCorrelation",
|
||||
# Ehlers / Cycle
|
||||
"SuperSmoother",
|
||||
"FisherTransform",
|
||||
"InverseFisherTransform",
|
||||
"Decycler",
|
||||
"DecyclerOscillator",
|
||||
"RoofingFilter",
|
||||
"CenterOfGravity",
|
||||
"CyberneticCycle",
|
||||
"InstantaneousTrendline",
|
||||
"EhlersStochastic",
|
||||
"EmpiricalModeDecomposition",
|
||||
"HilbertDominantCycle",
|
||||
"AdaptiveCycle",
|
||||
"SineWave",
|
||||
"MAMA",
|
||||
"FAMA",
|
||||
# Bands & Channels
|
||||
"MaEnvelope",
|
||||
"AccelerationBands",
|
||||
"StarcBands",
|
||||
"AtrBands",
|
||||
"HurstChannel",
|
||||
"LinRegChannel",
|
||||
"StandardErrorBands",
|
||||
"DoubleBollinger",
|
||||
"TtmSqueeze",
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
"WoodiePivots",
|
||||
"DemarkPivots",
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
"TDREI",
|
||||
"TDPressure",
|
||||
"TDCombo",
|
||||
"TDCountdown",
|
||||
"TDLines",
|
||||
"TDRangeProjection",
|
||||
"TDDifferential",
|
||||
"TDOpen",
|
||||
"TDRiskLevel",
|
||||
# Ichimoku & alternative charts
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
# Market Profile
|
||||
"ValueArea",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Candlestick patterns
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
"HangingMan",
|
||||
"ShootingStar",
|
||||
"Engulfing",
|
||||
"Harami",
|
||||
"MorningEveningStar",
|
||||
"ThreeSoldiersOrCrows",
|
||||
"PiercingDarkCloud",
|
||||
"Marubozu",
|
||||
"Tweezer",
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
"CalmarRatio",
|
||||
"OmegaRatio",
|
||||
"MaxDrawdown",
|
||||
"AverageDrawdown",
|
||||
"DrawdownDuration",
|
||||
"PainIndex",
|
||||
"ValueAtRisk",
|
||||
"ConditionalValueAtRisk",
|
||||
"ProfitFactor",
|
||||
"GainLossRatio",
|
||||
"RecoveryFactor",
|
||||
"KellyCriterion",
|
||||
"TreynorRatio",
|
||||
"InformationRatio",
|
||||
"Alpha",
|
||||
]
|
||||
|
||||
@@ -52,6 +52,642 @@ class WMA:
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class SMMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class TRIMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ADL:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class VolumePriceTrend:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ChaikinMoneyFlow:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class ChaikinOscillator:
|
||||
def __init__(self, fast: int = 3, slow: int = 10) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
|
||||
class ForceIndex:
|
||||
def __init__(self, period: int = 13) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class EaseOfMovement:
|
||||
def __init__(self, period: int = 14, divisor: float = 100000000.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def divisor(self) -> float: ...
|
||||
|
||||
class SuperTrend:
|
||||
def __init__(self, atr_period: int = 10, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[value, direction]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class ChandelierExit:
|
||||
def __init__(self, period: int = 22, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[long_stop, short_stop]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class ChandeKrollStop:
|
||||
def __init__(
|
||||
self, atr_period: int = 10, atr_multiplier: float = 1.0, stop_period: int = 9
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[stop_long, stop_short]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float, int]: ...
|
||||
|
||||
class AtrTrailingStop:
|
||||
def __init__(self, atr_period: int = 14, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, float]: ...
|
||||
|
||||
class TypicalPrice:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class MedianPrice:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class WeightedClose:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class LinearRegression:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class LinRegSlope:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class AcceleratorOscillator:
|
||||
def __init__(
|
||||
self, ao_fast: int = 5, ao_slow: int = 34, signal_period: int = 5
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def params(self) -> Tuple[int, int, int]: ...
|
||||
|
||||
class BalanceOfPower:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
open: NDArray[np.float64],
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ChoppinessIndex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class VerticalHorizontalFilter:
|
||||
def __init__(self, period: int = 28) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class TrueRange:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ChaikinVolatility:
|
||||
def __init__(self, ema_period: int = 10, roc_period: int = 10) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
|
||||
class ZScore:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class LinRegAngle:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class BollingerBandwidth:
|
||||
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def multiplier(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PercentB:
|
||||
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def multiplier(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class NATR:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class StdDev:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class UlcerIndex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class HistoricalVolatility:
|
||||
def __init__(self, period: int = 20, trading_periods: int = 252) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class AroonOscillator:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class Vortex:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[plus, minus]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class MassIndex:
|
||||
def __init__(self, ema_period: int = 9, sum_period: int = 25) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PPO:
|
||||
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class DPO:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def shift(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class Coppock:
|
||||
def __init__(
|
||||
self, roc_long: int = 14, roc_short: int = 11, wma_period: int = 10
|
||||
) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class StochRSI:
|
||||
def __init__(self, rsi_period: int = 14, stoch_period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class UltimateOscillator:
|
||||
def __init__(self, short: int = 7, mid: int = 14, long: int = 28) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class MOM:
|
||||
def __init__(self, period: int = 10) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class CMO:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class TSI:
|
||||
def __init__(self, long: int = 25, short: int = 13) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class PMO:
|
||||
def __init__(self, smoothing1: int = 35, smoothing2: int = 20) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def periods(self) -> Tuple[int, int]: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class ZLEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def lag(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class T3:
|
||||
def __init__(self, period: int, v: float = 0.7) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def volume_factor(self) -> float: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class VWMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class RSI:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
@@ -135,3 +771,218 @@ class OBV:
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def value(self) -> Optional[float]: ...
|
||||
|
||||
class DEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class TEMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class HMA:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class KAMA:
|
||||
def __init__(self, er_period: int = 10, fast: int = 2, slow: int = 30) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class CCI:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class ROC:
|
||||
def __init__(self, period: int = 10) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
|
||||
class WilliamsR:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class ADX:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[plus_di, minus_di, adx]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class MFI:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class TRIX:
|
||||
def __init__(self, period: int = 30) -> None: ...
|
||||
def update(self, value: float) -> Optional[float]: ...
|
||||
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class PSAR:
|
||||
def __init__(
|
||||
self, af_start: float = 0.02, af_step: float = 0.02, af_max: float = 0.20
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Keltner:
|
||||
def __init__(
|
||||
self, ema_period: int = 20, atr_period: int = 10, multiplier: float = 2.0
|
||||
) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Donchian:
|
||||
def __init__(self, period: int = 20) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 3)`` with columns ``[upper, middle, lower]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class VWAP:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class RollingVWAP:
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
close: NDArray[np.float64],
|
||||
volume: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
@property
|
||||
def period(self) -> int: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class AwesomeOscillator:
|
||||
def __init__(self, fast: int = 5, slow: int = 34) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[float]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]: ...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
class Aroon:
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||
def batch(
|
||||
self,
|
||||
high: NDArray[np.float64],
|
||||
low: NDArray[np.float64],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Returns shape ``(n, 2)`` with columns ``[up, down]``."""
|
||||
...
|
||||
def reset(self) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
def warmup_period(self) -> int: ...
|
||||
|
||||
+10982
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
"""Input-validation tests: malformed NumPy inputs raise ValueError, not panics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wickra as ta
|
||||
|
||||
|
||||
def test_non_contiguous_array_raises_value_error():
|
||||
# A strided view is not C-contiguous; batch() must reject it cleanly.
|
||||
base = np.linspace(1.0, 100.0, 60)
|
||||
non_contiguous = base[::2]
|
||||
assert not non_contiguous.flags["C_CONTIGUOUS"]
|
||||
with pytest.raises(ValueError):
|
||||
ta.SMA(5).batch(non_contiguous)
|
||||
|
||||
|
||||
def test_ascontiguousarray_recovers():
|
||||
base = np.linspace(1.0, 100.0, 60)
|
||||
fixed = np.ascontiguousarray(base[::2])
|
||||
out = ta.SMA(5).batch(fixed)
|
||||
assert out.shape == fixed.shape
|
||||
|
||||
|
||||
def test_unequal_length_candle_batch_raises(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
short = low[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.ATR(14).batch(high, short, close)
|
||||
with pytest.raises(ValueError):
|
||||
ta.WilliamsR(14).batch(high, short, close)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Aroon(14).batch(high, short)
|
||||
|
||||
|
||||
def test_roc_and_trix_have_default_periods():
|
||||
# ROC/TRIX gained constructor defaults matching the TA-Lib convention.
|
||||
assert ta.ROC().period == 10
|
||||
assert ta.TRIX() is not None
|
||||
|
||||
|
||||
def test_value_area_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(0, 50, 0.7)
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 0, 0.7)
|
||||
|
||||
|
||||
def test_value_area_rejects_invalid_pct():
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 50, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(20, 50, 1.5)
|
||||
|
||||
|
||||
def test_initial_balance_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.InitialBalance(0)
|
||||
|
||||
|
||||
def test_opening_range_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OpeningRange(0)
|
||||
|
||||
|
||||
def test_value_area_unequal_length_raises():
|
||||
high = np.array([1.0, 2.0, 3.0])
|
||||
low = np.array([0.5, 1.5])
|
||||
volume = np.array([10.0, 10.0, 10.0])
|
||||
with pytest.raises(ValueError):
|
||||
ta.ValueArea(2, 10, 0.7).batch(high, low, volume)
|
||||
|
||||
|
||||
def test_ichimoku_rejects_zero_and_non_increasing_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(0, 26, 52, 26)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(9, 26, 52, 0)
|
||||
# Periods must satisfy tenkan < kijun < senkou_b.
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(26, 9, 52, 26)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Ichimoku(9, 52, 52, 26)
|
||||
|
||||
|
||||
def test_family_10_ehlers_rejects_invalid_parameters():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SuperSmoother(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.FisherTransform(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.InverseFisherTransform(0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.DecyclerOscillator(30, 10)
|
||||
with pytest.raises(ValueError):
|
||||
ta.RoofingFilter(48, 10)
|
||||
with pytest.raises(ValueError):
|
||||
ta.MAMA(0.05, 0.5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.EmpiricalModeDecomposition(20, 0.0)
|
||||
@@ -66,6 +66,224 @@ def test_rsi_wilder_textbook_first_value():
|
||||
assert math.isclose(out[14], 70.464, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_inertia_constant_rvi_passes_through_linreg():
|
||||
# Every bar identical (open, high, low, close) = (10, 11, 9, 10.5):
|
||||
# RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant
|
||||
# series equals that constant after warmup.
|
||||
n = 60
|
||||
out = ta.Inertia(3, 4).batch(
|
||||
np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5)
|
||||
)
|
||||
# warmup_period = 3 + 4 - 1 = 6.
|
||||
np.testing.assert_allclose(out[5:], 0.25, atol=1e-12)
|
||||
|
||||
|
||||
def test_connors_rsi_output_is_bounded():
|
||||
# CRSI is the average of three [0, 100] components, so the aggregate must
|
||||
# also sit in [0, 100] after warmup.
|
||||
prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250))
|
||||
out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
assert ready.min() >= 0.0
|
||||
assert ready.max() <= 100.0
|
||||
|
||||
|
||||
def test_laguerre_rsi_constant_series_stays_at_mid_band():
|
||||
# All four Laguerre stages seed to the first input, so subsequent flat
|
||||
# inputs keep them equal and the up/down accumulator is 0 — Wickra maps
|
||||
# that to the neutral 50.
|
||||
out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64))
|
||||
np.testing.assert_allclose(out, 50.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_smi_close_at_centre_yields_zero():
|
||||
# Close at the midpoint of a flat high/low range -> displacement is
|
||||
# always zero -> SMI converges to 0.
|
||||
n = 60
|
||||
out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0))
|
||||
# warmup_period = 5 + 3 + 3 - 2 = 9.
|
||||
np.testing.assert_allclose(out[8:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_kst_constant_series_yields_zero():
|
||||
# ROC is zero on a flat input, so every RCMA is zero, so KST and its
|
||||
# signal SMA are both zero after warmup.
|
||||
kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9)
|
||||
out = kst.batch(np.full(80, 42.0, dtype=np.float64))
|
||||
warmup = kst.warmup_period()
|
||||
# Use NaN-safe comparison on the post-warmup tail.
|
||||
tail = out[warmup - 1 :]
|
||||
assert np.all(np.isfinite(tail))
|
||||
np.testing.assert_allclose(tail, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_pgo_flat_close_yields_zero():
|
||||
# On a constant close the numerator (close − SMA) is zero, so PGO emits 0
|
||||
# regardless of the TR-EMA in the denominator.
|
||||
n = 20
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 10.0)
|
||||
out = ta.PGO(5).batch(high, low, close)
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_rvi_reference_value_period_2():
|
||||
# Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
||||
# num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5.
|
||||
out = ta.RVI(2).batch(
|
||||
np.array([10.0, 10.5]),
|
||||
np.array([11.0, 11.5]),
|
||||
np.array([9.0, 10.0]),
|
||||
np.array([10.5, 11.0]),
|
||||
)
|
||||
assert math.isnan(out[0])
|
||||
assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alma_constant_series_yields_the_constant():
|
||||
# ALMA's Gaussian weights are normalised, so any constant series is
|
||||
# reproduced exactly after warmup.
|
||||
out = ta.ALMA(9, 0.85, 6.0).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:8]))
|
||||
np.testing.assert_allclose(out[8:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_alma_reference_value_period_3():
|
||||
# ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
|
||||
# m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
out = ta.ALMA(3, 0.85, 6.0).batch(np.array([10.0, 20.0, 30.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
# Independently compute the expected Gaussian-weighted sum.
|
||||
w = np.exp(-((np.arange(3, dtype=np.float64) - 1.7) ** 2) / 0.5)
|
||||
expected = float(np.dot([10.0, 20.0, 30.0], w) / w.sum())
|
||||
assert math.isclose(out[2], expected, abs_tol=1e-12)
|
||||
# Sanity: heavy offset toward the newest sample lifts the average above
|
||||
# the simple mean of 20.
|
||||
assert out[2] > 20.0
|
||||
|
||||
|
||||
def test_mcginley_dynamic_constant_series_yields_the_constant():
|
||||
# ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
|
||||
out = ta.McGinleyDynamic(5).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_mcginley_dynamic_reference_value():
|
||||
# Period 3, seed = SMA([10, 20, 30]) = 20.0. Next price 40.0:
|
||||
# ratio = 2; divisor = 0.6 * 3 * 16 = 28.8; next = 20 + 20/28.8.
|
||||
out = ta.McGinleyDynamic(3).batch(np.array([10.0, 20.0, 30.0, 40.0]))
|
||||
assert math.isnan(out[0]) and math.isnan(out[1])
|
||||
assert math.isclose(out[2], 20.0, abs_tol=1e-12)
|
||||
expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0)
|
||||
assert math.isclose(out[3], expected, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_frama_constant_series_yields_the_constant():
|
||||
# Flat input -> degenerate ranges -> alpha clamps to 0.01 and the EMA
|
||||
# recurrence holds the seed value.
|
||||
out = ta.FRAMA(4).batch(np.full(20, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:3]))
|
||||
np.testing.assert_allclose(out[3:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_frama_pure_uptrend_hugs_latest():
|
||||
# Monotonic uptrend -> alpha pushed toward 1.0, FRAMA tracks close.
|
||||
out = ta.FRAMA(4).batch(np.arange(1.0, 9.0, dtype=np.float64))
|
||||
assert math.isclose(out[-1], 8.0, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_jma_constant_series_yields_the_constant():
|
||||
# JMA seeds e0 and the output to the first input, so a constant series
|
||||
# is reproduced exactly from the first sample.
|
||||
out = ta.JMA(14, 0.0, 2).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
np.testing.assert_allclose(out, 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_evwma_reference_value_period_2():
|
||||
# EVWMA(2). Bars: (close, volume) = (10, 1), (20, 3), (30, 1).
|
||||
# Bar 2: sum_v = 4, seeded prev = 20, EVWMA = (1*20 + 3*20)/4 = 20.
|
||||
# Bar 3: sum_v = 4 (drops 1, gains 1), EVWMA = (3*20 + 1*30)/4 = 22.5.
|
||||
out = ta.EVWMA(2).batch(np.array([10.0, 20.0, 30.0]), np.array([1.0, 3.0, 1.0]))
|
||||
assert math.isnan(out[0])
|
||||
assert math.isclose(out[1], 20.0, abs_tol=1e-12)
|
||||
assert math.isclose(out[2], 22.5, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alligator_constant_series_holds_at_median_price():
|
||||
# Median price = (11 + 9) / 2 = 10 on every candle, so all three SMMAs
|
||||
# seed at 10 and stay there.
|
||||
n = 30
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
out = ta.Alligator(13, 8, 5).batch(high, low)
|
||||
assert out.shape == (n, 3)
|
||||
for row in out[12:]:
|
||||
assert math.isclose(row[0], 10.0, abs_tol=1e-12)
|
||||
assert math.isclose(row[1], 10.0, abs_tol=1e-12)
|
||||
assert math.isclose(row[2], 10.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_vidya_constant_series_holds_seed():
|
||||
# CMO = 0 on a flat series -> alpha = 0 -> VIDYA holds its seed value.
|
||||
out = ta.VIDYA(14, 4).batch(np.full(20, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 42.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_zero_lag_macd_constant_series_converges_to_zero():
|
||||
# Each inner ZLEMA reproduces a constant, so macd, signal and histogram
|
||||
# are all 0 once the slowest branch warms up.
|
||||
out = ta.ZeroLagMACD(3, 5, 3).batch(np.full(60, 42.0, dtype=np.float64))
|
||||
# Take the last row and verify all three columns are 0.
|
||||
last = out[-1]
|
||||
assert math.isclose(last[0], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(last[1], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(last[2], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_awesome_oscillator_histogram_flat_series_converges_to_zero():
|
||||
# Flat median price -> AO = 0 -> SMA(AO) = 0 -> AOHist = 0.
|
||||
n = 50
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
out = ta.AwesomeOscillatorHistogram(3, 5, 3).batch(high, low)
|
||||
# warmup = slow + sma - 1 = 5 + 3 - 1 = 7.
|
||||
np.testing.assert_allclose(out[6:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_stc_constant_series_yields_zero():
|
||||
# Flat input collapses both stochastic stages to zero -> STC stays at 0.
|
||||
out = ta.STC(3, 5, 4, 0.5).batch(np.full(60, 42.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_array_equal(ready[-5:], np.zeros(5))
|
||||
|
||||
|
||||
def test_elder_impulse_constant_series_is_neutral():
|
||||
# Flat input -> neither EMA nor MACD histogram moves -> Impulse stays at 0.
|
||||
out = ta.ElderImpulse(13, 12, 26, 9).batch(np.full(120, 42.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_array_equal(ready, np.zeros_like(ready))
|
||||
|
||||
|
||||
def test_cfo_perfect_linear_series_yields_zero():
|
||||
# LinReg of a perfectly linear series fits exactly, so CFO = 0 after warmup.
|
||||
out = ta.CFO(5).batch(np.arange(1.0, 21.0, dtype=np.float64) * 2.0)
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_apo_constant_series_converges_to_zero():
|
||||
# Both EMAs reproduce a constant exactly, so APO = 0 after warmup.
|
||||
out = ta.APO(3, 5).batch(np.full(30, 42.0, dtype=np.float64))
|
||||
assert np.all(np.isnan(out[:4]))
|
||||
np.testing.assert_allclose(out[4:], 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_macd_constant_series_converges_to_zero():
|
||||
out = ta.MACD().batch(np.full(200, 100.0))
|
||||
# Last row's MACD and signal must be ~0.
|
||||
@@ -112,3 +330,435 @@ def test_obv_cumulative_known_sequence():
|
||||
volume = np.array([100.0, 20.0, 30.0, 40.0, 10.0])
|
||||
out = ta.OBV().batch(close, volume)
|
||||
np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0])
|
||||
|
||||
|
||||
# --- Family 15: Risk / Performance ---------------------------------------
|
||||
|
||||
|
||||
def test_sharpe_ratio_known_window():
|
||||
# returns [0.01, 0.02, 0.03, 0.04], rf = 0; mean = 0.025;
|
||||
# sample-var = 0.000166...; Sharpe = 0.025 / sqrt(var).
|
||||
out = ta.SharpeRatio(4, 0.0).batch(np.array([0.01, 0.02, 0.03, 0.04]))
|
||||
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
|
||||
assert math.isclose(out[3], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_sortino_ratio_known_window():
|
||||
# returns [-0.02, 0.01, -0.01, 0.03], mar = 0; mean = 0.0025;
|
||||
# downside_sq = 0.0005; dd = sqrt(0.0005/4); Sortino = 0.0025/dd.
|
||||
out = ta.SortinoRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
|
||||
expected = 0.0025 / math.sqrt(0.000_125)
|
||||
assert math.isclose(out[3], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_max_drawdown_known_window():
|
||||
# window [100, 120, 90] -> peak 120, trough 90 -> 25% drawdown.
|
||||
out = ta.MaxDrawdown(3).batch(np.array([100.0, 120.0, 90.0]))
|
||||
assert math.isclose(out[2], 0.25, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_pain_index_known_window():
|
||||
# dd[0..2] = 0, 0, 0.25; mean = 0.25/3.
|
||||
out = ta.PainIndex(3).batch(np.array([100.0, 120.0, 90.0]))
|
||||
assert math.isclose(out[2], 0.25 / 3.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_profit_factor_known_window():
|
||||
# gains 0.05, losses 0.03 -> PF = 5/3.
|
||||
out = ta.ProfitFactor(4).batch(np.array([0.02, -0.01, 0.03, -0.02]))
|
||||
assert math.isclose(out[3], 5.0 / 3.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_gain_loss_ratio_known_window():
|
||||
# avg_win 0.03, avg_loss 0.02 -> GLR = 1.5.
|
||||
out = ta.GainLossRatio(4).batch(np.array([0.02, -0.01, 0.04, -0.03]))
|
||||
assert math.isclose(out[3], 1.5, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_omega_ratio_known_window():
|
||||
# gains 0.04, losses 0.03 -> Omega = 4/3.
|
||||
out = ta.OmegaRatio(4, 0.0).batch(np.array([-0.02, 0.01, -0.01, 0.03]))
|
||||
assert math.isclose(out[3], 4.0 / 3.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_kelly_criterion_known_window():
|
||||
# n_win=n_loss=2, payoff=2 -> Kelly = 0.5 - 0.5/2 = 0.25.
|
||||
out = ta.KellyCriterion(4).batch(np.array([0.02, 0.04, -0.01, -0.02]))
|
||||
assert math.isclose(out[3], 0.25, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_drawdown_duration_under_water_counter():
|
||||
out = ta.DrawdownDuration().batch(np.array([100.0, 95.0, 90.0, 85.0]))
|
||||
np.testing.assert_allclose(out, [0.0, 1.0, 2.0, 3.0])
|
||||
|
||||
|
||||
def test_recovery_factor_known_path():
|
||||
# Start 100, peak 110, trough 88 -> max_dd = 0.20; end 130 ->
|
||||
# net_return = 0.30 -> Recovery = 1.5.
|
||||
prices = np.array([100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0])
|
||||
out = ta.RecoveryFactor().batch(prices)
|
||||
assert math.isclose(out[-1], 1.5, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_alpha_perfect_capm_fit_yields_zero():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = 2.0 * bench
|
||||
out = ta.Alpha(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_alpha_additive_offset_recovered():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = bench + 0.005
|
||||
out = ta.Alpha(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], 0.005, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_treynor_ratio_known_window():
|
||||
bench = np.array([0.01 * i for i in range(1, 21)])
|
||||
asset = 2.0 * bench
|
||||
out = ta.TreynorRatio(20, 0.0).batch(asset, bench)
|
||||
assert math.isclose(out[-1], bench.mean(), rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_information_ratio_known_window():
|
||||
asset = np.array([0.02, 0.04, 0.06, 0.08])
|
||||
bench = np.array([0.01, 0.02, 0.03, 0.04])
|
||||
out = ta.InformationRatio(4).batch(asset, bench)
|
||||
expected = 0.025 / math.sqrt(0.000_166_666_666_666_666_67)
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_value_at_risk_known_window():
|
||||
# returns -5..4 *0.01; q=0.05*9=0.45 -> -0.0455; VaR = 0.0455.
|
||||
returns = np.array([i * 0.01 for i in range(-5, 5)])
|
||||
out = ta.ValueAtRisk(10, 0.95).batch(returns)
|
||||
assert math.isclose(out[-1], 0.0455, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_conditional_value_at_risk_known_window():
|
||||
# tail = {-0.10}; CVaR = 0.10.
|
||||
returns = np.array([i * 0.01 for i in range(-10, 10)])
|
||||
out = ta.ConditionalValueAtRisk(20, 0.95).batch(returns)
|
||||
assert math.isclose(out[-1], 0.10, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_calmar_ratio_known_path():
|
||||
# returns [0.10, -0.20, 0.05]; equity 1.0->1.10->0.88->0.924;
|
||||
# mdd = 0.20; mean = -0.01666...; Calmar = mean / 0.20.
|
||||
out = ta.CalmarRatio(3).batch(np.array([0.10, -0.20, 0.05]))
|
||||
expected = ((0.10 - 0.20 + 0.05) / 3.0) / 0.20
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_average_drawdown_known_window():
|
||||
# window [100, 120, 90, 110]: dd = 0, 0, 0.25, 10/120;
|
||||
# mean = (0.25 + 10/120) / 4.
|
||||
out = ta.AverageDrawdown(4).batch(np.array([100.0, 120.0, 90.0, 110.0]))
|
||||
expected = (0.25 + 10.0 / 120.0) / 4.0
|
||||
assert math.isclose(out[-1], expected, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_value_area_concentrated_volume_locates_poc():
|
||||
# Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume
|
||||
# at price 110. POC must fall inside the high-volume bar's [low, high]
|
||||
# range; ties resolve to the lowest-index bin, so the POC may sit on the
|
||||
# left edge of bar 4's range rather than at its midpoint.
|
||||
high = np.array([100.5, 100.5, 100.5, 100.5, 110.5])
|
||||
low = np.array([99.5, 99.5, 99.5, 99.5, 109.5])
|
||||
volume = np.array([1.0, 1.0, 1.0, 1.0, 1000.0])
|
||||
out = ta.ValueArea(5, 50, 0.70).batch(high, low, volume)
|
||||
poc = out[-1, 0]
|
||||
assert 109.5 <= poc <= 110.5
|
||||
# VAH >= POC >= VAL.
|
||||
assert out[-1, 1] >= poc >= out[-1, 2]
|
||||
|
||||
|
||||
def test_initial_balance_locks_after_period():
|
||||
# First two bars set IB = [99, 103]. Third bar (extreme) must be ignored.
|
||||
high = np.array([102.0, 103.0, 200.0])
|
||||
low = np.array([100.0, 99.0, 50.0])
|
||||
out = ta.InitialBalance(2).batch(high, low)
|
||||
# Bar 0: IB = [100, 102]; Bar 1: IB locked at [99, 103]; Bar 2: unchanged.
|
||||
np.testing.assert_allclose(out[0], [102.0, 100.0])
|
||||
np.testing.assert_allclose(out[1], [103.0, 99.0])
|
||||
np.testing.assert_allclose(out[2], [103.0, 99.0])
|
||||
|
||||
|
||||
def test_opening_range_breakout_distance_signed():
|
||||
# OR locks after 2 bars at high 103 / low 100; mid 101.5. Third bar
|
||||
# closes at 105 -> breakout +3.5; fourth bar closes at 95 -> -6.5.
|
||||
high = np.array([102.0, 103.0, 110.0, 110.0])
|
||||
low = np.array([100.0, 101.0, 102.0, 90.0])
|
||||
close = np.array([101.0, 102.0, 105.0, 95.0])
|
||||
out = ta.OpeningRange(2).batch(high, low, close)
|
||||
assert math.isclose(out[2, 0], 103.0)
|
||||
assert math.isclose(out[2, 1], 100.0)
|
||||
assert math.isclose(out[2, 2], 105.0 - 101.5)
|
||||
assert math.isclose(out[3, 2], 95.0 - 101.5)
|
||||
|
||||
|
||||
# --- Family 10 — Ehlers / Cycle reference values ---
|
||||
|
||||
|
||||
def test_inverse_fisher_saturates_for_large_input():
|
||||
# tanh(10) ~ 0.99999996; very close to +1 without exceeding.
|
||||
v = ta.InverseFisherTransform(1.0).batch(np.array([10.0]))[0]
|
||||
assert v < 1.0
|
||||
assert v > 0.999
|
||||
|
||||
|
||||
def test_super_smoother_constant_input_is_constant():
|
||||
out = ta.SuperSmoother(20).batch(np.full(200, 50.0))
|
||||
# Steady-state gain is 1, so a flat input stays flat.
|
||||
np.testing.assert_allclose(out[-50:], 50.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_decycler_oscillator_flat_series_is_zero():
|
||||
out = ta.DecyclerOscillator(10, 30).batch(np.full(80, 42.0))
|
||||
ready = out[~np.isnan(out)]
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_mama_constant_series_both_lines_converge_to_price():
|
||||
out = ta.MAMA().batch(np.full(200, 100.0))
|
||||
last = out[-1]
|
||||
# MAMA and FAMA both track price closely on a flat series.
|
||||
assert abs(last[0] - 100.0) < 1.0
|
||||
assert abs(last[1] - 100.0) < 1.0
|
||||
|
||||
|
||||
# --- DeMark family ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_td_setup_buy_setup_completes_at_minus_9_uptrend():
|
||||
# Strictly rising closes -> every bar has close > close[-4] (sell setup);
|
||||
# the streak hits -9 at index 12 and caps there.
|
||||
h = np.arange(2.0, 22.0)
|
||||
l = h - 1.0
|
||||
c = h - 0.5
|
||||
out = ta.TDSetup(4, 9).batch(h, l, c)
|
||||
assert out[12] == pytest.approx(-9.0)
|
||||
assert out[-1] == pytest.approx(-9.0)
|
||||
|
||||
|
||||
def test_td_demarker_downtrend_pegs_at_zero():
|
||||
n = 20
|
||||
h = np.arange(30.0, 30.0 - n, -1.0)
|
||||
l = h - 2.0
|
||||
out = ta.TDDeMarker(5).batch(h, l)
|
||||
assert out[-1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_td_pressure_pure_bearish_yields_minus_100():
|
||||
n = 20
|
||||
open_ = np.full(n, 11.0)
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 9.0)
|
||||
volume = np.full(n, 100.0)
|
||||
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
|
||||
assert out[-1] == pytest.approx(-100.0)
|
||||
|
||||
|
||||
def test_td_combo_uptrend_completes_to_minus_13():
|
||||
# Pure uptrend -> setup completes, then combo conditions (close>=high[-2],
|
||||
# high>=prev.high, close>prev.close) all hold for every subsequent bar
|
||||
# -> sell combo saturates at -13.
|
||||
n = 40
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDCombo().batch(high, low, close)
|
||||
assert out[-1] == pytest.approx(-13.0)
|
||||
|
||||
|
||||
def test_td_countdown_uptrend_completes_to_minus_13():
|
||||
n = 40
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDCountdown().batch(high, low, close)
|
||||
assert out[-1] == pytest.approx(-13.0)
|
||||
|
||||
|
||||
def test_td_range_projection_doji_reference():
|
||||
# open=close=10, high=12, low=9 -> doji branch.
|
||||
# pivot_sum = 12 + 9 + 2*10 = 41; half = 20.5.
|
||||
# projHigh = 20.5 - 9 = 11.5; projLow = 20.5 - 12 = 8.5.
|
||||
out = ta.TDRangeProjection().batch(
|
||||
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([10.0])
|
||||
)
|
||||
assert out[0, 0] == pytest.approx(11.5)
|
||||
assert out[0, 1] == pytest.approx(8.5)
|
||||
|
||||
|
||||
def test_td_open_sell_signal_reference():
|
||||
# Prev high=12. Curr open=13 > 12, curr low=11 < 12 -> -1.
|
||||
td = ta.TDOpen()
|
||||
assert td.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) is None
|
||||
assert td.update((13.0, 13.5, 11.0, 11.5, 1.0, 1)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_td_differential_sell_signal_reference():
|
||||
# Prev high=10, low=8, close=9: buying=1, selling=1.
|
||||
# Curr high=12, low=9.8, close=10.5: close>prev.close, selling=1.5>1,
|
||||
# buying=0.7<1 -> sell signal -1.
|
||||
td = ta.TDDifferential()
|
||||
assert td.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
|
||||
assert td.update((10.5, 12.0, 9.8, 10.5, 1.0, 1)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_td_lines_uptrend_support_reference():
|
||||
# Strictly rising series -> sell setup completes at idx 12, the
|
||||
# lowest low across bars 4..=12 is the low at idx 4 = 4.5.
|
||||
n = 20
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDLines().batch(high, low, close)
|
||||
assert math.isnan(out[-1, 0])
|
||||
assert out[-1, 1] == pytest.approx(4.5)
|
||||
|
||||
|
||||
def test_td_risk_level_uptrend_sell_risk_reference():
|
||||
# Strictly rising series -> sell setup completes at idx 12 with high
|
||||
# 13.5 and true range 1.5 -> sell_risk = 13.5 + 1.5 = 15.0.
|
||||
# Subsequent setups re-ratchet the level, so we check the first emission
|
||||
# at idx 12 rather than the latest value.
|
||||
n = 20
|
||||
high = np.arange(1.0, 1.0 + n) + 0.5
|
||||
low = high - 1.0
|
||||
close = high - 0.5
|
||||
out = ta.TDRiskLevel().batch(high, low, close)
|
||||
assert math.isnan(out[12, 0])
|
||||
assert out[12, 1] == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_percentage_trailing_stop_seed_and_ratchet():
|
||||
# 10% trail: first close 100 -> stop 90; next 110 -> stop max(90, 99) = 99.
|
||||
s = ta.PercentageTrailingStop(10.0)
|
||||
assert math.isclose(s.update(100.0), 90.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(110.0), 99.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_step_trailing_stop_snaps_below_close():
|
||||
# step 1: floor((100.4 - 1) / 1) = 99.
|
||||
s = ta.StepTrailingStop(1.0)
|
||||
assert math.isclose(s.update(100.4), 99.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_renko_trailing_stop_holds_until_full_block():
|
||||
# block 1: seed 100 -> stop 99; 100.5 still 99; 101 -> stop 100.
|
||||
s = ta.RenkoTrailingStop(1.0)
|
||||
assert math.isclose(s.update(100.0), 99.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(100.5), 99.0, abs_tol=1e-12)
|
||||
assert math.isclose(s.update(101.0), 100.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_donchian_stop_window_extremes():
|
||||
# 5-bar window of highs 1..5 and lows 0..4.
|
||||
high = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
low = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
|
||||
out = ta.DonchianStop(5).batch(high, low)
|
||||
# First 4 rows NaN, fifth row: stop_long = 0, stop_short = 5.
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i, 0])
|
||||
assert math.isnan(out[i, 1])
|
||||
assert math.isclose(out[4, 0], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(out[4, 1], 5.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_hilo_activator_flat_market_holds_low_sma():
|
||||
# Flat candles H=11, L=9, C=10 -> close (10) sits between bands, so the
|
||||
# initial long seed is preserved: emitted stop = lo_sma = 9.
|
||||
h = np.full(15, 11.0)
|
||||
l = np.full(15, 9.0)
|
||||
c = np.full(15, 10.0)
|
||||
out = ta.HiLoActivator(3).batch(h, l, c)
|
||||
# warmup_period == period + 1 == 4, so indices 0..2 are NaN; index 3 onwards is 9.
|
||||
for i in range(3):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(3, 15):
|
||||
assert math.isclose(out[i], 9.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_volty_stop_flat_market_constant_level():
|
||||
# ATR=2, mult=2 -> band 4; anchor stays at close 10 -> stop = 10 - 4 = 6.
|
||||
h = np.full(20, 11.0)
|
||||
l = np.full(20, 9.0)
|
||||
c = np.full(20, 10.0)
|
||||
out = ta.VoltyStop(5, 2.0).batch(h, l, c)
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(4, 20):
|
||||
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_yoyo_exit_flat_market_constant_level():
|
||||
# ATR=2, mult=2 -> band 4; trail = close - band = 10 - 4 = 6 and holds.
|
||||
h = np.full(20, 11.0)
|
||||
l = np.full(20, 9.0)
|
||||
c = np.full(20, 10.0)
|
||||
out = ta.YoyoExit(5, 2.0).batch(h, l, c)
|
||||
for i in range(4):
|
||||
assert math.isnan(out[i])
|
||||
for i in range(4, 20):
|
||||
assert math.isclose(out[i], 6.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_rvi_volatility_pure_uptrend_saturates_at_one_hundred():
|
||||
# Strictly rising closes -> every stddev sample classified as "up" ->
|
||||
# RVIVolatility saturates at 100. Renamed from the original ta.RVI in
|
||||
# PR 42 to disambiguate from Family 02's Relative Vigor Index, which
|
||||
# now owns the short ta.RVI name (candle input).
|
||||
out = ta.RVIVolatility(5).batch(np.arange(1.0, 41.0, dtype=np.float64))
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready[-10:], 100.0, atol=1e-9)
|
||||
|
||||
|
||||
def test_parkinson_volatility_zero_range_yields_zero():
|
||||
# H == L every bar -> ln(H/L) = 0 -> Parkinson sigma is zero.
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
out = ta.ParkinsonVolatility(14, 252).batch(h, l)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_garman_klass_zero_movement_yields_zero():
|
||||
# O == H == L == C every bar -> both log terms are zero -> sigma is zero.
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.GarmanKlassVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_rogers_satchell_zero_movement_yields_zero():
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.RogersSatchellVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
|
||||
def test_yang_zhang_zero_movement_yields_zero():
|
||||
# O == H == L == C and constant across bars -> every sub-component is
|
||||
# zero -> Yang-Zhang sigma is zero.
|
||||
o = np.full(30, 10.0)
|
||||
h = np.full(30, 10.0)
|
||||
l = np.full(30, 10.0)
|
||||
c = np.full(30, 10.0)
|
||||
out = ta.YangZhangVolatility(14, 252).batch(o, h, l, c)
|
||||
ready = out[~np.isnan(out)]
|
||||
assert ready.size > 0
|
||||
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
|
||||
|
||||
@@ -86,3 +86,46 @@ def test_candle_tuple_input_supported():
|
||||
atr.update((10.0, 11.0, 9.0, 10.5, 1.0, 0))
|
||||
v = atr.update((10.5, 12.0, 10.0, 11.0, 1.0, 1))
|
||||
assert v is not None
|
||||
|
||||
|
||||
def test_initial_balance_reset_unlocks():
|
||||
ib = ta.InitialBalance(2)
|
||||
assert not ib.is_ready()
|
||||
ib.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
|
||||
ib.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
|
||||
assert ib.is_ready()
|
||||
assert ib.is_locked()
|
||||
ib.reset()
|
||||
assert not ib.is_ready()
|
||||
assert not ib.is_locked()
|
||||
|
||||
|
||||
def test_opening_range_reset_unlocks():
|
||||
or_ind = ta.OpeningRange(2)
|
||||
or_ind.update((101.0, 102.0, 100.0, 101.0, 0.0, 0))
|
||||
or_ind.update((102.0, 103.0, 101.0, 102.0, 0.0, 1))
|
||||
assert or_ind.is_locked()
|
||||
or_ind.reset()
|
||||
assert not or_ind.is_locked()
|
||||
|
||||
|
||||
def test_value_area_warmup_equals_period():
|
||||
assert ta.ValueArea(20, 50, 0.70).warmup_period() == 20
|
||||
assert ta.ValueArea(10, 30, 0.80).warmup_period() == 10
|
||||
|
||||
|
||||
def test_ehlers_indicators_lifecycle():
|
||||
# Spot-check a few Family-10 entries beyond what test_new_indicators covers.
|
||||
series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0
|
||||
for ind in [
|
||||
ta.SuperSmoother(10),
|
||||
ta.FisherTransform(10),
|
||||
ta.MAMA(),
|
||||
ta.HilbertDominantCycle(),
|
||||
ta.SineWave(),
|
||||
]:
|
||||
assert not ind.is_ready()
|
||||
ind.batch(series)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,3 +55,45 @@ def test_obv_batch_shape(ohlc_series):
|
||||
volume = np.ones_like(close)
|
||||
out = ta.OBV().batch(close, volume)
|
||||
assert out.shape == close.shape
|
||||
|
||||
|
||||
def test_value_area_batch_shape(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
volume = np.ones_like(close)
|
||||
out = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
|
||||
assert out.shape == (close.size, 3)
|
||||
|
||||
|
||||
def test_initial_balance_batch_shape(ohlc_series):
|
||||
high, low, _close = ohlc_series
|
||||
out = ta.InitialBalance(12).batch(high, low)
|
||||
assert out.shape == (high.size, 2)
|
||||
|
||||
|
||||
def test_opening_range_batch_shape(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
out = ta.OpeningRange(6).batch(high, low, close)
|
||||
assert out.shape == (close.size, 3)
|
||||
|
||||
|
||||
def test_ichimoku_batch_returns_n_by_5(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
out = ta.Ichimoku().batch(high, low, close)
|
||||
assert out.shape == (close.size, 5)
|
||||
|
||||
|
||||
def test_heikin_ashi_batch_returns_n_by_4(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
open_ = (high + low) / 2.0
|
||||
out = ta.HeikinAshi().batch(open_, high, low, close)
|
||||
assert out.shape == (close.size, 4)
|
||||
|
||||
|
||||
def test_ehlers_super_smoother_batch_shape(sine_prices):
|
||||
out = ta.SuperSmoother(10).batch(sine_prices)
|
||||
assert out.shape == sine_prices.shape
|
||||
|
||||
|
||||
def test_mama_batch_shape(sine_prices):
|
||||
out = ta.MAMA().batch(sine_prices)
|
||||
assert out.shape == (sine_prices.size, 2)
|
||||
|
||||
@@ -115,3 +115,89 @@ def test_obv_streaming_matches_batch(ohlc_series):
|
||||
rows.append(streamer.update((float(c), float(c), float(c), float(c), float(v), 0)))
|
||||
streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_mama_streaming_matches_batch(sine_prices):
|
||||
batch = ta.MAMA().batch(sine_prices)
|
||||
streamer = ta.MAMA()
|
||||
rows = []
|
||||
for p in sine_prices:
|
||||
v = streamer.update(float(p))
|
||||
if v is None:
|
||||
rows.append([math.nan, math.nan])
|
||||
else:
|
||||
rows.append(list(v))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_super_smoother_streaming_matches_batch(sine_prices):
|
||||
batch = ta.SuperSmoother(10).batch(sine_prices)
|
||||
streamer = ta.SuperSmoother(10)
|
||||
streamed = np.array(
|
||||
[math.nan if (v := streamer.update(float(p))) is None else float(v) for p in sine_prices],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_rolling_vwap_streaming_matches_batch(ohlc_series):
|
||||
# RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP
|
||||
# parity coverage now that the indicator is exposed across all bindings.
|
||||
high, low, close = ohlc_series
|
||||
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
|
||||
batch = ta.RollingVWAP(20).batch(high, low, close, volume)
|
||||
|
||||
streamer = ta.RollingVWAP(20)
|
||||
rows = []
|
||||
for h, l, c, v in zip(high, low, close, volume):
|
||||
rows.append(streamer.update((float(c), float(h), float(l), float(c), float(v), 0)))
|
||||
streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
assert streamer.period == 20
|
||||
assert streamer.warmup_period() == 20
|
||||
assert streamer.is_ready()
|
||||
streamer.reset()
|
||||
assert not streamer.is_ready()
|
||||
|
||||
|
||||
def test_value_area_streaming_matches_batch(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64)
|
||||
batch = ta.ValueArea(20, 50, 0.70).batch(high, low, volume)
|
||||
|
||||
streamer = ta.ValueArea(20, 50, 0.70)
|
||||
rows = []
|
||||
for h, l, v in zip(high, low, volume):
|
||||
mid = float((h + l) / 2.0)
|
||||
out = streamer.update((mid, float(h), float(l), mid, float(v), 0))
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_initial_balance_streaming_matches_batch(ohlc_series):
|
||||
high, low, _close = ohlc_series
|
||||
batch = ta.InitialBalance(12).batch(high, low)
|
||||
|
||||
streamer = ta.InitialBalance(12)
|
||||
rows = []
|
||||
for h, l in zip(high, low):
|
||||
mid = float((h + l) / 2.0)
|
||||
out = streamer.update((mid, float(h), float(l), mid, 0.0, 0))
|
||||
rows.append([math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_opening_range_streaming_matches_batch(ohlc_series):
|
||||
high, low, close = ohlc_series
|
||||
batch = ta.OpeningRange(6).batch(high, low, close)
|
||||
|
||||
streamer = ta.OpeningRange(6)
|
||||
rows = []
|
||||
for h, l, c in zip(high, low, close):
|
||||
out = streamer.update((float(c), float(h), float(l), float(c), 0.0, 0))
|
||||
rows.append([math.nan, math.nan, math.nan] if out is None else list(out))
|
||||
streamed = np.array(rows, dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
@@ -34,6 +34,9 @@ console_error_panic_hook = { version = "0.1", optional = true }
|
||||
default = []
|
||||
panic-hook = ["dep:console_error_panic_hook"]
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release.wasm-bindgen]
|
||||
debug-js-glue = false
|
||||
demangle-name-section = true
|
||||
|
||||
+295
-28
@@ -1,47 +1,314 @@
|
||||
# wickra-wasm
|
||||
# Wickra
|
||||
|
||||
WebAssembly bindings for the Wickra streaming-first technical indicators library.
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](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 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
|
||||
Python 3.12, Node 20.
|
||||
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
|
||||
`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
|
||||
214 streaming-first indicators across sixteen 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, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
|
||||
| Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
|
||||
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Detrended StdDev |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Spearman Correlation |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
|
||||
```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 `bindings/wasm/examples/index.html`. After building
|
||||
the package serve the `bindings/wasm/` directory and open `examples/index.html`.
|
||||
## 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/wickra-lib/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/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
# Proper nouns that appear in indicator documentation. They are real names,
|
||||
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
|
||||
# `..` keeps clippy's built-in default identifier list in addition to these.
|
||||
doc-valid-idents = ["LeBeau", ".."]
|
||||
@@ -11,6 +11,12 @@ homepage.workspace = true
|
||||
readme.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
documentation = "https://docs.rs/wickra-core"
|
||||
|
||||
# Render the docs on docs.rs with every feature enabled so the parallel
|
||||
# (rayon-backed) batch APIs are documented.
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -21,6 +21,13 @@ pub enum Error {
|
||||
#[error("invalid candle: {message}")]
|
||||
InvalidCandle { message: &'static str },
|
||||
|
||||
/// A tick whose components do not satisfy the tick invariants (e.g. negative
|
||||
/// volume) was provided. Ticks are a different concept from candles and
|
||||
/// surface as their own variant so consumers of a tick-stream pipeline
|
||||
/// can match on a semantically-correct error instead of `InvalidCandle`.
|
||||
#[error("invalid tick: {message}")]
|
||||
InvalidTick { message: &'static str },
|
||||
|
||||
/// A multiplier or factor must be strictly positive.
|
||||
#[error("multiplier must be greater than zero")]
|
||||
NonPositiveMultiplier,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Acceleration Bands (Price Headley).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Acceleration Bands output: SMA of close with momentum-biased envelopes
|
||||
/// driven by the bar's high/low geometry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AccelerationBandsOutput {
|
||||
/// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`.
|
||||
pub upper: f64,
|
||||
/// Middle band: SMA of close.
|
||||
pub middle: f64,
|
||||
/// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
|
||||
/// bar's relative range `(high − low) / (high + low)`.
|
||||
///
|
||||
/// ```text
|
||||
/// ratio = (high − low) / (high + low)
|
||||
/// raw_up = high · (1 + factor · ratio)
|
||||
/// raw_lo = low · (1 − factor · ratio)
|
||||
/// upper = SMA(raw_up, period)
|
||||
/// middle = SMA(close, period)
|
||||
/// lower = SMA(raw_lo, period)
|
||||
/// ```
|
||||
///
|
||||
/// Headley's reference parameters are `period = 20`, `factor = 0.001` for
|
||||
/// intraday equity markets — the geometric `ratio` term tends to scale on
|
||||
/// fractional moves, so the literal `factor` is small. The bands compress in
|
||||
/// quiet markets and flare on impulsive bars, making them a momentum-biased
|
||||
/// alternative to the volatility-driven Bollinger or Keltner envelopes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AccelerationBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccelerationBands {
|
||||
upper_sma: Sma,
|
||||
middle_sma: Sma,
|
||||
lower_sma: Sma,
|
||||
factor: f64,
|
||||
period: usize,
|
||||
}
|
||||
|
||||
impl AccelerationBands {
|
||||
/// Construct a new Acceleration Bands indicator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
|
||||
/// and finite.
|
||||
pub fn new(period: usize, factor: f64) -> Result<Self> {
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
upper_sma: Sma::new(period)?,
|
||||
middle_sma: Sma::new(period)?,
|
||||
lower_sma: Sma::new(period)?,
|
||||
factor,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Headley's classic configuration: `period = 20`, `factor = 0.001`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, factor)`.
|
||||
pub const fn parameters(&self) -> (usize, f64) {
|
||||
(self.period, self.factor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AccelerationBands {
|
||||
type Input = Candle;
|
||||
type Output = AccelerationBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
|
||||
// (high + low) == 0 is geometrically impossible for valid OHLC
|
||||
// (high >= low and a zero-sum requires both equal to 0, which would
|
||||
// make the bar degenerate). Guard anyway so a hypothetical zero-price
|
||||
// bar collapses the ratio to zero rather than emitting NaN.
|
||||
let sum_hl = candle.high + candle.low;
|
||||
let ratio = if sum_hl == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(candle.high - candle.low) / sum_hl
|
||||
};
|
||||
let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
|
||||
let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
|
||||
|
||||
// Feed all three SMAs unconditionally so they warm up in lock-step.
|
||||
let upper = self.upper_sma.update(raw_up);
|
||||
let middle = self.middle_sma.update(candle.close);
|
||||
let lower = self.lower_sma.update(raw_lo);
|
||||
let (upper, middle, lower) = (upper?, middle?, lower?);
|
||||
Some(AccelerationBandsOutput {
|
||||
upper,
|
||||
middle,
|
||||
lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.upper_sma.reset();
|
||||
self.middle_sma.reset();
|
||||
self.lower_sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.middle_sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AccelerationBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(0, 0.001),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_factor() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AccelerationBands::classic();
|
||||
let (p, f) = ab.parameters();
|
||||
assert_eq!(p, 20);
|
||||
assert_relative_eq!(f, 0.001, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 20);
|
||||
assert_eq!(ab.name(), "AccelerationBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_to_constant() {
|
||||
// high == low so the ratio term is zero; all three SMAs converge to
|
||||
// the same constant.
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut ab = AccelerationBands::new(5, 0.001).unwrap();
|
||||
for i in 0..4 {
|
||||
let base = 100.0 + f64::from(i);
|
||||
assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
|
||||
}
|
||||
assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(20, 0.5).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
|
||||
assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AccelerationBands::new(10, 0.5).unwrap();
|
||||
let mut b = AccelerationBands::new(10, 0.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_price_candle_collapses_ratio_to_zero() {
|
||||
// `high + low == 0` is geometrically only reachable with a fully-zero
|
||||
// bar (high >= low and both non-negative for a real market, but
|
||||
// `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
|
||||
// ratio guard must fire and the bands all collapse to zero.
|
||||
let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(zero).unwrap();
|
||||
assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
/// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
|
||||
/// `close = 10`, `factor = 0.5`, `period = 1`.
|
||||
/// `ratio = (12 − 8) / (12 + 8) = 0.2`
|
||||
/// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
|
||||
/// `raw_lo = 8 · (1 − 0.5 · 0.2) = 8 · 0.9 = 7.2`
|
||||
/// `middle = SMA(close, 1) = 10`
|
||||
#[test]
|
||||
fn reference_value_single_bar() {
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
|
||||
assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Accelerator Oscillator (Bill Williams).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::awesome_oscillator::AwesomeOscillator;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
|
||||
///
|
||||
/// ```text
|
||||
/// AO = SMA(median, fast) − SMA(median, slow) (the Awesome Oscillator)
|
||||
/// AC = AO − SMA(AO, signal)
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
|
||||
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
|
||||
/// moving average of itself. Because acceleration leads speed, `AC` tends to
|
||||
/// turn before the `AO` does. Bill Williams' classic configuration is the
|
||||
/// `(5, 34)` AO with a `5`-period signal average.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
|
||||
///
|
||||
/// let mut indicator = AcceleratorOscillator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcceleratorOscillator {
|
||||
ao: AwesomeOscillator,
|
||||
signal: Sma,
|
||||
ao_fast: usize,
|
||||
ao_slow: usize,
|
||||
signal_period: usize,
|
||||
}
|
||||
|
||||
impl AcceleratorOscillator {
|
||||
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
|
||||
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
|
||||
/// AO `fast` period is not strictly below `slow`.
|
||||
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
ao_fast,
|
||||
ao_slow,
|
||||
signal_period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ao_fast, ao_slow, signal_period)`.
|
||||
pub const fn params(&self) -> (usize, usize, usize) {
|
||||
(self.ao_fast, self.ao_slow, self.signal_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AcceleratorOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ao = self.ao.update(candle)?;
|
||||
let signal = self.signal.update(ao)?;
|
||||
Some(ao - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ao.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The AO emits at candle `ao_slow`; the signal SMA then needs
|
||||
// `signal_period` AO values.
|
||||
self.ao_slow + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AcceleratorOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// A flat market gives AO = 0, so its signal average and AC are 0 too.
|
||||
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
for v in ac.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ao_and_signal() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.3, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let mut ao = AwesomeOscillator::classic();
|
||||
let mut signal = Sma::new(5).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = ac.update(*candle);
|
||||
match ao.update(*candle) {
|
||||
Some(ao_val) => match signal.update(ao_val) {
|
||||
Some(sig) => {
|
||||
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
|
||||
}
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
},
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let out = ac.batch(&candles);
|
||||
assert_eq!(ac.warmup_period(), 38);
|
||||
for (i, v) in out.iter().enumerate().take(37) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
|
||||
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
|
||||
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
ac.batch(&candles);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
assert_eq!(ac.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AcceleratorOscillator::classic();
|
||||
let mut b = AcceleratorOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Williams Accumulation/Distribution.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
|
||||
/// flow that classifies each bar as accumulation or distribution based on its
|
||||
/// close relative to the previous close, then sums the directional component.
|
||||
///
|
||||
/// Williams' definition (1972) uses a *true* high/low that includes the prior
|
||||
/// close as an anchor — the same idea that motivates true range:
|
||||
///
|
||||
/// ```text
|
||||
/// TR_h_t = max(close_{t−1}, high_t)
|
||||
/// TR_l_t = min(close_{t−1}, low_t)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_l_t) if close_t > close_{t−1} (accumulation)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_h_t) if close_t < close_{t−1} (distribution)
|
||||
/// AD_t = AD_{t−1} if close_t == close_{t−1} (no change)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
|
||||
/// volume entirely — Williams argued that the relative position of the close
|
||||
/// already encodes the day's "true" buying or selling pressure. The series is
|
||||
/// unbounded and used primarily for divergence analysis. The first candle only
|
||||
/// seeds the previous close; the first emission lands at bar 2.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdOscillator};
|
||||
///
|
||||
/// let mut indicator = AdOscillator::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdOscillator {
|
||||
prev_close: Option<f64>,
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdOscillator {
|
||||
/// Construct a new Williams A/D starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev_close: None,
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one emission has happened.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev_close else {
|
||||
// The first bar only establishes the previous close anchor.
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let delta = if candle.close > prev {
|
||||
// Accumulation: distance from the true low.
|
||||
let tr_l = prev.min(candle.low);
|
||||
candle.close - tr_l
|
||||
} else if candle.close < prev {
|
||||
// Distribution: distance from the true high (negative).
|
||||
let tr_h = prev.max(candle.high);
|
||||
candle.close - tr_h
|
||||
} else {
|
||||
// Unchanged close contributes nothing.
|
||||
0.0
|
||||
};
|
||||
self.total += delta;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed bar; the second bar is the first emission.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WilliamsAD"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 100.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ad = AdOscillator::new();
|
||||
assert_eq!(ad.name(), "WilliamsAD");
|
||||
assert_eq!(ad.warmup_period(), 2);
|
||||
assert_eq!(ad.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_returns_total_after_first_emission() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_only_seeds() {
|
||||
let mut ad = AdOscillator::new();
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
|
||||
assert!(!ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulation_adds_distance_from_true_low() {
|
||||
// prev close = 10, today low = 8, today close = 12 (up day).
|
||||
// TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_adds_distance_from_true_high() {
|
||||
// prev close = 10, today high = 11, today close = 7 (down day).
|
||||
// TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, -4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_keeps_total() {
|
||||
// close equals prev close -> no contribution.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Every close equals the previous -> AD stays at zero forever.
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ad = AdOscillator::new();
|
||||
for v in ad.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80i64)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
let mid = 100.0 + (f * 0.3).sin() * 5.0;
|
||||
c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AdOscillator::new();
|
||||
let mut b = AdOscillator::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.batch(&[
|
||||
c(10.0, 11.0, 9.0, 10.0, 0),
|
||||
c(10.0, 12.0, 9.0, 11.0, 1),
|
||||
c(11.0, 13.0, 10.0, 12.0, 2),
|
||||
]);
|
||||
assert!(ad.is_ready());
|
||||
ad.reset();
|
||||
assert!(!ad.is_ready());
|
||||
assert_eq!(ad.value(), None);
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
|
||||
|
||||
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Adaptive Cycle Indicator.
|
||||
///
|
||||
/// Returns half the current dominant cycle period — the "best" lookback for
|
||||
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
|
||||
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
|
||||
/// the fact that an oscillator over a half-cycle captures the full peak-to-
|
||||
/// trough swing without aliasing.
|
||||
///
|
||||
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
|
||||
/// matching the typical operating range of period-adaptive oscillators.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveCycle};
|
||||
///
|
||||
/// let mut ac = AdaptiveCycle::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdaptiveCycle {
|
||||
cycle: HilbertDominantCycle,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCycle {
|
||||
/// Construct a new adaptive cycle estimator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current adaptive period if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCycle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let period = self.cycle.update(input)?;
|
||||
let half = (period * 0.5).round().clamp(3.0, 25.0);
|
||||
self.last_value = Some(half);
|
||||
Some(half)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cycle.reset();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.cycle.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCycle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
assert_eq!(ac.warmup_period(), 50);
|
||||
assert_eq!(ac.name(), "AdaptiveCycle");
|
||||
assert!(!ac.is_ready());
|
||||
assert!(ac.value().is_none());
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
assert!(ac.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_clamp_band() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
for v in ac.batch(&prices).into_iter().flatten() {
|
||||
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
|
||||
assert_eq!(v, v.round(), "expected integer-valued output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = AdaptiveCycle::new();
|
||||
let mut b = AdaptiveCycle::new();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
let before = ac.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(ac.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Accumulation/Distribution Line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
|
||||
/// indicator.
|
||||
///
|
||||
/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
|
||||
/// where the close fell within the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1)
|
||||
/// MFV_t = MFM_t · volume_t
|
||||
/// ADL_t = ADL_{t−1} + MFV_t
|
||||
/// ```
|
||||
///
|
||||
/// A close near the high makes the multiplier near `+1` (accumulation), near
|
||||
/// the low near `−1` (distribution). The running total is unbounded and drifts
|
||||
/// with cumulative volume — what matters is its slope and its divergence from
|
||||
/// price. A bar with `high == low` contributes `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Adl};
|
||||
///
|
||||
/// let mut indicator = Adl::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Adl {
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
/// Construct a new Accumulation/Distribution Line starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one candle has been ingested.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adl {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let mfv = if range == 0.0 {
|
||||
// A zero-range bar carries no positional information.
|
||||
0.0
|
||||
} else {
|
||||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||||
mfm * candle.volume
|
||||
};
|
||||
self.total += mfv;
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADL"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
|
||||
// bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
|
||||
// ADL = 100 - 100 = 0.
|
||||
let mut adl = Adl::new();
|
||||
let out = adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||||
candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
|
||||
]);
|
||||
assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut adl = Adl::new();
|
||||
assert_eq!(adl.warmup_period(), 1);
|
||||
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
let mut adl = Adl::new();
|
||||
let mut expected = 0.0;
|
||||
for i in 0..10 {
|
||||
let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
|
||||
expected += 25.0;
|
||||
assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_contributes_nothing() {
|
||||
let mut adl = Adl::new();
|
||||
adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
|
||||
let before = adl.value().unwrap();
|
||||
// A flat candle (high == low) adds zero.
|
||||
let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
|
||||
assert_relative_eq!(after, before, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adl = Adl::new();
|
||||
adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
|
||||
candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
|
||||
]);
|
||||
assert!(adl.is_ready());
|
||||
adl.reset();
|
||||
assert!(!adl.is_ready());
|
||||
assert_eq!(adl.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Adl::new().batch(&candles);
|
||||
let mut b = Adl::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,22 @@ pub struct AdxOutput {
|
||||
/// movement / true range sums; the next `period` candles produce DX values that
|
||||
/// seed the ADX. The first complete `AdxOutput` is emitted after `2 * period`
|
||||
/// candles.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Adx};
|
||||
///
|
||||
/// let mut indicator = Adx::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[allow(clippy::struct_field_names)] // adx_value pairs with adx (the output line) — renaming hurts clarity
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Adx {
|
||||
@@ -253,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)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Average Directional Movement Index Rating (ADXR).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::adx::Adx;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wilder's Average Directional Movement Index Rating.
|
||||
///
|
||||
/// `ADXR` smooths the [`Adx`] line by averaging its current value with the value
|
||||
/// it had `period` bars ago:
|
||||
///
|
||||
/// ```text
|
||||
/// ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2
|
||||
/// ```
|
||||
///
|
||||
/// The lookback length is the same `period` that feeds the underlying ADX.
|
||||
/// Wilder introduced ADXR alongside ADX in *New Concepts in Technical Trading
|
||||
/// Systems* (1978) as a more stable directional-strength reading: because the
|
||||
/// older `ADX` is `period - 1` bars stale, ADXR responds more slowly than ADX
|
||||
/// and is used to compare trend-strength between different instruments.
|
||||
///
|
||||
/// The first complete `ADXR` is emitted after `3 * period - 1` candles
|
||||
/// (`2 * period` to seed the ADX plus another `period - 1` to fill the
|
||||
/// lookback ring).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Adxr, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = Adxr::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Adxr {
|
||||
period: usize,
|
||||
adx: Adx,
|
||||
/// Ring buffer of the most recent `period` `ADX` values; the front is the
|
||||
/// oldest, the back is the newest. ADXR is `(back + front) / 2` once the
|
||||
/// ring is full.
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Adxr {
|
||||
/// Construct a new ADXR with the given Wilder smoothing period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
adx: Adx::new(period)?,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adxr {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let adx_value = self.adx.update(candle)?.adx;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(adx_value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let oldest = *self.window.front().expect("ring is full");
|
||||
let adxr = f64::midpoint(adx_value, oldest);
|
||||
self.last = Some(adxr);
|
||||
Some(adxr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.adx.reset();
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ADX warmup is `2 * period` and emits one `ADX` per subsequent candle;
|
||||
// the ADXR ring then needs `period - 1` more candles to fill, so the
|
||||
// first ADXR lands at `2 * period + (period - 1) = 3 * period - 1`.
|
||||
3 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADXR"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
|
||||
Candle::new(c, h, l, c, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Adxr::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut a = Adxr::new(14).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.warmup_period(), 41);
|
||||
assert_eq!(a.name(), "ADXR");
|
||||
assert!(a.value().is_none());
|
||||
// Drive past warmup.
|
||||
for i in 0..50_i64 {
|
||||
let base = 100.0 + (i as f64) * 2.0;
|
||||
a.update(candle(base + 1.0, base - 0.5, base + 0.5, i));
|
||||
}
|
||||
assert!(a.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_finite_positive_adxr() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64) * 2.0;
|
||||
candle(base + 1.0, base - 0.5, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(14).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0 && last <= 100.0 + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero_adxr() {
|
||||
let candles: Vec<Candle> = (0..50_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
let out = a.batch(&candles);
|
||||
let warmup = 3 * 5 - 1; // 14
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_against_explicit_adx_average() {
|
||||
// The first ADXR(p) emits at index `3p - 2` (0-based), and equals
|
||||
// (ADX[index] + ADX[index - (p - 1)]) / 2. Verify against a separate
|
||||
// ADX run.
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.2).sin() * 6.0;
|
||||
candle(p + 1.5, p - 1.5, p, i)
|
||||
})
|
||||
.collect();
|
||||
let period = 5;
|
||||
let mut adx = Adx::new(period).unwrap();
|
||||
let adx_out: Vec<_> = adx
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.map(|o| o.map(|x| x.adx))
|
||||
.collect();
|
||||
let mut adxr = Adxr::new(period).unwrap();
|
||||
let adxr_out = adxr.batch(&candles);
|
||||
// First ADXR index (0-based) = 3 * period - 2 = 13.
|
||||
let first = 3 * period - 2;
|
||||
let prev = first - (period - 1);
|
||||
let expected = f64::midpoint(adx_out[first].unwrap(), adx_out[prev].unwrap());
|
||||
assert_relative_eq!(adxr_out[first].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.25).sin() * 5.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(7).unwrap();
|
||||
let mut b = Adxr::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..60_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Bill Williams' Alligator indicator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::smma::Smma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Alligator output: three smoothed moving averages of the median price
|
||||
/// `(high + low) / 2`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AlligatorOutput {
|
||||
/// `Jaw` — the slowest line (default period 13).
|
||||
pub jaw: f64,
|
||||
/// `Teeth` — the middle line (default period 8).
|
||||
pub teeth: f64,
|
||||
/// `Lips` — the fastest line (default period 5).
|
||||
pub lips: f64,
|
||||
}
|
||||
|
||||
/// Bill Williams' Alligator: three `SMMA`s of the median price `(high + low) / 2`
|
||||
/// with different periods. Classic parameters are `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
///
|
||||
/// The original chart variant additionally shifts each line forward by a fixed
|
||||
/// number of bars for display (Jaw +8, Teeth +5, Lips +3). Wickra publishes the
|
||||
/// *unshifted* `SMMA` values — the consumer can apply the visual shift on the
|
||||
/// chart side. The indicator emits values once all three `SMMA`s have warmed
|
||||
/// up, i.e. after `max(jaw, teeth, lips) = jaw` candles.
|
||||
///
|
||||
/// Reference: Bill Williams, *Trading Chaos*, 1995.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Alligator, Candle, Indicator};
|
||||
///
|
||||
/// let mut alligator = Alligator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = alligator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alligator {
|
||||
jaw_period: usize,
|
||||
teeth_period: usize,
|
||||
lips_period: usize,
|
||||
jaw: Smma,
|
||||
teeth: Smma,
|
||||
lips: Smma,
|
||||
}
|
||||
|
||||
impl Alligator {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero.
|
||||
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
|
||||
if jaw_period == 0 || teeth_period == 0 || lips_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
jaw_period,
|
||||
teeth_period,
|
||||
lips_period,
|
||||
jaw: Smma::new(jaw_period)?,
|
||||
teeth: Smma::new(teeth_period)?,
|
||||
lips: Smma::new(lips_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(13, 8, 5).expect("classic Alligator parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(jaw_period, teeth_period, lips_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.jaw_period, self.teeth_period, self.lips_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alligator {
|
||||
type Input = Candle;
|
||||
type Output = AlligatorOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AlligatorOutput> {
|
||||
let median = f64::midpoint(candle.high, candle.low);
|
||||
// Feed every `SMMA` on every bar so they warm up in parallel; gating
|
||||
// the longer lines behind the shorter ones would starve them during
|
||||
// their own warmup.
|
||||
let lips = self.lips.update(median);
|
||||
let teeth = self.teeth.update(median);
|
||||
let jaw = self.jaw.update(median);
|
||||
Some(AlligatorOutput {
|
||||
jaw: jaw?,
|
||||
teeth: teeth?,
|
||||
lips: lips?,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.jaw.reset();
|
||||
self.teeth.reset();
|
||||
self.lips.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// All three SMMAs run on every bar, so readiness is gated by the
|
||||
// longest period — the Jaw with the default parameters.
|
||||
self.jaw_period.max(self.teeth_period).max(self.lips_period)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.jaw.is_ready() && self.teeth.is_ready() && self.lips.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Alligator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let close = f64::midpoint(high, low);
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Alligator::new(0, 8, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Alligator::new(13, 0, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Alligator::new(13, 8, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let alligator = Alligator::classic();
|
||||
assert_eq!(alligator.periods(), (13, 8, 5));
|
||||
assert_eq!(alligator.warmup_period(), 13);
|
||||
assert_eq!(alligator.name(), "Alligator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// Median price = 10 for every bar, so each SMMA seeds to 10 and stays.
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = alligator.batch(&candles);
|
||||
for v in out.iter().skip(12).flatten() {
|
||||
assert_relative_eq!(v.jaw, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.teeth, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lips, 10.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_longest_period() {
|
||||
let mut alligator = Alligator::new(5, 3, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = alligator.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_ordering() {
|
||||
// On a clean uptrend the fastest line (Lips, smallest SMMA) leads the
|
||||
// slowest line (Jaw) — lips > teeth > jaw at the latest bar.
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0_i64..80)
|
||||
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
|
||||
.collect();
|
||||
let out = alligator.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert!(
|
||||
last.lips > last.teeth,
|
||||
"lips {} > teeth {}",
|
||||
last.lips,
|
||||
last.teeth
|
||||
);
|
||||
assert!(
|
||||
last.teeth > last.jaw,
|
||||
"teeth {} > jaw {}",
|
||||
last.teeth,
|
||||
last.jaw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 1.0, base - 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Alligator::classic();
|
||||
let mut b = Alligator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut alligator = Alligator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
alligator.batch(&candles);
|
||||
assert!(alligator.is_ready());
|
||||
alligator.reset();
|
||||
assert!(!alligator.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Arnaud Legoux Moving Average (ALMA).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average.
|
||||
///
|
||||
/// Each output is a weighted sum of the last `period` inputs:
|
||||
///
|
||||
/// ```text
|
||||
/// w[i] = exp(-(i - m)^2 / (2 * s^2)) for i in 0..period
|
||||
/// m = offset * (period - 1)
|
||||
/// s = period / sigma
|
||||
/// ALMA = sum(price[i] * w[i]) / sum(w[i])
|
||||
/// ```
|
||||
///
|
||||
/// The Gaussian is centred on the relative index `offset * (period - 1)`, so
|
||||
/// `offset = 0.85` puts the peak near the newest sample (responsive), while
|
||||
/// `offset = 0.5` centres the peak in the middle of the window (smooth).
|
||||
/// `sigma` controls how concentrated the Gaussian is: larger `sigma` ->
|
||||
/// narrower kernel, smaller `sigma` -> broader (closer to SMA).
|
||||
///
|
||||
/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009.
|
||||
///
|
||||
/// # Defaults
|
||||
///
|
||||
/// The community-standard parameters are `period = 9`, `offset = 0.85`,
|
||||
/// `sigma = 6.0`. The first output lands after exactly `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Alma, Indicator};
|
||||
///
|
||||
/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = alma.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alma {
|
||||
period: usize,
|
||||
offset: f64,
|
||||
sigma: f64,
|
||||
/// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest
|
||||
/// sample in the window, `weights[period - 1]` the newest.
|
||||
weights: Vec<f64>,
|
||||
window: VecDeque<f64>,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Alma {
|
||||
/// Construct a new ALMA with the given period, offset and sigma.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::PeriodZero`] if `period == 0`.
|
||||
/// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or
|
||||
/// `sigma <= 0.0` or either of `offset` / `sigma` is non-finite.
|
||||
pub fn new(period: usize, offset: f64, sigma: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !offset.is_finite() || !(0.0..=1.0).contains(&offset) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "ALMA offset must be a finite value in [0, 1]",
|
||||
});
|
||||
}
|
||||
if !sigma.is_finite() || sigma <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "ALMA sigma must be a finite positive value",
|
||||
});
|
||||
}
|
||||
let m = offset * (period as f64 - 1.0);
|
||||
let s = period as f64 / sigma;
|
||||
let denom = 2.0 * s * s;
|
||||
// The raw Gaussian weights sum to a strictly positive value because
|
||||
// every term is `exp(_) > 0`, so the normalisation below cannot divide
|
||||
// by zero.
|
||||
let mut raw: Vec<f64> = (0..period)
|
||||
.map(|i| (-((i as f64 - m).powi(2)) / denom).exp())
|
||||
.collect();
|
||||
let sum: f64 = raw.iter().sum();
|
||||
for w in &mut raw {
|
||||
*w /= sum;
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
offset,
|
||||
sigma,
|
||||
weights: raw,
|
||||
window: VecDeque::with_capacity(period),
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct ALMA with the community-standard parameters
|
||||
/// `(period = 9, offset = 0.85, sigma = 6.0)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured offset.
|
||||
pub const fn offset(&self) -> f64 {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// Configured sigma.
|
||||
pub const fn sigma(&self) -> f64 {
|
||||
self.sigma
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alma {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut acc = 0.0;
|
||||
for (w, p) in self.weights.iter().zip(self.window.iter()) {
|
||||
acc += w * p;
|
||||
}
|
||||
self.current = Some(acc);
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ALMA"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_offset() {
|
||||
assert!(matches!(
|
||||
Alma::new(9, -0.1, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 1.1, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, f64::NAN, 6.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_sigma() {
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, -1.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Alma::new(9, 0.85, f64::INFINITY),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
assert_eq!(alma.period(), 9);
|
||||
assert_eq!(alma.warmup_period(), 9);
|
||||
assert_eq!(alma.name(), "ALMA");
|
||||
assert!((alma.offset() - 0.85).abs() < 1e-12);
|
||||
assert!((alma.sigma() - 6.0).abs() < 1e-12);
|
||||
// Weights are normalised by construction.
|
||||
let sum: f64 = alma.weights.iter().sum();
|
||||
assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
let a = Alma::classic();
|
||||
assert_eq!(a.period(), 9);
|
||||
assert!((a.offset() - 0.85).abs() < 1e-12);
|
||||
assert!((a.sigma() - 6.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_the_constant() {
|
||||
// Normalised weights sum to 1, so any constant is reproduced exactly.
|
||||
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
let out = alma.batch(&[42.0_f64; 40]);
|
||||
for v in out.iter().skip(8).flatten() {
|
||||
assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
|
||||
for i in 0..4 {
|
||||
assert_eq!(alma.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(alma.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_period_3() {
|
||||
// ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
|
||||
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
||||
// Independently compute the normalised Gaussian weights and the
|
||||
// expected weighted sum, then check the indicator output matches.
|
||||
// Computing the expectation here (rather than pinning a printed
|
||||
// constant) keeps the test stable across libm `exp` implementations.
|
||||
let mut alma = Alma::new(3, 0.85, 6.0).unwrap();
|
||||
alma.update(10.0);
|
||||
alma.update(20.0);
|
||||
let v = alma.update(30.0).expect("ALMA emits after period");
|
||||
|
||||
let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp();
|
||||
let s = w0 + w1 + w2;
|
||||
let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s;
|
||||
|
||||
// The weighted sum is heavily skewed toward the newest sample so the
|
||||
// output must sit close to but below the latest input (30).
|
||||
assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}");
|
||||
assert_relative_eq!(v, expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_zero_centres_on_oldest_sample() {
|
||||
// With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward
|
||||
// the oldest sample in the window and away from the newest.
|
||||
let mut alma = Alma::new(5, 0.0, 6.0).unwrap();
|
||||
let series: Vec<f64> = (1..=5).map(f64::from).collect();
|
||||
let mut last = None;
|
||||
for p in &series {
|
||||
last = alma.update(*p);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
let mean = series.iter().sum::<f64>() / series.len() as f64;
|
||||
// Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit
|
||||
// strictly below the mean.
|
||||
assert!(v < mean, "{v} should be less than {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_one_centres_on_newest_sample() {
|
||||
// Symmetric to the above: offset = 1 leans toward the newest sample.
|
||||
let mut alma = Alma::new(5, 1.0, 6.0).unwrap();
|
||||
let series: Vec<f64> = (1..=5).map(f64::from).collect();
|
||||
let mut last = None;
|
||||
for p in &series {
|
||||
last = alma.update(*p);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
let mean = series.iter().sum::<f64>() / series.len() as f64;
|
||||
assert!(v > mean, "{v} should exceed {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
|
||||
.collect();
|
||||
let mut a = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
let mut b = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
|
||||
alma.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(alma.is_ready());
|
||||
alma.reset();
|
||||
assert!(!alma.is_ready());
|
||||
assert_eq!(alma.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
|
||||
alma.batch(&(1..=5).map(f64::from).collect::<Vec<_>>());
|
||||
let before = alma.update(6.0).unwrap();
|
||||
// Non-finite inputs leave the window/current untouched.
|
||||
assert_eq!(alma.update(f64::NAN), Some(before));
|
||||
assert_eq!(alma.update(f64::INFINITY), Some(before));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Rolling Jensen's Alpha (CAPM).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Jensen's Alpha.
|
||||
///
|
||||
/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
|
||||
/// the trailing window of `period` pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// Beta = cov(asset, bench) / var(bench)
|
||||
/// Alpha = mean(asset) − ( risk_free + Beta · (mean(bench) − risk_free) )
|
||||
/// ```
|
||||
///
|
||||
/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
|
||||
/// performance that cannot be explained by simple exposure to the
|
||||
/// benchmark. A positive alpha indicates outperformance net of the market
|
||||
/// premium implied by the asset's beta; negative alpha is the opposite.
|
||||
///
|
||||
/// Population covariance and variance are used (matching common
|
||||
/// implementations in pandas-ta / quantstats); the rolling estimator stays
|
||||
/// unbiased in the steady state for fixed `period`.
|
||||
///
|
||||
/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
|
||||
/// `alpha = mean(asset) − risk_free` — the asset's mean excess return, with
|
||||
/// no market-risk adjustment, since the regression slope is undefined.
|
||||
///
|
||||
/// Each `update` is O(1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alpha {
|
||||
period: usize,
|
||||
risk_free: f64,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl Alpha {
|
||||
/// Construct a new rolling Alpha.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize, risk_free: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "alpha needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
risk_free,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured per-period risk-free rate.
|
||||
pub const fn risk_free(&self) -> f64 {
|
||||
self.risk_free
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Alpha {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_a -= oa;
|
||||
self.sum_b -= ob;
|
||||
self.sum_bb -= ob * ob;
|
||||
self.sum_ab -= oa * ob;
|
||||
}
|
||||
self.window.push_back((a, b));
|
||||
self.sum_a += a;
|
||||
self.sum_b += b;
|
||||
self.sum_bb += b * b;
|
||||
self.sum_ab += a * b;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_a = self.sum_a / n;
|
||||
let mean_b = self.sum_b / n;
|
||||
let var_b = (self.sum_bb / n) - mean_b * mean_b;
|
||||
if var_b <= 0.0 {
|
||||
// Undefined beta: report unadjusted excess.
|
||||
return Some(mean_a - self.risk_free);
|
||||
}
|
||||
let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
|
||||
let beta = cov_ab / var_b;
|
||||
Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_a = 0.0;
|
||||
self.sum_b = 0.0;
|
||||
self.sum_bb = 0.0;
|
||||
self.sum_ab = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Alpha"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
Alpha::new(1, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Alpha::new(20, 0.001).unwrap();
|
||||
assert_eq!(a.period(), 20);
|
||||
assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
|
||||
assert_eq!(a.name(), "Alpha");
|
||||
assert_eq!(a.warmup_period(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capm_perfect_fit_yields_zero_alpha() {
|
||||
// asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
|
||||
// CAPM-implied return matches the asset's mean perfectly.
|
||||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||||
.map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
|
||||
.collect();
|
||||
let out = a.batch(&inputs);
|
||||
assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_alpha_offset_recovered() {
|
||||
// asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
|
||||
// Expected alpha = 0.005.
|
||||
let mut a = Alpha::new(20, 0.0).unwrap();
|
||||
let inputs: Vec<(f64, f64)> = (1..=20)
|
||||
.map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
|
||||
.collect();
|
||||
let out = a.batch(&inputs);
|
||||
assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_benchmark_falls_back_to_excess_return() {
|
||||
// Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
|
||||
let mut a = Alpha::new(4, 0.001).unwrap();
|
||||
let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
|
||||
let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
|
||||
assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||||
assert_eq!(a.update((f64::NAN, 0.0)), None);
|
||||
assert_eq!(a.update((0.0, f64::INFINITY)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = Alpha::new(3, 0.0).unwrap();
|
||||
a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update((0.01, 0.005)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<(f64, f64)> = (0..50)
|
||||
.map(|i| {
|
||||
let b = (f64::from(i) * 0.2).sin() * 0.01;
|
||||
(1.5 * b + 0.002, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
|
||||
let mut s = Alpha::new(10, 0.0).unwrap();
|
||||
let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Anchored Volume-Weighted Average Price.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a
|
||||
/// user-chosen anchor bar rather than the session open.
|
||||
///
|
||||
/// ```text
|
||||
/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i
|
||||
/// ```
|
||||
///
|
||||
/// The indicator emits `None` until the first anchored bar has been ingested.
|
||||
/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that
|
||||
/// arrives, clearing the running sums; this is the conventional behaviour for
|
||||
/// "click to anchor" trader workflows where the anchor is set on the close of
|
||||
/// a swing point and the next bar starts the new accumulation. The cumulative
|
||||
/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`].
|
||||
///
|
||||
/// Bars where the running volume is still zero (only happens if every anchored
|
||||
/// bar so far carried zero volume) return `None` to avoid a zero-division.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AnchoredVwap, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AnchoredVwap::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// // Re-anchor at bar 40 (e.g. a major swing low).
|
||||
/// if i == 40 {
|
||||
/// indicator.set_anchor();
|
||||
/// }
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AnchoredVwap {
|
||||
sum_pv: f64,
|
||||
sum_v: f64,
|
||||
has_emitted: bool,
|
||||
pending_anchor: bool,
|
||||
}
|
||||
|
||||
impl AnchoredVwap {
|
||||
/// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
sum_pv: 0.0,
|
||||
sum_v: 0.0,
|
||||
has_emitted: false,
|
||||
pending_anchor: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
|
||||
/// running sums before adding its own contribution, effectively starting a
|
||||
/// fresh anchored window.
|
||||
pub fn set_anchor(&mut self) {
|
||||
self.pending_anchor = true;
|
||||
}
|
||||
|
||||
/// Current anchored value if at least one bar with non-zero volume has
|
||||
/// been observed in the current anchor window.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.sum_v == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(self.sum_pv / self.sum_v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AnchoredVwap {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.pending_anchor {
|
||||
// Drop the old window before folding in this bar.
|
||||
self.sum_pv = 0.0;
|
||||
self.sum_v = 0.0;
|
||||
self.has_emitted = false;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
let tp = candle.typical_price();
|
||||
self.sum_pv += tp * candle.volume;
|
||||
self.sum_v += candle.volume;
|
||||
if self.sum_v == 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.has_emitted = true;
|
||||
Some(self.sum_pv / self.sum_v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sum_pv = 0.0;
|
||||
self.sum_v = 0.0;
|
||||
self.has_emitted = false;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AnchoredVWAP"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(price: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price, price, price, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let v = AnchoredVwap::new();
|
||||
assert_eq!(v.name(), "AnchoredVWAP");
|
||||
assert_eq!(v.warmup_period(), 1);
|
||||
assert_eq!(v.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_with_zero_volume_returns_none() {
|
||||
let mut v = AnchoredVwap::new();
|
||||
assert_eq!(v.update(c(50.0, 0.0, 0)), None);
|
||||
assert!(!v.is_ready());
|
||||
// The next bar with volume still works.
|
||||
assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_volumes_yield_mean_typical_price() {
|
||||
// typical_price of a flat OHLC bar equals the price.
|
||||
let mut v = AnchoredVwap::new();
|
||||
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]);
|
||||
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_clears_old_window() {
|
||||
// Run a few bars at price 10, then re-anchor and pump in price 100.
|
||||
// After the re-anchor the running mean must be 100, not the mix.
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]);
|
||||
assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12);
|
||||
v.set_anchor();
|
||||
let after = v.update(c(100.0, 5.0, 3)).unwrap();
|
||||
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_before_first_bar_acts_as_normal_first_bar() {
|
||||
// Calling set_anchor on an empty indicator should be a no-op effect:
|
||||
// the first bar still anchors the window.
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.set_anchor();
|
||||
assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_average_reference() {
|
||||
// Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5.
|
||||
let mut v = AnchoredVwap::new();
|
||||
let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]);
|
||||
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect();
|
||||
let mut a = AnchoredVwap::new();
|
||||
let mut b = AnchoredVwap::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut v = AnchoredVwap::new();
|
||||
v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]);
|
||||
assert!(v.is_ready());
|
||||
v.reset();
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
// After reset the first bar acts as the new anchor.
|
||||
assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Absolute Price Oscillator (APO).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Absolute Price Oscillator — the raw difference between a fast and a slow
|
||||
/// `EMA`. This is MACD's line without the signal-EMA — useful when only the
|
||||
/// momentum-direction reading is needed.
|
||||
///
|
||||
/// ```text
|
||||
/// APO_t = EMA(close, fast)_t − EMA(close, slow)_t
|
||||
/// ```
|
||||
///
|
||||
/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be
|
||||
/// strictly less than `slow`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Apo, Indicator};
|
||||
///
|
||||
/// let mut apo = Apo::new(12, 26).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = apo.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Apo {
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
fast: Ema,
|
||||
slow: Ema,
|
||||
}
|
||||
|
||||
impl Apo {
|
||||
/// # Errors
|
||||
/// - [`Error::PeriodZero`] if either period is zero.
|
||||
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "APO fast period must be strictly less than slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
fast: Ema::new(fast)?,
|
||||
slow: Ema::new(slow)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// MACD-style defaults: `(fast = 12, slow = 26)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26).expect("classic APO parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast, slow)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.fast_period, self.slow_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Apo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Feed both EMAs on every input so the slow one warms in parallel.
|
||||
let f = self.fast.update(input);
|
||||
let s = self.slow.update(input);
|
||||
Some(f? - s?)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Slow EMA dominates; both EMAs emit at their `period` th input.
|
||||
self.slow_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.slow.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"APO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fast_geq_slow() {
|
||||
assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let apo = Apo::classic();
|
||||
assert_eq!(apo.periods(), (12, 26));
|
||||
assert_eq!(apo.warmup_period(), 26);
|
||||
assert_eq!(apo.name(), "APO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_factory() {
|
||||
assert_eq!(Apo::classic().periods(), (12, 26));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
// Both EMAs reproduce the constant exactly, so APO is 0.
|
||||
let mut apo = Apo::new(3, 5).unwrap();
|
||||
let out = apo.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_slow_period() {
|
||||
let mut apo = Apo::new(2, 4).unwrap();
|
||||
assert_eq!(apo.warmup_period(), 4);
|
||||
for i in 1..=3 {
|
||||
assert_eq!(apo.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(apo.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
// Fast EMA leads the slow EMA on an uptrend, so APO > 0.
|
||||
let mut apo = Apo::classic();
|
||||
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
|
||||
let out = apo.batch(&prices);
|
||||
let last = out.iter().rev().flatten().next().unwrap();
|
||||
assert!(*last > 0.0, "APO on uptrend should be positive: {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Apo::classic();
|
||||
let mut b = Apo::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut apo = Apo::classic();
|
||||
apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(apo.is_ready());
|
||||
apo.reset();
|
||||
assert!(!apo.is_ready());
|
||||
assert_eq!(apo.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,22 @@ pub struct AroonOutput {
|
||||
|
||||
/// Aroon indicator: tracks how many bars since the highest high and lowest low
|
||||
/// inside a `period + 1`-bar window. Returned as a percentage.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Aroon};
|
||||
///
|
||||
/// let mut indicator = Aroon::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Aroon {
|
||||
period: usize,
|
||||
@@ -153,4 +169,26 @@ mod tests {
|
||||
assert!((0.0..=100.0).contains(&o.down));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (1..=20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut a = Aroon::new(14).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Aroon Oscillator.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Aroon;
|
||||
|
||||
/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
|
||||
///
|
||||
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
|
||||
/// collapses them into one value in `[−100, 100]`:
|
||||
///
|
||||
/// ```text
|
||||
/// AroonOscillator = AroonUp − AroonDown
|
||||
/// ```
|
||||
///
|
||||
/// Strongly positive means the most recent high is much fresher than the most
|
||||
/// recent low (an up-trend); strongly negative is the mirror image. Readings
|
||||
/// near zero mean neither extreme is recent — a range.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AroonOscillator};
|
||||
///
|
||||
/// let mut indicator = AroonOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(100.0)); // pure uptrend
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AroonOscillator {
|
||||
aroon: Aroon,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AroonOscillator {
|
||||
/// Construct a new Aroon Oscillator with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
aroon: Aroon::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.aroon.period()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AroonOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
|
||||
self.last = Some(osc);
|
||||
Some(osc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.aroon.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.aroon.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AroonOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(AroonOscillator::new(0).is_err());
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 + i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_yields_minus_100() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 - i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_within_minus_100_and_100() {
|
||||
let mut osc = AroonOscillator::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_matches_aroon() {
|
||||
let osc = AroonOscillator::new(7).unwrap();
|
||||
assert_eq!(osc.warmup_period(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
|
||||
.collect();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
|
||||
let mut b = AroonOscillator::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,22 @@ use crate::traits::Indicator;
|
||||
/// The first emitted value, by convention, appears after `period` candles: the
|
||||
/// first `period − 1` true-range values seed the Wilder average alongside the
|
||||
/// `period`-th, then the smoothed update begins.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Atr};
|
||||
///
|
||||
/// let mut indicator = Atr::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Atr {
|
||||
period: usize,
|
||||
@@ -100,11 +116,56 @@ mod tests {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Independent reference: Wilder ATR computed straight from the definition.
|
||||
fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec<Option<f64>> {
|
||||
let n = period as f64;
|
||||
let mut out = Vec::with_capacity(hlc.len());
|
||||
let mut trs: Vec<f64> = Vec::new();
|
||||
let mut avg: Option<f64> = None;
|
||||
let mut prev_close: Option<f64> = None;
|
||||
for &(h, l, cl) in hlc {
|
||||
let tr = match prev_close {
|
||||
None => h - l,
|
||||
Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()),
|
||||
};
|
||||
prev_close = Some(cl);
|
||||
if let Some(a) = avg {
|
||||
let na = (a * (n - 1.0) + tr) / n;
|
||||
avg = Some(na);
|
||||
out.push(Some(na));
|
||||
} else {
|
||||
trs.push(tr);
|
||||
if trs.len() == period {
|
||||
avg = Some(trs.iter().sum::<f64>() / n);
|
||||
out.push(avg);
|
||||
} else {
|
||||
out.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// 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![
|
||||
@@ -187,4 +248,37 @@ mod tests {
|
||||
assert!(v >= 0.0, "ATR must be non-negative: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
fn atr_matches_naive(
|
||||
period in 1usize..15,
|
||||
bars in proptest::collection::vec(
|
||||
(10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0),
|
||||
0..120,
|
||||
),
|
||||
) {
|
||||
// bars: (low, range, close_fraction) -> a valid OHLC candle.
|
||||
let hlc: Vec<(f64, f64, f64)> = bars
|
||||
.iter()
|
||||
.map(|&(low, range, frac)| (low + range, low, low + range * frac))
|
||||
.collect();
|
||||
let candles: Vec<Candle> = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect();
|
||||
let mut atr = Atr::new(period).unwrap();
|
||||
let got = atr.batch(&candles);
|
||||
let want = atr_naive(&hlc, period);
|
||||
proptest::prop_assert_eq!(got.len(), want.len());
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => proptest::prop_assert!(
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
|
||||
"got={a} want={b}"
|
||||
),
|
||||
_ => proptest::prop_assert!(false, "warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! ATR Bands.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtrBandsOutput {
|
||||
/// Upper band: `close + multiplier · ATR`.
|
||||
pub upper: f64,
|
||||
/// Middle band: the current close.
|
||||
pub middle: f64,
|
||||
/// Lower band: `close − multiplier · ATR`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
|
||||
///
|
||||
/// ```text
|
||||
/// upper = close + multiplier · ATR(period)
|
||||
/// lower = close − multiplier · ATR(period)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
|
||||
/// centerline is the *raw close* rather than a smoothed average — the band
|
||||
/// rides the price tick-for-tick. This is the standard volatility-targeting
|
||||
/// envelope traders use to set initial stop-loss and profit targets: an entry
|
||||
/// at the close sets a `multiplier · ATR` stop and the symmetric target
|
||||
/// without ever needing to wait for a moving average to warm up.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AtrBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrBands {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
}
|
||||
|
||||
impl AtrBands {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
|
||||
/// invalid inputs.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(period)?,
|
||||
multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured ATR period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.atr.period()
|
||||
}
|
||||
|
||||
/// Configured ATR multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrBands {
|
||||
type Input = Candle;
|
||||
type Output = AtrBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
Some(AtrBandsOutput {
|
||||
upper: candle.close + self.multiplier * atr,
|
||||
middle: candle.close,
|
||||
lower: candle.close - self.multiplier * atr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.atr.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, f64::INFINITY),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AtrBands::new(14, 3.0).unwrap();
|
||||
assert_eq!(ab.period(), 14);
|
||||
assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 14);
|
||||
assert_eq!(ab.name(), "AtrBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_bands() {
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(14, 3.0).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle);
|
||||
assert!(o.middle >= o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AtrBands::new(10, 2.5).unwrap();
|
||||
let mut b = AtrBands::new(10, 2.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// Reference: with constant high-low spread of 2, ATR(period) converges to
|
||||
/// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
|
||||
#[test]
|
||||
fn reference_values_constant_spread() {
|
||||
// Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
|
||||
let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let out = ab.batch(&candles);
|
||||
assert!(out[0].is_none() && out[3].is_none());
|
||||
let v = out[4].unwrap();
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! ATR Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
|
||||
/// and ratchets in the direction of the trend.
|
||||
///
|
||||
/// ```text
|
||||
/// loss = multiplier · ATR
|
||||
///
|
||||
/// stop_t = max(stop_{t−1}, close − loss) while price holds above the stop
|
||||
/// = min(stop_{t−1}, close + loss) while price holds below the stop
|
||||
/// = close − loss on a fresh break above the stop
|
||||
/// = close + loss on a fresh break below the stop
|
||||
/// ```
|
||||
///
|
||||
/// While price stays on one side of the stop the level only ratchets toward
|
||||
/// price — up in an uptrend, down in a downtrend — never away from it. When a
|
||||
/// close crosses the stop the level snaps to the opposite side, `loss` away
|
||||
/// from the new close, flipping the trade. This is the trailing stop used by
|
||||
/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
|
||||
/// price (a long).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
|
||||
///
|
||||
/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrTrailingStop {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
atr_period: usize,
|
||||
prev_close: Option<f64>,
|
||||
prev_stop: Option<f64>,
|
||||
}
|
||||
|
||||
impl AtrTrailingStop {
|
||||
/// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
multiplier,
|
||||
atr_period,
|
||||
prev_close: None,
|
||||
prev_stop: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: `ATR(14)` with a `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrTrailingStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let loss = self.multiplier * atr;
|
||||
let close = candle.close;
|
||||
|
||||
let stop = match (self.prev_stop, self.prev_close) {
|
||||
(Some(prev_stop), Some(prev_close)) => {
|
||||
if close > prev_stop && prev_close > prev_stop {
|
||||
// Holding above the stop — ratchet it up only.
|
||||
(close - loss).max(prev_stop)
|
||||
} else if close < prev_stop && prev_close < prev_stop {
|
||||
// Holding below the stop — ratchet it down only.
|
||||
(close + loss).min(prev_stop)
|
||||
} else if close > prev_stop {
|
||||
// Fresh break above — place the stop below the new close.
|
||||
close - loss
|
||||
} else {
|
||||
// Fresh break below — place the stop above the new close.
|
||||
close + loss
|
||||
}
|
||||
}
|
||||
// First ATR-ready bar: seed the stop below price (a long).
|
||||
_ => close - loss,
|
||||
};
|
||||
|
||||
self.prev_close = Some(close);
|
||||
self.prev_stop = Some(stop);
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.prev_close = None;
|
||||
self.prev_stop = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev_stop.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
|
||||
// Seed stop = close - loss = 10 - 6 = 4, and it holds there.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
|
||||
for v in ts.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_stop_ratchets_up_and_stays_below_price() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let emitted: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
for w in emitted.windows(2) {
|
||||
assert!(
|
||||
w[1].0 >= w[0].0 - 1e-9,
|
||||
"stop must not loosen in an uptrend"
|
||||
);
|
||||
}
|
||||
for &(stop, close) in &emitted {
|
||||
assert!(stop < close, "uptrend stop should sit below the close");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_flips_to_the_other_side_when_price_reverses() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
// A steep decline drags price through the trailing stop.
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - 3.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base, 40 + i)
|
||||
}));
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let paired: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop < close),
|
||||
"expected a long stretch with the stop below price"
|
||||
);
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop > close),
|
||||
"expected the stop to flip above price after the reversal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
|
||||
let out = ts.batch(&candles);
|
||||
assert_eq!(ts.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AtrTrailingStop::new(0, 3.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, 0.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, -1.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::classic();
|
||||
ts.batch(&candles);
|
||||
assert!(ts.is_ready());
|
||||
ts.reset();
|
||||
assert!(!ts.is_ready());
|
||||
assert_eq!(ts.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AtrTrailingStop::classic();
|
||||
let mut b = AtrTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Rolling lag-`k` autocorrelation.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
|
||||
///
|
||||
/// Over the trailing window the Pearson correlation between the series and
|
||||
/// itself shifted by `lag` is computed:
|
||||
///
|
||||
/// ```text
|
||||
/// y_i for i = 0..period − 1
|
||||
/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )²
|
||||
/// ```
|
||||
///
|
||||
/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a
|
||||
/// perfect alternation. Values near `0` mean the series at `t` and `t −
|
||||
/// lag` carry no linear relationship — a clean white-noise proxy. The
|
||||
/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
|
||||
/// flags a cycle of that length) or testing whether returns are
|
||||
/// uncorrelated (a key efficient-markets diagnostic).
|
||||
///
|
||||
/// `period` must be strictly greater than `lag` so that at least two
|
||||
/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
|
||||
/// indicator returns `0` rather than dividing by zero.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Autocorrelation, Indicator};
|
||||
///
|
||||
/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Autocorrelation {
|
||||
period: usize,
|
||||
lag: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl Autocorrelation {
|
||||
/// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
|
||||
pub fn new(period: usize, lag: usize) -> Result<Self> {
|
||||
if lag == 0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation lag must be >= 1",
|
||||
});
|
||||
}
|
||||
if period <= lag {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation needs period > lag",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
lag,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured lag.
|
||||
pub const fn lag(&self) -> usize {
|
||||
self.lag
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Autocorrelation {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// ACF over the current window with a single inner pass. The window is
|
||||
// small relative to a typical input stream so the O(period) per-bar
|
||||
// cost is bounded by the user-chosen `period`; the constant factor
|
||||
// is dominated by two adds and one multiply per element.
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut denom = 0.0;
|
||||
let mut numer = 0.0;
|
||||
// The window is a deque; index via slices for cache-friendly access.
|
||||
let (front, back) = self.window.as_slices();
|
||||
let get = |i: usize| -> f64 {
|
||||
if i < front.len() {
|
||||
front[i]
|
||||
} else {
|
||||
back[i - front.len()]
|
||||
}
|
||||
};
|
||||
for i in 0..self.period {
|
||||
let d = get(i) - mean;
|
||||
denom += d * d;
|
||||
}
|
||||
let lag = self.lag;
|
||||
for i in 0..(self.period - lag) {
|
||||
numer += (get(i) - mean) * (get(i + lag) - mean);
|
||||
}
|
||||
if denom == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(numer / denom)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Autocorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_lag() {
|
||||
assert!(Autocorrelation::new(10, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_lag_geq_period() {
|
||||
assert!(Autocorrelation::new(5, 5).is_err());
|
||||
assert!(Autocorrelation::new(5, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Autocorrelation::new(14, 2).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.lag(), 2);
|
||||
assert_eq!(a.warmup_period(), 14);
|
||||
assert_eq!(a.name(), "Autocorrelation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
for v in a.batch(&[42.0; 30]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_series_lag_one_is_strongly_negative() {
|
||||
// [−1, 1, −1, 1, …] alternates each step.
|
||||
let prices: Vec<f64> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
|
||||
.collect();
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < -0.5,
|
||||
"alternating series should be strongly negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_series_is_strongly_positive_at_period() {
|
||||
// A series that repeats every 4 steps must have ACF(4) ≈ +1.
|
||||
let pattern = [1.0, 2.0, 3.0, 4.0];
|
||||
let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
|
||||
let mut a = Autocorrelation::new(16, 4).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.5,
|
||||
"period-4 repeat should ACF(4) > 0.5, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = Autocorrelation::new(5, 1).unwrap();
|
||||
a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
|
||||
let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
|
||||
let mut b = Autocorrelation::new(14, 2).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Rolling Average Drawdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Average Drawdown.
|
||||
///
|
||||
/// Input is treated as an equity-curve sample. The indicator scans the
|
||||
/// trailing window of `period` values, tracks the running peak inside the
|
||||
/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
|
||||
/// "pain" of being under water):
|
||||
///
|
||||
/// ```text
|
||||
/// drawdown_t = (peak_t − equity_t) / peak_t (running peak inside window)
|
||||
/// AvgDD = mean(drawdown_t over window)
|
||||
/// ```
|
||||
///
|
||||
/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
|
||||
/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
|
||||
/// for the same metric exposed under its conventional label.
|
||||
///
|
||||
/// Each `update` is O(period).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AverageDrawdown {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl AverageDrawdown {
|
||||
/// Construct a new rolling Average Drawdown.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AverageDrawdown {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut peak = f64::NEG_INFINITY;
|
||||
let mut sum_dd = 0.0_f64;
|
||||
for &v in &self.window {
|
||||
if v > peak {
|
||||
peak = v;
|
||||
}
|
||||
if peak > 0.0 {
|
||||
sum_dd += (peak - v) / peak;
|
||||
}
|
||||
}
|
||||
Some(sum_dd / self.period as f64)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AverageDrawdown"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = AverageDrawdown::new(10).unwrap();
|
||||
assert_eq!(a.period(), 10);
|
||||
assert_eq!(a.name(), "AverageDrawdown");
|
||||
assert_eq!(a.warmup_period(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_zero() {
|
||||
let mut a = AverageDrawdown::new(5).unwrap();
|
||||
let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// window [100, 120, 90, 110]:
|
||||
// peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
|
||||
// avg = (.25 + .0833...) / 4 = .0833...
|
||||
let mut a = AverageDrawdown::new(4).unwrap();
|
||||
let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
|
||||
let expected = (0.25 + (10.0 / 120.0)) / 4.0;
|
||||
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
assert_eq!(a.update(f64::NAN), None);
|
||||
assert_eq!(a.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
a.batch(&[100.0, 90.0, 110.0]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(100.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
|
||||
let mut s = AverageDrawdown::new(10).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_positive_peak_yields_zero() {
|
||||
let mut a = AverageDrawdown::new(3).unwrap();
|
||||
let out = a.batch(&[0.0_f64; 6]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,22 @@ use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Awesome Oscillator: `SMA(median_price, 5) - SMA(median_price, 34)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AwesomeOscillator};
|
||||
///
|
||||
/// let mut indicator = AwesomeOscillator::new(3, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AwesomeOscillator {
|
||||
fast: Sma,
|
||||
@@ -102,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)
|
||||
@@ -114,4 +141,17 @@ mod tests {
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut ao = AwesomeOscillator::classic();
|
||||
ao.batch(&candles);
|
||||
assert!(ao.is_ready());
|
||||
ao.reset();
|
||||
assert!(!ao.is_ready());
|
||||
assert_eq!(ao.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Awesome Oscillator Histogram.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::awesome_oscillator::AwesomeOscillator;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// "Awesome Oscillator Histogram" — the difference between the Awesome
|
||||
/// Oscillator and its `sma_period`-bar `SMA`. Positive bars mean `AO` is
|
||||
/// trending up (bullish acceleration); negative bars mean `AO` is trending
|
||||
/// down (bearish acceleration).
|
||||
///
|
||||
/// ```text
|
||||
/// AO = SMA(median, fast) − SMA(median, slow)
|
||||
/// AOHist = AO − SMA(AO, sma_period)
|
||||
/// ```
|
||||
///
|
||||
/// With Williams' default `sma_period = 5`, this collapses to the existing
|
||||
/// `AcceleratorOscillator` for `fast = 5, slow = 34, sma_period = 5`; for any
|
||||
/// other parameterisation this is a more flexible variant.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AwesomeOscillatorHistogram, Candle, Indicator};
|
||||
///
|
||||
/// let mut hist = AwesomeOscillatorHistogram::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let p = 100.0 + f64::from(i);
|
||||
/// let candle = Candle::new(p, p + 0.5, p - 0.5, p, 1.0, i64::from(i)).unwrap();
|
||||
/// last = hist.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AwesomeOscillatorHistogram {
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
sma_period: usize,
|
||||
ao: AwesomeOscillator,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl AwesomeOscillatorHistogram {
|
||||
/// # Errors
|
||||
/// - [`Error::PeriodZero`] if any period is zero.
|
||||
/// - [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, sma_period: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 || sma_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "AwesomeOscillatorHistogram fast must be strictly less than slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
sma_period,
|
||||
ao: AwesomeOscillator::new(fast, slow)?,
|
||||
sma: Sma::new(sma_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' Accelerator-equivalent defaults `(5, 34, 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 34, 5).expect("classic Awesome Oscillator Histogram parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast_period, slow_period, sma_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.fast_period, self.slow_period, self.sma_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AwesomeOscillatorHistogram {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ao = self.ao.update(candle)?;
|
||||
let sma = self.sma.update(ao)?;
|
||||
Some(ao - sma)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ao.reset();
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// AO emits at `slow` candles; the SMA then needs `sma_period - 1`
|
||||
// more AO values to fill its window.
|
||||
self.slow_period + self.sma_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AwesomeOscillatorHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(price: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price + 0.5, price - 0.5, price, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(0, 34, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(5, 0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(5, 34, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fast_geq_slow() {
|
||||
assert!(matches!(
|
||||
AwesomeOscillatorHistogram::new(34, 5, 5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hist = AwesomeOscillatorHistogram::classic();
|
||||
assert_eq!(hist.periods(), (5, 34, 5));
|
||||
assert_eq!(hist.warmup_period(), 38);
|
||||
assert_eq!(hist.name(), "AwesomeOscillatorHistogram");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
// AO of a flat series is 0; SMA of 0 is 0; difference is 0.
|
||||
let mut hist = AwesomeOscillatorHistogram::new(3, 5, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30).map(|i| candle(42.0, i)).collect();
|
||||
let out = hist.batch(&candles);
|
||||
for v in out.iter().skip(hist.warmup_period() - 1).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut hist = AwesomeOscillatorHistogram::new(2, 4, 3).unwrap();
|
||||
assert_eq!(hist.warmup_period(), 6);
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
|
||||
.collect();
|
||||
let out = hist.batch(&candles);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..100_i64)
|
||||
.map(|i| candle(100.0 + (i as f64 * 0.3).sin() * 5.0, i))
|
||||
.collect();
|
||||
let batch = AwesomeOscillatorHistogram::classic().batch(&candles);
|
||||
let mut b = AwesomeOscillatorHistogram::classic();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hist = AwesomeOscillatorHistogram::classic();
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| candle(10.0 + f64::from(i), i64::from(i)))
|
||||
.collect();
|
||||
hist.batch(&candles);
|
||||
assert!(hist.is_ready());
|
||||
hist.reset();
|
||||
assert!(!hist.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Balance of Power.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Balance of Power — where the close settled within the bar's range relative
|
||||
/// to the open.
|
||||
///
|
||||
/// ```text
|
||||
/// BOP = (close − open) / (high − low)
|
||||
/// ```
|
||||
///
|
||||
/// The result lives in `[−1, +1]`: `+1` is a bar that opened on its low and
|
||||
/// closed on its high (buyers in full control), `−1` the mirror image. It is
|
||||
/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
|
||||
/// zero-range bar carries no information and yields `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BalanceOfPower};
|
||||
///
|
||||
/// let mut indicator = BalanceOfPower::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BalanceOfPower {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl BalanceOfPower {
|
||||
/// Construct a new Balance of Power transform.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BalanceOfPower {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let range = candle.high - candle.low;
|
||||
let bop = if range == 0.0 {
|
||||
// A zero-range bar carries no directional information.
|
||||
0.0
|
||||
} else {
|
||||
(candle.close - candle.open) / range
|
||||
};
|
||||
Some(bop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BalanceOfPower"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
||||
let mut bop = BalanceOfPower::new();
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
|
||||
0.5,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_high_after_open_on_low_is_plus_one() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
// open == low, close == high -> BOP = +1.
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
|
||||
1.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_unit_range() {
|
||||
let candles: Vec<Candle> = (0..100)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
|
||||
let close = mid + (i as f64 * 0.5).cos() * 2.0;
|
||||
candle(mid, mid + 3.0, mid - 3.0, close, i)
|
||||
})
|
||||
.collect();
|
||||
let mut bop = BalanceOfPower::new();
|
||||
for v in bop.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_yields_zero() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
assert_relative_eq!(
|
||||
bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
assert_eq!(bop.warmup_period(), 1);
|
||||
assert!(!bop.is_ready());
|
||||
assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
|
||||
assert!(bop.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bop = BalanceOfPower::new();
|
||||
bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
|
||||
assert!(bop.is_ready());
|
||||
bop.reset();
|
||||
assert!(!bop.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = BalanceOfPower::new();
|
||||
let mut b = BalanceOfPower::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Rolling Beta — sensitivity of an asset to a benchmark.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Beta of an `asset` series relative to a `benchmark` series.
|
||||
///
|
||||
/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing
|
||||
/// window of `period` pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// cov_ab = (1/n) · Σ a·b − ā·b̄
|
||||
/// var_b = (1/n) · Σ b² − b̄²
|
||||
/// Beta = cov_ab / var_b
|
||||
/// ```
|
||||
///
|
||||
/// Beta measures how much the asset moves for a unit move in the
|
||||
/// benchmark. A reading of `1.0` means the two move together one-for-one;
|
||||
/// `2.0` means the asset typically doubles the benchmark's moves;
|
||||
/// `0.5` means it moves only half as much; `0.0` means moves are
|
||||
/// uncorrelated; negative Betas signal a hedge. It is the slope of the
|
||||
/// OLS regression of the asset on the benchmark and the foundation of the
|
||||
/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free —
|
||||
/// it carries the ratio of standard deviations.
|
||||
///
|
||||
/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`)
|
||||
/// are maintained as the window slides. A flat benchmark window has zero
|
||||
/// variance and Beta is undefined; the indicator returns `0` in that
|
||||
/// case rather than producing `NaN`.
|
||||
///
|
||||
/// Conventionally Beta is computed on **returns** (typically log-returns)
|
||||
/// rather than raw prices; feed the indicator pre-computed returns if
|
||||
/// that is your convention. The pure rolling OLS slope is the same
|
||||
/// either way.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Beta, Indicator};
|
||||
///
|
||||
/// let mut indicator = Beta::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // Asset doubles every benchmark move.
|
||||
/// last = indicator.update((2.0 * f64::from(i), f64::from(i)));
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Beta {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl Beta {
|
||||
/// Construct a new rolling Beta.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "beta needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Beta {
|
||||
/// `(asset, benchmark)` pair.
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_a -= oa;
|
||||
self.sum_b -= ob;
|
||||
self.sum_bb -= ob * ob;
|
||||
self.sum_ab -= oa * ob;
|
||||
}
|
||||
self.window.push_back((a, b));
|
||||
self.sum_a += a;
|
||||
self.sum_b += b;
|
||||
self.sum_bb += b * b;
|
||||
self.sum_ab += a * b;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_a = self.sum_a / n;
|
||||
let mean_b = self.sum_b / n;
|
||||
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
|
||||
let cov = self.sum_ab / n - mean_a * mean_b;
|
||||
if var_b == 0.0 {
|
||||
// A flat benchmark has no defined beta.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(cov / var_b)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_a = 0.0;
|
||||
self.sum_b = 0.0;
|
||||
self.sum_bb = 0.0;
|
||||
self.sum_ab = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Beta"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(Beta::new(0).is_err());
|
||||
assert!(Beta::new(1).is_err());
|
||||
assert!(Beta::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let b = Beta::new(14).unwrap();
|
||||
assert_eq!(b.period(), 14);
|
||||
assert_eq!(b.warmup_period(), 14);
|
||||
assert_eq!(b.name(), "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_two_to_one_relationship() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10)
|
||||
.map(|i| (2.0 * f64::from(i), f64::from(i)))
|
||||
.collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_negative_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_benchmark_yields_zero() {
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = Beta::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut b = Beta::new(5).unwrap();
|
||||
b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
|
||||
assert!(b.is_ready());
|
||||
b.reset();
|
||||
assert!(!b.is_ready());
|
||||
assert_eq!(b.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 2.0 + 0.3 * t.cos(), t.sin())
|
||||
})
|
||||
.collect();
|
||||
let batch = Beta::new(14).unwrap().batch(&pairs);
|
||||
let mut b = Beta::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,27 @@ pub struct BollingerOutput {
|
||||
/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
|
||||
/// publication uses population (not sample) standard deviation, which matches every
|
||||
/// reference implementation (TA-Lib, pandas-ta, etc.).
|
||||
///
|
||||
/// The running `sum` and `sum_sq` are reseeded from the live window every
|
||||
/// `16 · period` updates to cap floating-point drift on long streams. This is
|
||||
/// amortised O(1), preserves bit-equivalence with the previous behaviour on
|
||||
/// inputs that did not drift, and is particularly important for `sum_sq`,
|
||||
/// where catastrophic cancellation between large add/subtract pairs can drive
|
||||
/// the computed variance negative (the `.max(0.0)` clamp below is the
|
||||
/// safety-net for the rare cases where the reseed has not happened yet).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BollingerBands};
|
||||
///
|
||||
/// let mut indicator = BollingerBands::new(5, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBands {
|
||||
period: usize,
|
||||
@@ -31,8 +52,17 @@ pub struct BollingerBands {
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
/// Number of finite updates since the running sums were last reseeded
|
||||
/// from the live window. See [`RECOMPUTE_EVERY`] below.
|
||||
updates_since_recompute: usize,
|
||||
}
|
||||
|
||||
/// How often (in finite updates) the incremental `sum` / `sum_sq` are reseeded
|
||||
/// from the live window. The multiplier `16` keeps the amortised cost flat and
|
||||
/// caps any cancellation drift to roughly `16 · period · ULP · max(|x|²)` —
|
||||
/// negligible on real-world price scales.
|
||||
const RECOMPUTE_EVERY: usize = 16;
|
||||
|
||||
impl BollingerBands {
|
||||
/// Construct a new Bollinger Bands indicator.
|
||||
///
|
||||
@@ -53,6 +83,7 @@ impl BollingerBands {
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
updates_since_recompute: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +137,12 @@ impl Indicator for BollingerBands {
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
|
||||
self.sum = self.window.iter().copied().sum();
|
||||
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
self.current()
|
||||
}
|
||||
|
||||
@@ -113,6 +150,7 @@ impl Indicator for BollingerBands {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
@@ -134,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]
|
||||
@@ -174,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();
|
||||
@@ -203,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);
|
||||
@@ -240,4 +293,59 @@ mod tests {
|
||||
bb.reset();
|
||||
assert!(!bb.is_ready());
|
||||
}
|
||||
|
||||
/// Long-running stability check. After several recompute cycles the
|
||||
/// reported Bollinger bands must still equal a fresh from-scratch
|
||||
/// computation over the live window — even on inputs designed to cause
|
||||
/// catastrophic cancellation in the `sum_sq` accumulator (alternating
|
||||
/// between two very different magnitudes).
|
||||
#[test]
|
||||
fn long_stream_drift_stays_bounded() {
|
||||
let period = 20;
|
||||
let mult = 2.0;
|
||||
let mut bb = BollingerBands::new(period, mult).unwrap();
|
||||
let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
|
||||
// Forces the periodic reseed to fire 5+ times.
|
||||
let n_updates = 16 * period * 5;
|
||||
let mut last = None;
|
||||
for i in 0..n_updates {
|
||||
let v = if i % 2 == 0 { 1e6 } else { 1.0 };
|
||||
last = bb.update(v);
|
||||
if window.len() == period {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(v);
|
||||
}
|
||||
let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
|
||||
let got = last.expect("warmed up");
|
||||
assert!(
|
||||
(got.middle - scratch.middle).abs() < 1e-3,
|
||||
"middle drift: got={}, scratch={}",
|
||||
got.middle,
|
||||
scratch.middle,
|
||||
);
|
||||
assert!(
|
||||
(got.stddev - scratch.stddev).abs() < 1e-3,
|
||||
"stddev drift: got={}, scratch={}",
|
||||
got.stddev,
|
||||
scratch.stddev,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||
let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let last = ready.last().unwrap().unwrap();
|
||||
// Non-finite inputs return the current bands without mutating the window.
|
||||
assert_eq!(bb.update(f64::NAN).unwrap(), last);
|
||||
assert_eq!(bb.update(f64::INFINITY).unwrap(), last);
|
||||
// The window still holds 1..=5, so a real input slides it to 2..=6.
|
||||
let after = bb.update(6.0).unwrap();
|
||||
assert_relative_eq!(
|
||||
after.middle,
|
||||
(2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Bollinger Bandwidth.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::BollingerBands;
|
||||
|
||||
/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the
|
||||
/// middle band.
|
||||
///
|
||||
/// ```text
|
||||
/// Bandwidth = (upper − lower) / middle
|
||||
/// ```
|
||||
///
|
||||
/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is
|
||||
/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its
|
||||
/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a
|
||||
/// multi-month low, signalling a coiled, low-volatility market about to
|
||||
/// expand) and the **bulge** (bandwidth at an extreme high).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BollingerBandwidth};
|
||||
///
|
||||
/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BollingerBandwidth {
|
||||
bands: BollingerBands,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BollingerBandwidth {
|
||||
/// Construct a new Bollinger Bandwidth indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] for `period == 0` and
|
||||
/// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
Ok(Self {
|
||||
bands: BollingerBands::new(period, multiplier)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.bands.period()
|
||||
}
|
||||
|
||||
/// Configured multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.bands.multiplier()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BollingerBandwidth {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let o = self.bands.update(input)?;
|
||||
let bandwidth = if o.middle == 0.0 {
|
||||
// Undefined against a zero middle band.
|
||||
0.0
|
||||
} else {
|
||||
(o.upper - o.lower) / o.middle
|
||||
};
|
||||
self.last = Some(bandwidth);
|
||||
Some(bandwidth)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.bands.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BollingerBandwidth"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_invalid_parameters() {
|
||||
assert!(BollingerBandwidth::new(0, 2.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, 0.0).is_err());
|
||||
assert!(BollingerBandwidth::new(20, -1.0).is_err());
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
let out = bbw.batch(&[100.0; 20]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect();
|
||||
let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
|
||||
for (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
|
||||
.collect();
|
||||
for v in bbw.batch(&prices).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
|
||||
bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bbw.is_ready());
|
||||
bbw.reset();
|
||||
assert!(!bbw.is_ready());
|
||||
assert_eq!(bbw.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
|
||||
.collect();
|
||||
let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
|
||||
let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Rolling Calmar Ratio — return over max drawdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Calmar Ratio.
|
||||
///
|
||||
/// Input is treated as a single period return. Over the trailing window of
|
||||
/// `period` returns the indicator reconstructs the implied equity curve
|
||||
/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
|
||||
/// divides the mean return by that drawdown:
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = ∏(1 + r_i) for i in window up to t
|
||||
/// mdd = max peak-to-trough decline of equity over window
|
||||
/// Calmar = mean(returns) / mdd
|
||||
/// ```
|
||||
///
|
||||
/// If the drawdown is zero (monotonically non-decreasing equity in the
|
||||
/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
|
||||
///
|
||||
/// The equity curve is recomputed inside the window each `update`, which
|
||||
/// keeps each call O(period) — acceptable for typical backtest windows
|
||||
/// (`period ≤ 252`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CalmarRatio, Indicator};
|
||||
///
|
||||
/// let mut cr = CalmarRatio::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalmarRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
}
|
||||
|
||||
impl CalmarRatio {
|
||||
/// Construct a new rolling Calmar Ratio.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "calmar ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CalmarRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
// Build equity curve and track the worst peak-to-trough drawdown.
|
||||
let mut equity = 1.0_f64;
|
||||
let mut peak = 1.0_f64;
|
||||
let mut mdd = 0.0_f64;
|
||||
for &r in &self.window {
|
||||
equity *= 1.0 + r;
|
||||
if equity > peak {
|
||||
peak = equity;
|
||||
}
|
||||
// peak starts at 1.0 and never decreases, so peak > 0 by construction.
|
||||
let dd = (peak - equity) / peak;
|
||||
if dd > mdd {
|
||||
mdd = dd;
|
||||
}
|
||||
}
|
||||
if mdd == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(mean / mdd)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CalmarRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
CalmarRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = CalmarRatio::new(10).unwrap();
|
||||
assert_eq!(c.period(), 10);
|
||||
assert_eq!(c.name(), "CalmarRatio");
|
||||
assert_eq!(c.warmup_period(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_zero() {
|
||||
// All positive returns -> no drawdown -> Calmar = 0 by convention.
|
||||
let mut c = CalmarRatio::new(5).unwrap();
|
||||
let out = c.batch(&[0.01; 10]);
|
||||
for v in out.into_iter().flatten() {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns = [0.10, -0.20, 0.05]
|
||||
// equity: 1.0 -> 1.10 -> 0.88 -> 0.924
|
||||
// peak 1.10, trough 0.88 -> mdd = 0.20.
|
||||
// mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
|
||||
// Calmar = -0.01666... / 0.20 ≈ -0.08333...
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
let out = c.batch(&[0.10, -0.20, 0.05]);
|
||||
let mean = (0.10 - 0.20 + 0.05) / 3.0;
|
||||
let expected = mean / 0.20;
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
assert_eq!(c.update(f64::NAN), None);
|
||||
assert_eq!(c.update(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = CalmarRatio::new(3).unwrap();
|
||||
c.batch(&[0.10, -0.20, 0.05]);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let returns: Vec<f64> = (0..50)
|
||||
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = CalmarRatio::new(10).unwrap().batch(&returns);
|
||||
let mut s = CalmarRatio::new(10).unwrap();
|
||||
let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Camarilla Pivot Points (Nick Stott).
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Camarilla Pivot Points output: four resistances, the pivot, four supports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CamarillaPivotsOutput {
|
||||
/// Pivot Point: `(H + L + C) / 3` (informational, not in the Camarilla R/S formulas).
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `C + (H − L)·1.1/12`.
|
||||
pub r1: f64,
|
||||
/// Resistance 2: `C + (H − L)·1.1/6`.
|
||||
pub r2: f64,
|
||||
/// Resistance 3: `C + (H − L)·1.1/4`.
|
||||
pub r3: f64,
|
||||
/// Resistance 4: `C + (H − L)·1.1/2`.
|
||||
pub r4: f64,
|
||||
/// Support 1: `C − (H − L)·1.1/12`.
|
||||
pub s1: f64,
|
||||
/// Support 2: `C − (H − L)·1.1/6`.
|
||||
pub s2: f64,
|
||||
/// Support 3: `C − (H − L)·1.1/4`.
|
||||
pub s3: f64,
|
||||
/// Support 4: `C − (H − L)·1.1/2`.
|
||||
pub s4: f64,
|
||||
}
|
||||
|
||||
/// Camarilla Pivot Points — Nick Stott's four-tier range-based level set.
|
||||
/// Anchored on the prior close rather than the typical price, with widths
|
||||
/// scaled by the constant `1.1` divided by `{12, 6, 4, 2}`.
|
||||
///
|
||||
/// ```text
|
||||
/// PP = (H + L + C) / 3
|
||||
/// R_n = C + (H − L) · 1.1 / d_n S_n = C − (H − L) · 1.1 / d_n
|
||||
/// where d_1 = 12, d_2 = 6, d_3 = 4, d_4 = 2
|
||||
/// ```
|
||||
///
|
||||
/// R3/S3 are typically used as reversal levels; R4/S4 as breakout levels. As
|
||||
/// with the other pivot variants there are no parameters and no warmup — the
|
||||
/// first candle produces the first set of levels.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Camarilla, Candle, Indicator};
|
||||
///
|
||||
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
|
||||
/// let levels = Camarilla::new().update(prev).unwrap();
|
||||
/// assert!(levels.r4 > levels.r3);
|
||||
/// assert!(levels.s4 < levels.s3);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Camarilla {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl Camarilla {
|
||||
/// Construct a new Camarilla Pivot Points indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
const CAM: f64 = 1.1;
|
||||
|
||||
impl Indicator for Camarilla {
|
||||
type Input = Candle;
|
||||
type Output = CamarillaPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CamarillaPivotsOutput> {
|
||||
let (h, l, c) = (candle.high, candle.low, candle.close);
|
||||
let range = h - l;
|
||||
let pp = (h + l + c) / 3.0;
|
||||
let w1 = range * CAM / 12.0;
|
||||
let w2 = range * CAM / 6.0;
|
||||
let w3 = range * CAM / 4.0;
|
||||
let w4 = range * CAM / 2.0;
|
||||
let out = CamarillaPivotsOutput {
|
||||
pp,
|
||||
r1: c + w1,
|
||||
r2: c + w2,
|
||||
r3: c + w3,
|
||||
r4: c + w4,
|
||||
s1: c - w1,
|
||||
s2: c - w2,
|
||||
s3: c - w3,
|
||||
s4: c - w4,
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Camarilla"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, h, l, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, C=105, range=20.
|
||||
let levels = Camarilla::new().update(c(110.0, 90.0, 105.0, 0)).unwrap();
|
||||
let range = 20.0;
|
||||
assert!((levels.r1 - (105.0 + range * 1.1 / 12.0)).abs() < 1e-12);
|
||||
assert!((levels.r2 - (105.0 + range * 1.1 / 6.0)).abs() < 1e-12);
|
||||
assert!((levels.r3 - (105.0 + range * 1.1 / 4.0)).abs() < 1e-12);
|
||||
assert!((levels.r4 - (105.0 + range * 1.1 / 2.0)).abs() < 1e-12);
|
||||
assert!((levels.s1 - (105.0 - range * 1.1 / 12.0)).abs() < 1e-12);
|
||||
assert!((levels.s4 - (105.0 - range * 1.1 / 2.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resistance_strictly_widens_with_index() {
|
||||
let levels = Camarilla::new().update(c(120.0, 80.0, 110.0, 0)).unwrap();
|
||||
assert!(levels.r4 > levels.r3);
|
||||
assert!(levels.r3 > levels.r2);
|
||||
assert!(levels.r2 > levels.r1);
|
||||
assert!(levels.r1 > 110.0);
|
||||
assert!(levels.s1 < 110.0);
|
||||
assert!(levels.s2 < levels.s1);
|
||||
assert!(levels.s3 < levels.s2);
|
||||
assert!(levels.s4 < levels.s3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
let levels = Camarilla::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
|
||||
assert_eq!(levels.r4, 50.0);
|
||||
assert_eq!(levels.s4, 50.0);
|
||||
assert_eq!(levels.pp, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_and_ready() {
|
||||
let mut p = Camarilla::new();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = Camarilla::new();
|
||||
p.update(c(11.0, 9.0, 10.0, 0));
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0_i32..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
f64::from(i) + 2.0,
|
||||
f64::from(i),
|
||||
f64::from(i) + 1.0,
|
||||
i.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Camarilla::new();
|
||||
let mut b = Camarilla::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = Camarilla::new();
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
assert_eq!(p.name(), "Camarilla");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,22 @@ use crate::traits::Indicator;
|
||||
///
|
||||
/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
|
||||
/// `TP = (high + low + close) / 3`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Cci};
|
||||
///
|
||||
/// let mut indicator = Cci::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cci {
|
||||
period: usize,
|
||||
@@ -122,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)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Ehlers Center of Gravity Oscillator.
|
||||
#![allow(clippy::manual_midpoint)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Center of Gravity (CG) oscillator.
|
||||
///
|
||||
/// Treats the most recent `period` prices as masses and reports the
|
||||
/// weighted "center" of that mass distribution, negated so positive readings
|
||||
/// correspond to recent strength:
|
||||
///
|
||||
/// ```text
|
||||
/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
|
||||
/// den = sum_{k=0..period-1} price[t - k]
|
||||
/// cg = - num / den + (period + 1) / 2
|
||||
/// ```
|
||||
///
|
||||
/// The constant offset centres the oscillator around zero. From Ehlers,
|
||||
/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CenterOfGravity};
|
||||
///
|
||||
/// let mut cg = CenterOfGravity::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CenterOfGravity {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl CenterOfGravity {
|
||||
/// Construct with the rolling window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CenterOfGravity {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// Most recent has weight 1; oldest has weight `period`.
|
||||
let mut num = 0.0;
|
||||
let mut den = 0.0;
|
||||
for (k, p) in self.window.iter().rev().enumerate() {
|
||||
let w = 1.0 + k as f64;
|
||||
num += w * p;
|
||||
den += p;
|
||||
}
|
||||
let v = if den.abs() > f64::EPSILON {
|
||||
-num / den + (self.period as f64 + 1.0) / 2.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CenterOfGravity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut cg = CenterOfGravity::new(10).unwrap();
|
||||
assert_eq!(cg.period(), 10);
|
||||
assert_eq!(cg.warmup_period(), 10);
|
||||
assert_eq!(cg.name(), "CenterOfGravity");
|
||||
assert!(!cg.is_ready());
|
||||
for i in 1..=10 {
|
||||
cg.update(f64::from(i));
|
||||
}
|
||||
assert!(cg.is_ready());
|
||||
assert!(cg.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// num = sum k * p, den = period * p, ratio = (period + 1) / 2,
|
||||
// so cg = - (period+1)/2 + (period+1)/2 = 0.
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
let out = cg.batch(&[7.0_f64; 30]);
|
||||
for x in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=50).map(f64::from).collect();
|
||||
let mut a = CenterOfGravity::new(10).unwrap();
|
||||
let mut b = CenterOfGravity::new(10).unwrap();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
let before = cg.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(cg.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(cg.is_ready());
|
||||
cg.reset();
|
||||
assert!(!cg.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none_until_seed() {
|
||||
let mut cg = CenterOfGravity::new(4).unwrap();
|
||||
assert_eq!(cg.update(1.0), None);
|
||||
assert_eq!(cg.update(2.0), None);
|
||||
assert_eq!(cg.update(3.0), None);
|
||||
assert!(cg.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_window_uses_zero_fallback() {
|
||||
// den == sum(prices) == 0 when the rolling window is all zeros, which
|
||||
// exercises the protective fallback in the divisor guard.
|
||||
let mut cg = CenterOfGravity::new(5).unwrap();
|
||||
let out = cg.batch(&[0.0_f64; 10]);
|
||||
for x in out.iter().skip(5).flatten() {
|
||||
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Chande Forecast Oscillator (CFO).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::linreg::LinearRegression;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Tushar Chande's Forecast Oscillator — the percentage difference between
|
||||
/// the close and the endpoint of an `n`-bar linear-regression forecast of the
|
||||
/// close.
|
||||
///
|
||||
/// ```text
|
||||
/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t
|
||||
/// ```
|
||||
///
|
||||
/// Positive readings mean the close is *above* the linear forecast (price has
|
||||
/// overshot trend); negative readings mean it sits below. Wraps the existing
|
||||
/// `LinearRegression` so the warmup matches.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Cfo, Indicator};
|
||||
///
|
||||
/// let mut cfo = Cfo::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = cfo.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cfo {
|
||||
period: usize,
|
||||
linreg: LinearRegression,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Cfo {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
linreg: LinearRegression::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Cfo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let forecast = self.linreg.update(input)?;
|
||||
// Hold the previous value if the close is zero — the percentage form
|
||||
// is undefined and a return of inf would propagate badly.
|
||||
if input == 0.0 {
|
||||
return self.current;
|
||||
}
|
||||
let value = 100.0 * (input - forecast) / input;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.linreg.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CFO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cfo = Cfo::new(14).unwrap();
|
||||
assert_eq!(cfo.period(), 14);
|
||||
assert_eq!(cfo.warmup_period(), 14);
|
||||
assert_eq!(cfo.name(), "CFO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// LinReg of a constant series equals the constant, so close − forecast
|
||||
// is 0 and CFO is 0.
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
let out = cfo.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_linear_series_yields_zero() {
|
||||
// LinReg of a perfectly linear input fits the line exactly, so the
|
||||
// close lands on the forecast and CFO = 0.
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
|
||||
let out = cfo.batch(&prices);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut cfo = Cfo::new(3).unwrap();
|
||||
for i in 1..=2 {
|
||||
assert_eq!(cfo.update(f64::from(i)), None);
|
||||
}
|
||||
assert!(cfo.update(3.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Cfo::new(14).unwrap();
|
||||
let mut b = Cfo::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cfo = Cfo::new(5).unwrap();
|
||||
cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(cfo.is_ready());
|
||||
cfo.reset();
|
||||
assert!(!cfo.is_ready());
|
||||
assert_eq!(cfo.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_close_holds_value() {
|
||||
let mut cfo = Cfo::new(3).unwrap();
|
||||
cfo.batch(&[1.0_f64, 2.0, 3.0]);
|
||||
let before = cfo.current;
|
||||
assert_eq!(cfo.update(0.0), before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Chaikin Oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::adl::Adl;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line.
|
||||
///
|
||||
/// ```text
|
||||
/// ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t
|
||||
/// ```
|
||||
///
|
||||
/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a
|
||||
/// zero-centred momentum oscillator: positive when short-term accumulation
|
||||
/// outpaces the longer trend, negative when distribution leads. Because the
|
||||
/// ADL emits from the very first candle, the slow EMA gates the first output —
|
||||
/// the warmup period is exactly `slow`. Chaikin's classic configuration is
|
||||
/// `fast = 3`, `slow = 10`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinOscillator};
|
||||
///
|
||||
/// let mut indicator = ChaikinOscillator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChaikinOscillator {
|
||||
adl: Adl,
|
||||
fast: Ema,
|
||||
slow: Ema,
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
}
|
||||
|
||||
impl ChaikinOscillator {
|
||||
/// Construct a Chaikin Oscillator with explicit fast / slow EMA periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if either period is zero, or
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Chaikin Oscillator needs fast < slow",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
adl: Adl::new(),
|
||||
fast: Ema::new(fast)?,
|
||||
slow: Ema::new(slow)?,
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
})
|
||||
}
|
||||
|
||||
/// Chaikin's classic configuration: `EMA(ADL, 3) − EMA(ADL, 10)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(3, 10).expect("classic Chaikin Oscillator params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(fast, slow)` periods.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.fast_period, self.slow_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
// The ADL emits a value from the very first candle, so both EMAs are
|
||||
// fed on every bar and warm up in parallel.
|
||||
let adl = self.adl.update(candle)?;
|
||||
let fast = self.fast.update(adl);
|
||||
let slow = self.slow.update(adl);
|
||||
Some(fast? - slow?)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.adl.reset();
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ADL is ready at candle 1; the slow EMA gates the first emission.
|
||||
self.slow_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.fast.is_ready() && self.slow.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChaikinOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
fn flat(price: f64, ts: i64) -> Candle {
|
||||
Candle::new(price, price, price, price, 100.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_adl_and_emas() {
|
||||
// The oscillator must equal feeding a standalone ADL into two
|
||||
// standalone EMAs and differencing them once both are ready.
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
|
||||
Candle::new(
|
||||
mid,
|
||||
mid + 1.5,
|
||||
mid - 1.5,
|
||||
mid + 0.3,
|
||||
10.0 + (i % 6) as f64,
|
||||
i,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
let mut adl = Adl::new();
|
||||
let mut fast = Ema::new(3).unwrap();
|
||||
let mut slow = Ema::new(10).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = osc.update(*candle);
|
||||
let a = adl.update(*candle).expect("ADL emits from candle 1");
|
||||
let f = fast.update(a);
|
||||
let s = slow.update(a);
|
||||
match (f, s) {
|
||||
(Some(fv), Some(sv)) => {
|
||||
assert_relative_eq!(
|
||||
got.expect("oscillator ready once slow EMA is"),
|
||||
fv - sv,
|
||||
epsilon = 1e-9
|
||||
);
|
||||
}
|
||||
_ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_yields_zero() {
|
||||
// A flat candle has zero money-flow volume, so the ADL never moves and
|
||||
// both EMAs of a constant-zero series stay at zero.
|
||||
let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
let out = osc.batch(&candles);
|
||||
assert_eq!(osc.warmup_period(), 10);
|
||||
for (i, v) in out.iter().enumerate().take(9) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChaikinOscillator::new(0, 10).is_err());
|
||||
assert!(ChaikinOscillator::new(3, 0).is_err());
|
||||
assert!(ChaikinOscillator::new(10, 3).is_err());
|
||||
assert!(ChaikinOscillator::new(5, 5).is_err());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut osc = ChaikinOscillator::classic();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
Candle::new(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinOscillator::classic();
|
||||
let mut b = ChaikinOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Chaikin Volatility.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::roc::Roc;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Volatility — the rate of change of a smoothed high-low spread.
|
||||
///
|
||||
/// ```text
|
||||
/// spread_t = high_t − low_t
|
||||
/// smoothed_t = EMA(spread, ema_period)_t
|
||||
/// ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period}
|
||||
/// ```
|
||||
///
|
||||
/// Marc Chaikin's volatility measure tracks not the *level* of the trading
|
||||
/// range but how fast it is *widening or narrowing*. A rising value means
|
||||
/// ranges are expanding (often near a top, as fear spikes); a falling value
|
||||
/// means they are contracting (often a quiet, complacent market). The classic
|
||||
/// configuration smooths the spread with a `10`-period EMA and takes its
|
||||
/// `10`-period rate of change.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinVolatility};
|
||||
///
|
||||
/// let mut indicator = ChaikinVolatility::new(10, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChaikinVolatility {
|
||||
ema: Ema,
|
||||
roc: Roc,
|
||||
ema_period: usize,
|
||||
roc_period: usize,
|
||||
}
|
||||
|
||||
impl ChaikinVolatility {
|
||||
/// Construct a Chaikin Volatility with explicit EMA and rate-of-change
|
||||
/// periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period
|
||||
/// is zero.
|
||||
pub fn new(ema_period: usize, roc_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ema: Ema::new(ema_period)?,
|
||||
roc: Roc::new(roc_period)?,
|
||||
ema_period,
|
||||
roc_period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 10).expect("classic Chaikin Volatility params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ema_period, roc_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.ema_period, self.roc_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinVolatility {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let spread = candle.high - candle.low;
|
||||
let smoothed = self.ema.update(spread)?;
|
||||
self.roc.update(smoothed)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
self.roc.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The EMA emits at candle `ema_period`; the ROC then needs
|
||||
// `roc_period` more smoothed values to span its lookback.
|
||||
self.ema_period + self.roc_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.roc.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChaikinVolatility"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_range_yields_zero() {
|
||||
// A constant high-low spread smooths to a constant EMA, whose rate of
|
||||
// change is zero.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
for v in cv.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_range_reads_positive() {
|
||||
// Each bar's range is strictly wider than the last -> expanding
|
||||
// volatility -> positive Chaikin Volatility.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let half = 1.0 + i as f64 * 0.1;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
for v in cv.batch(&candles).into_iter().flatten() {
|
||||
assert!(v > 0.0, "an expanding range should read positive, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ema_and_roc() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(10, 10).unwrap();
|
||||
let mut ema = Ema::new(10).unwrap();
|
||||
let mut roc = Roc::new(10).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = cv.update(*candle);
|
||||
match ema.update(candle.high - candle.low) {
|
||||
Some(e) => {
|
||||
let want = roc.update(e);
|
||||
assert_eq!(got, want, "i={i}");
|
||||
}
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::new(5, 5).unwrap();
|
||||
let out = cv.batch(&candles);
|
||||
assert_eq!(cv.warmup_period(), 10);
|
||||
for (i, v) in out.iter().enumerate().take(9) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ChaikinVolatility::new(0, 10).is_err());
|
||||
assert!(ChaikinVolatility::new(10, 0).is_err());
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cv = ChaikinVolatility::classic();
|
||||
cv.batch(&candles);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0;
|
||||
c(100.0 + half, 100.0 - half, 100.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinVolatility::classic();
|
||||
let mut b = ChaikinVolatility::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Chande Kroll Stop.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chande Kroll Stop output: the long-side and short-side stop levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChandeKrollStopOutput {
|
||||
/// Long-position stop — the lowest preliminary low-stop over `stop_period`.
|
||||
pub stop_long: f64,
|
||||
/// Short-position stop — the highest preliminary high-stop over `stop_period`.
|
||||
pub stop_short: f64,
|
||||
}
|
||||
|
||||
/// Chande Kroll Stop — Tushar Chande and Stanley Kroll's two-stage ATR stop.
|
||||
///
|
||||
/// ```text
|
||||
/// preliminary (window p = atr_period, x = atr_multiplier):
|
||||
/// high_stop = highest_high(p) − x · ATR(p)
|
||||
/// low_stop = lowest_low(p) + x · ATR(p)
|
||||
///
|
||||
/// final (window q = stop_period):
|
||||
/// stop_short = highest(high_stop, q)
|
||||
/// stop_long = lowest(low_stop, q)
|
||||
/// ```
|
||||
///
|
||||
/// The first stage builds an ATR stop off the recent extreme, exactly like a
|
||||
/// [`ChandelierExit`](crate::ChandelierExit); the second stage smooths it by
|
||||
/// taking the most extreme preliminary stop over a shorter window, which keeps
|
||||
/// the stop from whipsawing on a single wide bar. The classic configuration
|
||||
/// from *The New Technical Trader* is `ATR(10)`, multiplier `1.0`, smoothing
|
||||
/// window `9`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChandeKrollStop};
|
||||
///
|
||||
/// let mut indicator = ChandeKrollStop::new(10, 1.0, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChandeKrollStop {
|
||||
atr_period: usize,
|
||||
atr_multiplier: f64,
|
||||
stop_period: usize,
|
||||
atr: Atr,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
high_stops: VecDeque<f64>,
|
||||
low_stops: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChandeKrollStop {
|
||||
/// Construct a Chande Kroll Stop with explicit ATR and smoothing windows.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period` or `stop_period` is zero,
|
||||
/// and [`Error::NonPositiveMultiplier`] if `atr_multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, atr_multiplier: f64, stop_period: usize) -> Result<Self> {
|
||||
if !atr_multiplier.is_finite() || atr_multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
if stop_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
atr_period,
|
||||
atr_multiplier,
|
||||
stop_period,
|
||||
atr: Atr::new(atr_period)?,
|
||||
highs: VecDeque::with_capacity(atr_period),
|
||||
lows: VecDeque::with_capacity(atr_period),
|
||||
high_stops: VecDeque::with_capacity(stop_period),
|
||||
low_stops: VecDeque::with_capacity(stop_period),
|
||||
})
|
||||
}
|
||||
|
||||
/// The classic configuration: `ATR(10)`, multiplier `1.0`, window `9`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(10, 1.0, 9).expect("classic Chande Kroll Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, atr_multiplier, stop_period)`.
|
||||
pub const fn params(&self) -> (usize, f64, usize) {
|
||||
(self.atr_period, self.atr_multiplier, self.stop_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChandeKrollStop {
|
||||
type Input = Candle;
|
||||
type Output = ChandeKrollStopOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ChandeKrollStopOutput> {
|
||||
let atr = self.atr.update(candle);
|
||||
if self.highs.len() == self.atr_period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
if self.highs.len() < self.atr_period {
|
||||
return None;
|
||||
}
|
||||
// ATR(atr_period) becomes ready on exactly the candle that fills the
|
||||
// preliminary window, so this never discards a value.
|
||||
let atr = atr?;
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let high_stop = highest - self.atr_multiplier * atr;
|
||||
let low_stop = lowest + self.atr_multiplier * atr;
|
||||
|
||||
if self.high_stops.len() == self.stop_period {
|
||||
self.high_stops.pop_front();
|
||||
self.low_stops.pop_front();
|
||||
}
|
||||
self.high_stops.push_back(high_stop);
|
||||
self.low_stops.push_back(low_stop);
|
||||
if self.high_stops.len() < self.stop_period {
|
||||
return None;
|
||||
}
|
||||
let stop_short = self
|
||||
.high_stops
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let stop_long = self.low_stops.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Some(ChandeKrollStopOutput {
|
||||
stop_long,
|
||||
stop_short,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
self.high_stops.clear();
|
||||
self.low_stops.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The preliminary stop first appears on candle `atr_period`; the
|
||||
// smoothing window then needs `stop_period` of them.
|
||||
self.atr_period + self.stop_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.high_stops.len() == self.stop_period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChandeKrollStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
|
||||
// high_stop = 11 - 1·2 = 9; low_stop = 9 + 1·2 = 11.
|
||||
// stop_short = highest(high_stop, q) = 9; stop_long = lowest(low_stop, q) = 11.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut cks = ChandeKrollStop::new(5, 1.0, 3).unwrap();
|
||||
let last = cks.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.stop_short, 9.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.stop_long, 11.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..16)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cks = ChandeKrollStop::new(4, 1.0, 3).unwrap();
|
||||
let out = cks.batch(&candles);
|
||||
assert_eq!(cks.warmup_period(), 6);
|
||||
for (i, v) in out.iter().enumerate().take(5) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[5].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChandeKrollStop::new(0, 1.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, 1.0, 0).is_err());
|
||||
assert!(ChandeKrollStop::new(10, 0.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, -1.0, 9).is_err());
|
||||
assert!(ChandeKrollStop::new(10, f64::NAN, 9).is_err());
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut cks = ChandeKrollStop::classic();
|
||||
cks.batch(&candles);
|
||||
assert!(cks.is_ready());
|
||||
cks.reset();
|
||||
assert!(!cks.is_ready());
|
||||
assert_eq!(cks.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChandeKrollStop::classic();
|
||||
let mut b = ChandeKrollStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Chandelier Exit.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chandelier Exit output: the long-side and short-side trailing stops.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChandelierExitOutput {
|
||||
/// Long-position stop: `highest_high − multiplier · ATR`.
|
||||
pub long_stop: f64,
|
||||
/// Short-position stop: `lowest_low + multiplier · ATR`.
|
||||
pub short_stop: f64,
|
||||
}
|
||||
|
||||
/// Chandelier Exit — Chuck LeBeau's ATR trailing stop, hung from the highest
|
||||
/// high (for longs) or the lowest low (for shorts) of the lookback window.
|
||||
///
|
||||
/// ```text
|
||||
/// long_stop = highest_high(period) − multiplier · ATR(period)
|
||||
/// short_stop = lowest_low(period) + multiplier · ATR(period)
|
||||
/// ```
|
||||
///
|
||||
/// A long position is exited when price closes below `long_stop`; a short
|
||||
/// when it closes above `short_stop`. Because the stop hangs a fixed number
|
||||
/// of ATRs off the extreme of the window — like a chandelier off a ceiling —
|
||||
/// it follows price up but never loosens. LeBeau's classic configuration is a
|
||||
/// `22`-bar window with a `3.0` multiplier.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChandelierExit};
|
||||
///
|
||||
/// let mut indicator = ChandelierExit::new(22, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChandelierExit {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
atr: Atr,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChandelierExit {
|
||||
/// Construct a Chandelier Exit with an explicit window and band multiplier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
atr: Atr::new(period)?,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// LeBeau's classic configuration: a `22`-bar window, `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(22, 3.0).expect("classic Chandelier Exit params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChandelierExit {
|
||||
type Input = Candle;
|
||||
type Output = ChandelierExitOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ChandelierExitOutput> {
|
||||
let atr = self.atr.update(candle);
|
||||
if self.highs.len() == self.period {
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
if self.highs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// ATR(period) becomes ready on exactly the candle that fills the
|
||||
// highest-high / lowest-low window, so this never discards a value.
|
||||
let atr = atr?;
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Some(ChandelierExitOutput {
|
||||
long_stop: highest - self.multiplier * atr,
|
||||
short_stop: lowest + self.multiplier * atr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.highs.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChandelierExit"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; HH=11, LL=9.
|
||||
// long_stop = 11 - 3·2 = 5; short_stop = 9 + 3·2 = 15.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ce = ChandelierExit::new(5, 3.0).unwrap();
|
||||
let last = ce.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.long_stop, 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.short_stop, 15.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_stop_below_highest_short_stop_above_lowest() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.2).sin() * 9.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.4, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ce = ChandelierExit::classic();
|
||||
for (i, o) in ce.batch(&candles).into_iter().enumerate() {
|
||||
if let Some(o) = o {
|
||||
// The window's extremes bound the stops from one side.
|
||||
let win = &candles[i + 1 - 22..=i];
|
||||
let hh = win.iter().map(|c| c.high).fold(f64::NEG_INFINITY, f64::max);
|
||||
let ll = win.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
|
||||
assert!(o.long_stop <= hh + 1e-9);
|
||||
assert!(o.short_stop >= ll - 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ce = ChandelierExit::new(8, 3.0).unwrap();
|
||||
let out = ce.batch(&candles);
|
||||
assert_eq!(ce.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(ChandelierExit::new(0, 3.0).is_err());
|
||||
assert!(ChandelierExit::new(22, 0.0).is_err());
|
||||
assert!(ChandelierExit::new(22, -1.0).is_err());
|
||||
assert!(ChandelierExit::new(22, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ce = ChandelierExit::classic();
|
||||
ce.batch(&candles);
|
||||
assert!(ce.is_ready());
|
||||
ce.reset();
|
||||
assert!(!ce.is_ready());
|
||||
assert_eq!(ce.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChandelierExit::classic();
|
||||
let mut b = ChandelierExit::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Choppiness Index.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Choppiness Index — is the market trending or just chopping sideways?
|
||||
///
|
||||
/// ```text
|
||||
/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n)
|
||||
/// ```
|
||||
///
|
||||
/// The ratio compares the *distance price actually travelled* (the summed true
|
||||
/// range) with the *net ground it covered* (the high-low span of the window).
|
||||
/// A clean trend travels almost exactly its span, so the ratio is near `1` and
|
||||
/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
|
||||
/// ratio is large and `CI` climbs toward `100`. The conventional reading is
|
||||
/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
|
||||
/// `100` by convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
|
||||
///
|
||||
/// let mut indicator = ChoppinessIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChoppinessIndex {
|
||||
period: usize,
|
||||
log_n: f64,
|
||||
prev_close: Option<f64>,
|
||||
tr_window: VecDeque<f64>,
|
||||
tr_sum: f64,
|
||||
highs: VecDeque<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl ChoppinessIndex {
|
||||
/// Construct a new Choppiness Index over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
|
||||
/// denominator is zero for `period == 1` and undefined for `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "choppiness index needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
log_n: (period as f64).log10(),
|
||||
prev_close: None,
|
||||
tr_window: VecDeque::with_capacity(period),
|
||||
tr_sum: 0.0,
|
||||
highs: VecDeque::with_capacity(period),
|
||||
lows: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChoppinessIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tr = candle.true_range(self.prev_close);
|
||||
self.prev_close = Some(candle.close);
|
||||
|
||||
if self.tr_window.len() == self.period {
|
||||
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
|
||||
self.highs.pop_front();
|
||||
self.lows.pop_front();
|
||||
}
|
||||
self.tr_window.push_back(tr);
|
||||
self.tr_sum += tr;
|
||||
self.highs.push_back(candle.high);
|
||||
self.lows.push_back(candle.low);
|
||||
|
||||
if self.tr_window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let span = highest - lowest;
|
||||
if span == 0.0 {
|
||||
// A perfectly flat window: maximal choppiness by convention.
|
||||
return Some(100.0);
|
||||
}
|
||||
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.tr_window.clear();
|
||||
self.tr_sum = 0.0;
|
||||
self.highs.clear();
|
||||
self.lows.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.tr_window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ChoppinessIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_equal_range_bars() {
|
||||
// Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
|
||||
// CI = 100 · log10(4 / 2) / log10(2) = 100.
|
||||
let mut ci = ChoppinessIndex::new(2).unwrap();
|
||||
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_yields_hundred() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
for v in ci.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_trend_reads_low() {
|
||||
// A clean one-directional march travels close to its span -> low CI.
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
for v in ci.batch(&candles).into_iter().flatten() {
|
||||
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
|
||||
assert!(v >= 0.0, "CI must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ci = ChoppinessIndex::new(8).unwrap();
|
||||
let out = ci.batch(&candles);
|
||||
assert_eq!(ci.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(ChoppinessIndex::new(0).is_err());
|
||||
assert!(ChoppinessIndex::new(1).is_err());
|
||||
assert!(ChoppinessIndex::new(2).is_ok());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut ci = ChoppinessIndex::new(14).unwrap();
|
||||
ci.batch(&candles);
|
||||
assert!(ci.is_ready());
|
||||
ci.reset();
|
||||
assert!(!ci.is_ready());
|
||||
assert_eq!(ci.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChoppinessIndex::new(14).unwrap();
|
||||
let mut b = ChoppinessIndex::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Classic (Floor-Trader) Pivot Points.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Classic Pivot Points output: pivot plus three resistances and three supports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ClassicPivotsOutput {
|
||||
/// Pivot Point: `(H + L + C) / 3`.
|
||||
pub pp: f64,
|
||||
/// Resistance 1: `2·PP − L`.
|
||||
pub r1: f64,
|
||||
/// Resistance 2: `PP + (H − L)`.
|
||||
pub r2: f64,
|
||||
/// Resistance 3: `H + 2·(PP − L)`.
|
||||
pub r3: f64,
|
||||
/// Support 1: `2·PP − H`.
|
||||
pub s1: f64,
|
||||
/// Support 2: `PP − (H − L)`.
|
||||
pub s2: f64,
|
||||
/// Support 3: `L − 2·(H − PP)`.
|
||||
pub s3: f64,
|
||||
}
|
||||
|
||||
/// Classic (Floor-Trader) Pivot Points — the standard pivot/resistance/support
|
||||
/// levels computed from a completed candle's high, low and close.
|
||||
///
|
||||
/// ```text
|
||||
/// PP = (H + L + C) / 3
|
||||
/// R1 = 2·PP − L S1 = 2·PP − H
|
||||
/// R2 = PP + (H − L) S2 = PP − (H − L)
|
||||
/// R3 = H + 2·(PP − L) S3 = L − 2·(H − PP)
|
||||
/// ```
|
||||
///
|
||||
/// Pivots are typically computed once per session (day, week, month) from the
|
||||
/// **previous** session's bar and used as fixed reference levels for the next
|
||||
/// session. The streaming API here simply re-evaluates the formula on every
|
||||
/// candle it sees, which makes it a one-step transform you can wire to any
|
||||
/// pre-aggregated session bar. There are no parameters and no warmup — the
|
||||
/// first candle produces the first set of levels.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, ClassicPivots, Indicator};
|
||||
///
|
||||
/// let prev = Candle::new(100.0, 110.0, 90.0, 105.0, 1.0, 0).unwrap();
|
||||
/// let mut pp = ClassicPivots::new();
|
||||
/// let levels = pp.update(prev).unwrap();
|
||||
/// assert!((levels.pp - 101.6666666666).abs() < 1e-9);
|
||||
/// assert!(levels.r1 > levels.pp);
|
||||
/// assert!(levels.s1 < levels.pp);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClassicPivots {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl ClassicPivots {
|
||||
/// Construct a new Classic Pivot Points indicator. The indicator has no
|
||||
/// parameters and no warmup.
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ClassicPivots {
|
||||
type Input = Candle;
|
||||
type Output = ClassicPivotsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ClassicPivotsOutput> {
|
||||
let (h, l, c) = (candle.high, candle.low, candle.close);
|
||||
let pp = (h + l + c) / 3.0;
|
||||
let range = h - l;
|
||||
let out = ClassicPivotsOutput {
|
||||
pp,
|
||||
r1: 2.0 * pp - l,
|
||||
r2: pp + range,
|
||||
r3: h + 2.0 * (pp - l),
|
||||
s1: 2.0 * pp - h,
|
||||
s2: pp - range,
|
||||
s3: l - 2.0 * (h - pp),
|
||||
};
|
||||
self.ready = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ClassicPivots"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(h: f64, l: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, h, l, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, C=105 -> PP = 305/3 ≈ 101.6667.
|
||||
let levels = ClassicPivots::new()
|
||||
.update(c(110.0, 90.0, 105.0, 0))
|
||||
.unwrap();
|
||||
let pp = 305.0 / 3.0;
|
||||
let range = 20.0;
|
||||
assert!((levels.pp - pp).abs() < 1e-12);
|
||||
assert!((levels.r1 - (2.0 * pp - 90.0)).abs() < 1e-12);
|
||||
assert!((levels.s1 - (2.0 * pp - 110.0)).abs() < 1e-12);
|
||||
assert!((levels.r2 - (pp + range)).abs() < 1e-12);
|
||||
assert!((levels.s2 - (pp - range)).abs() < 1e-12);
|
||||
assert!((levels.r3 - (110.0 + 2.0 * (pp - 90.0))).abs() < 1e-12);
|
||||
assert!((levels.s3 - (90.0 - 2.0 * (110.0 - pp))).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_resistance_above_pivot_above_support() {
|
||||
// For any non-degenerate bar with H > L, R-levels exceed PP and S-levels lie below.
|
||||
let levels = ClassicPivots::new()
|
||||
.update(c(200.0, 100.0, 150.0, 0))
|
||||
.unwrap();
|
||||
assert!(levels.r3 >= levels.r2);
|
||||
assert!(levels.r2 >= levels.r1);
|
||||
assert!(levels.r1 >= levels.pp);
|
||||
assert!(levels.pp >= levels.s1);
|
||||
assert!(levels.s1 >= levels.s2);
|
||||
assert!(levels.s2 >= levels.s3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_levels() {
|
||||
// H = L = C means range = 0 and every level equals the close.
|
||||
let levels = ClassicPivots::new().update(c(50.0, 50.0, 50.0, 0)).unwrap();
|
||||
assert_eq!(levels.pp, 50.0);
|
||||
assert_eq!(levels.r1, 50.0);
|
||||
assert_eq!(levels.s1, 50.0);
|
||||
assert_eq!(levels.r2, 50.0);
|
||||
assert_eq!(levels.s2, 50.0);
|
||||
assert_eq!(levels.r3, 50.0);
|
||||
assert_eq!(levels.s3, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update_warmup_is_one() {
|
||||
let mut pp = ClassicPivots::new();
|
||||
assert!(!pp.is_ready());
|
||||
assert_eq!(pp.warmup_period(), 1);
|
||||
pp.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(pp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pp = ClassicPivots::new();
|
||||
pp.update(c(11.0, 9.0, 10.0, 0));
|
||||
assert!(pp.is_ready());
|
||||
pp.reset();
|
||||
assert!(!pp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0_i32..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
f64::from(i) + 2.0,
|
||||
f64::from(i),
|
||||
f64::from(i) + 1.0,
|
||||
i.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ClassicPivots::new();
|
||||
let mut b = ClassicPivots::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pp = ClassicPivots::new();
|
||||
assert_eq!(pp.warmup_period(), 1);
|
||||
assert_eq!(pp.name(), "ClassicPivots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Chaikin Money Flow (CMF).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator.
|
||||
///
|
||||
/// Each bar produces a *money-flow volume*: the bar's volume weighted by where
|
||||
/// the close fell within its range (the same money-flow multiplier the
|
||||
/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to
|
||||
/// summed volume over the lookback window:
|
||||
///
|
||||
/// ```text
|
||||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (−1..+1)
|
||||
/// MFV_t = MFM_t · volume_t
|
||||
/// CMF_t = Σ(MFV, period) / Σ(volume, period)
|
||||
/// ```
|
||||
///
|
||||
/// The result lives in `[−1, +1]`: sustained closes near the high push CMF
|
||||
/// toward `+1` (accumulation), near the low toward `−1` (distribution). A bar
|
||||
/// with `high == low` carries no positional information and contributes a
|
||||
/// money-flow volume of `0`; a window whose total volume is zero yields `0.0`
|
||||
/// by convention.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow};
|
||||
///
|
||||
/// let mut indicator = ChaikinMoneyFlow::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChaikinMoneyFlow {
|
||||
period: usize,
|
||||
mfv_window: VecDeque<f64>,
|
||||
vol_window: VecDeque<f64>,
|
||||
mfv_sum: f64,
|
||||
vol_sum: f64,
|
||||
}
|
||||
|
||||
impl ChaikinMoneyFlow {
|
||||
/// Construct a new Chaikin Money Flow over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
mfv_window: VecDeque::with_capacity(period),
|
||||
vol_window: VecDeque::with_capacity(period),
|
||||
mfv_sum: 0.0,
|
||||
vol_sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ChaikinMoneyFlow {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let mfv = if range == 0.0 {
|
||||
// A zero-range bar carries no positional information.
|
||||
0.0
|
||||
} else {
|
||||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||||
mfm * candle.volume
|
||||
};
|
||||
|
||||
if self.mfv_window.len() == self.period {
|
||||
self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
|
||||
self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
|
||||
}
|
||||
self.mfv_window.push_back(mfv);
|
||||
self.vol_window.push_back(candle.volume);
|
||||
self.mfv_sum += mfv;
|
||||
self.vol_sum += candle.volume;
|
||||
|
||||
if self.mfv_window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
if self.vol_sum == 0.0 {
|
||||
// No volume traded across the whole window — no flow to report.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(self.mfv_sum / self.vol_sum)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.mfv_window.clear();
|
||||
self.vol_window.clear();
|
||||
self.mfv_sum = 0.0;
|
||||
self.vol_sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.mfv_window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CMF"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
|
||||
// bar 2 closes mid-range -> MFM = 0, MFV = 0.
|
||||
// CMF = (100 + 0) / (100 + 100) = 0.5.
|
||||
let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
|
||||
let out = cmf.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||||
candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
|
||||
]);
|
||||
assert!(out[0].is_none());
|
||||
assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_unit_range() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 3.0,
|
||||
mid - 3.0,
|
||||
mid + (i as f64 * 0.5).cos() * 2.0,
|
||||
10.0 + (i % 7) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_high_yield_cmf_one() {
|
||||
// Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_yields_zero() {
|
||||
// A window with no traded volume divides 0/0 — defined as 0.0.
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
|
||||
for v in cmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_value_on_period_th_candle() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
|
||||
let out = cmf.batch(&candles);
|
||||
for (i, v) in out.iter().enumerate().take(4) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[4].is_some(), "first CMF lands at index period - 1");
|
||||
assert_eq!(cmf.warmup_period(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
|
||||
.collect();
|
||||
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
|
||||
cmf.batch(&candles);
|
||||
assert!(cmf.is_ready());
|
||||
cmf.reset();
|
||||
assert!(!cmf.is_ready());
|
||||
assert_eq!(cmf.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ChaikinMoneyFlow::new(20).unwrap();
|
||||
let mut b = ChaikinMoneyFlow::new(20).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Chande Momentum Oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Chande Momentum Oscillator — Tushar Chande's bounded momentum gauge.
|
||||
///
|
||||
/// Over the last `period` price *changes* it sums the gains and the losses
|
||||
/// separately and reports:
|
||||
///
|
||||
/// ```text
|
||||
/// CMO = 100 · (Σ gains − Σ losses) / (Σ gains + Σ losses)
|
||||
/// ```
|
||||
///
|
||||
/// The result is bounded in `[−100, 100]`: `+100` is a window of pure gains,
|
||||
/// `−100` a window of pure losses, `0` a perfect balance. Unlike RSI the sums
|
||||
/// are *unsmoothed* — every change in the window carries equal weight — so CMO
|
||||
/// reacts faster and swings wider.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Cmo};
|
||||
///
|
||||
/// let mut indicator = Cmo::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cmo {
|
||||
period: usize,
|
||||
prev_price: Option<f64>,
|
||||
/// Rolling window of `(gain, loss)` pairs, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl Cmo {
|
||||
/// Construct a new CMO with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_price: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Cmo {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
// Non-finite input is ignored; state is left untouched.
|
||||
return self.current;
|
||||
}
|
||||
let Some(prev) = self.prev_price else {
|
||||
self.prev_price = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_price = Some(input);
|
||||
|
||||
let change = input - prev;
|
||||
let gain = change.max(0.0);
|
||||
let loss = (-change).max(0.0);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_gain, old_loss) = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum_gain -= old_gain;
|
||||
self.sum_loss -= old_loss;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
let cmo = if denom == 0.0 {
|
||||
// A flat window (no gains and no losses): momentum is exactly zero.
|
||||
0.0
|
||||
} else {
|
||||
100.0 * (self.sum_gain - self.sum_loss) / denom
|
||||
};
|
||||
self.current = Some(cmo);
|
||||
Some(cmo)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price = None;
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CMO"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// Σgain = 3, Σloss = 1 -> 100·(3−1)/(3+1) = 50.
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
|
||||
assert_eq!(cmo.warmup_period(), 4);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[2], None);
|
||||
assert_relative_eq!(out[3].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_at_plus_100() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_saturates_at_minus_100() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&(1..=20).rev().map(f64::from).collect::<Vec<_>>());
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut cmo = Cmo::new(5).unwrap();
|
||||
let out = cmo.batch(&[42.0; 20]);
|
||||
for v in out.iter().skip(6).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
|
||||
let ready = out[3].expect("CMO(3) ready after four inputs");
|
||||
assert_eq!(cmo.update(f64::NAN), Some(ready));
|
||||
assert_eq!(cmo.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cmo = Cmo::new(3).unwrap();
|
||||
cmo.batch(&[10.0, 11.0, 12.0, 13.0, 14.0]);
|
||||
assert!(cmo.is_ready());
|
||||
cmo.reset();
|
||||
assert!(!cmo.is_ready());
|
||||
assert_eq!(cmo.update(10.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
|
||||
.collect();
|
||||
let batch = Cmo::new(9).unwrap().batch(&prices);
|
||||
let mut b = Cmo::new(9).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user