Compare commits
302 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 099ae66b57 | |||
| 11dd659b5f | |||
| c096943bdf | |||
| c44f625e69 | |||
| 46dc8f5a00 | |||
| a3a1ae4dba | |||
| 53941b7b07 | |||
| 72ec65bbde | |||
| 82d1a4fe77 | |||
| f71b3b6b49 | |||
| d081cb9581 | |||
| 9eb46f144a | |||
| 9a98e9bf55 | |||
| d4b3f9dbd1 | |||
| f37eedd44e | |||
| 93097db482 | |||
| 2f3a0b9149 | |||
| 49c0fd7dd5 | |||
| 3d98592461 | |||
| 124efb4432 | |||
| 4d0bc08efd | |||
| c2c85c7ecf | |||
| 04ae145126 | |||
| e4ca9c3f8f | |||
| d43bc9ddf3 | |||
| 244d754707 | |||
| 03ceac1f3b | |||
| 73415cd2dc | |||
| ad51dbc1a3 | |||
| f09057aaf1 | |||
| 458ef2385e | |||
| 2d140419bb | |||
| 8e5bfd07ce | |||
| 5eb820a9c7 | |||
| fae60e0d54 | |||
| 433b06367f | |||
| 3dd7010129 | |||
| 4f11df0e33 | |||
| b5d9e47a2e | |||
| 511d3a27f7 | |||
| 5b23b36261 | |||
| 5867f71450 | |||
| 2be21df803 | |||
| 498b74a5ae | |||
| 921a250715 | |||
| 0479191b66 | |||
| 4631519885 | |||
| 0b85142ad1 | |||
| 1ab9bc70d1 | |||
| 2ab578bee8 | |||
| 2be39b8b98 | |||
| 99af5f8ee1 | |||
| bff1148d20 | |||
| ebddc5e376 | |||
| eab2649f1c | |||
| f7f947e048 | |||
| 01dd08714b | |||
| 0fa70c9882 | |||
| debe4523d5 | |||
| a046c441e5 | |||
| f7b91f6fa5 | |||
| 5030360a0c | |||
| 1dd487fabc | |||
| cee174c0de | |||
| c8e5d8a658 | |||
| dc2e19e762 | |||
| d8212beff6 | |||
| 86a595d505 | |||
| 9309bf9d60 | |||
| 3f05342f72 | |||
| eb4454ab27 | |||
| 3093f194a2 | |||
| 21c86f348f | |||
| a2ff35f5f9 | |||
| 01aeb965d1 | |||
| f1fed6cdd5 | |||
| 70e9cbb397 | |||
| 37e5e19b57 | |||
| c189075491 | |||
| 74dd82a867 | |||
| b654db312e | |||
| 88f119109d | |||
| 2945b47e1a | |||
| c212f91256 | |||
| 6c2ddf319f | |||
| 9db1ff8023 | |||
| fb6eae7fe2 | |||
| 0edb9f4857 | |||
| ea684e0d48 | |||
| 62ab84c472 | |||
| 0f2ff9c3c7 | |||
| e85334a2e9 | |||
| 4e3c41ea80 | |||
| 55284a3042 | |||
| 9b8e1346ed | |||
| 05fcdd9a5e | |||
| 5aa0949bce | |||
| b971e671b4 | |||
| 7a18a26daf | |||
| 4f9ed34884 | |||
| 7e1e988596 | |||
| f10b8c2e2d | |||
| 880a0e7430 | |||
| 6287bd48c1 | |||
| 54194a4ff8 | |||
| 3ea0f12b7a | |||
| d9d3ad18aa | |||
| 7f1a6df202 | |||
| 1ea05fb2a1 | |||
| 24e723fa7d | |||
| a39adb9dae | |||
| 1cd5d1d8da | |||
| 466faddd87 | |||
| 178fbfd68e | |||
| e30b3c6b35 | |||
| 070be2eb27 | |||
| 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 |
@@ -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,3 @@
|
||||
# Shell scripts must keep LF line endings so they run on Linux/macOS CI and
|
||||
# local shells regardless of the committer's platform autocrlf setting.
|
||||
*.sh text eol=lf
|
||||
@@ -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,5 @@
|
||||
# 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]
|
||||
@@ -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 [documentation site](https://docs.wickra.org)
|
||||
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||
separate repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- [ ] 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 (MIT OR Apache-2.0)
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything reviewers should look at first, known follow-ups, deliberately out-of-scope items. -->
|
||||
@@ -0,0 +1,61 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Rust workspace (root Cargo.toml + all member crates).
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(cargo)"
|
||||
|
||||
# Node binding npm dependencies.
|
||||
- package-ecosystem: npm
|
||||
directory: "/bindings/node"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(npm)"
|
||||
|
||||
# Python binding pip dependencies.
|
||||
- package-ecosystem: pip
|
||||
directory: "/bindings/python"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(pip)"
|
||||
|
||||
# Hash-pinned CI/bench Python tooling under .github/requirements/. Each
|
||||
# <name>.in is the loose source; the matching hash-locked <name>.txt is the
|
||||
# output regenerated by scripts/update-lockfiles.sh (uv). Dependabot keeps the
|
||||
# pins fresh; ci-dev-py39.in caps numpy <2.1 so 3.9 stays installable. Any
|
||||
# bump that breaks a matrix row surfaces in the PR's CI run.
|
||||
- package-ecosystem: pip
|
||||
directory: "/.github/requirements"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(ci-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
|
||||
cooldown:
|
||||
default-days: 7
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Python deps + peer TA libraries for the bench.yml cross-library benchmark.
|
||||
# Loose source spec — the pinned, hash-locked output is generated from this:
|
||||
# bench.txt (Python 3.11) via scripts/update-lockfiles.sh
|
||||
# bench.yml runs on a single Python version (3.11), so one output suffices.
|
||||
maturin
|
||||
numpy
|
||||
pandas
|
||||
talipp
|
||||
finta
|
||||
@@ -0,0 +1,167 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
finta==1.3 \
|
||||
--hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \
|
||||
--hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2
|
||||
# via -r .github/requirements/bench.in
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/bench.in
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via
|
||||
# -r .github/requirements/bench.in
|
||||
# finta
|
||||
# pandas
|
||||
pandas==3.0.3 \
|
||||
--hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
|
||||
--hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
|
||||
--hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \
|
||||
--hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \
|
||||
--hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \
|
||||
--hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \
|
||||
--hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \
|
||||
--hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \
|
||||
--hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \
|
||||
--hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \
|
||||
--hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \
|
||||
--hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \
|
||||
--hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \
|
||||
--hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \
|
||||
--hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \
|
||||
--hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \
|
||||
--hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \
|
||||
--hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \
|
||||
--hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \
|
||||
--hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \
|
||||
--hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \
|
||||
--hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \
|
||||
--hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \
|
||||
--hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \
|
||||
--hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \
|
||||
--hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \
|
||||
--hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \
|
||||
--hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \
|
||||
--hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \
|
||||
--hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \
|
||||
--hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \
|
||||
--hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \
|
||||
--hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \
|
||||
--hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \
|
||||
--hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \
|
||||
--hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \
|
||||
--hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \
|
||||
--hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \
|
||||
--hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \
|
||||
--hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \
|
||||
--hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \
|
||||
--hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \
|
||||
--hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \
|
||||
--hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \
|
||||
--hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \
|
||||
--hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \
|
||||
--hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \
|
||||
--hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09
|
||||
# via
|
||||
# -r .github/requirements/bench.in
|
||||
# finta
|
||||
python-dateutil==2.9.0.post0 \
|
||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
# via pandas
|
||||
six==1.17.0 \
|
||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
||||
# via python-dateutil
|
||||
talipp==2.7.0 \
|
||||
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
|
||||
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
|
||||
# via -r .github/requirements/bench.in
|
||||
tzdata==2026.2 \
|
||||
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
|
||||
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
|
||||
# via pandas
|
||||
@@ -0,0 +1,7 @@
|
||||
# Python 3.10+ dev/test tooling for the ci.yml binding test job
|
||||
# (covers the 3.11 / 3.12 / 3.13 matrix rows). Locked output: ci-dev-py3.txt
|
||||
# Refresh via scripts/update-lockfiles.sh.
|
||||
maturin
|
||||
pytest
|
||||
numpy
|
||||
hypothesis
|
||||
@@ -0,0 +1,124 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
colorama==0.4.6 \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via pytest
|
||||
hypothesis==6.155.1 \
|
||||
--hash=sha256:07c102031612b98d7c1be15ca3608c43e1234d9d07e3a190a53fa01536700196 \
|
||||
--hash=sha256:2753f469df3ba3c483b08e0c37dbcbc41d8316ebb921abcc07493ee9c8a7d187
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pytest==9.0.3 \
|
||||
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
|
||||
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
|
||||
# via -r .github/requirements/ci-dev-py3.in
|
||||
sortedcontainers==2.4.0 \
|
||||
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
|
||||
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
|
||||
# via hypothesis
|
||||
@@ -0,0 +1,8 @@
|
||||
# Python 3.9 dev/test tooling for the ci.yml binding test job.
|
||||
# numpy is capped <2.1 because that is the last series shipping cp39 wheels
|
||||
# (>=2.1 dropped Python 3.9). Locked output: ci-dev-py39.txt
|
||||
# Refresh via scripts/update-lockfiles.sh.
|
||||
maturin
|
||||
pytest
|
||||
numpy<2.1
|
||||
hypothesis
|
||||
@@ -0,0 +1,162 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# ./scripts/update-lockfiles.sh
|
||||
attrs==26.1.0 \
|
||||
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
||||
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
||||
# via hypothesis
|
||||
colorama==0.4.6 \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via pytest
|
||||
exceptiongroup==1.3.1 \
|
||||
--hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \
|
||||
--hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598
|
||||
# via
|
||||
# hypothesis
|
||||
# pytest
|
||||
hypothesis==6.141.1 \
|
||||
--hash=sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f \
|
||||
--hash=sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
iniconfig==2.1.0 \
|
||||
--hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
|
||||
--hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
|
||||
# via pytest
|
||||
maturin==1.13.3 \
|
||||
--hash=sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b \
|
||||
--hash=sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3 \
|
||||
--hash=sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b \
|
||||
--hash=sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c \
|
||||
--hash=sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018 \
|
||||
--hash=sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f \
|
||||
--hash=sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff \
|
||||
--hash=sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0 \
|
||||
--hash=sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323 \
|
||||
--hash=sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079 \
|
||||
--hash=sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11 \
|
||||
--hash=sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273 \
|
||||
--hash=sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a \
|
||||
--hash=sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
numpy==2.0.2 \
|
||||
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
|
||||
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
|
||||
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
|
||||
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
|
||||
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
|
||||
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
|
||||
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
|
||||
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
|
||||
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
|
||||
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
|
||||
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
|
||||
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
|
||||
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
|
||||
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
|
||||
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
|
||||
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
|
||||
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
|
||||
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
|
||||
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
|
||||
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
|
||||
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
|
||||
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
|
||||
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
|
||||
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
|
||||
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
|
||||
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
|
||||
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
|
||||
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
|
||||
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
|
||||
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
|
||||
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
|
||||
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
|
||||
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
|
||||
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
|
||||
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
|
||||
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
|
||||
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
|
||||
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
|
||||
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
|
||||
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
|
||||
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
|
||||
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
|
||||
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
|
||||
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
|
||||
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via pytest
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via pytest
|
||||
pytest==8.4.2 \
|
||||
--hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \
|
||||
--hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
|
||||
# via -r .github/requirements/ci-dev-py39.in
|
||||
sortedcontainers==2.4.0 \
|
||||
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
|
||||
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
|
||||
# via hypothesis
|
||||
tomli==2.4.1 \
|
||||
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
|
||||
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
|
||||
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
|
||||
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
|
||||
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
|
||||
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
|
||||
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
|
||||
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
|
||||
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
|
||||
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
|
||||
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
|
||||
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
|
||||
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
|
||||
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
|
||||
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
|
||||
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
|
||||
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
|
||||
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
|
||||
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
|
||||
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
|
||||
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
|
||||
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
|
||||
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
|
||||
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
|
||||
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
|
||||
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
|
||||
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
|
||||
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
|
||||
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
|
||||
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
|
||||
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
|
||||
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
|
||||
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
|
||||
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
|
||||
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
|
||||
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
|
||||
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
|
||||
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
|
||||
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
|
||||
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
|
||||
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
|
||||
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
|
||||
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
|
||||
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
|
||||
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
|
||||
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
|
||||
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
|
||||
# via
|
||||
# maturin
|
||||
# pytest
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
# via exceptiongroup
|
||||
@@ -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,119 @@
|
||||
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"
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The single job
|
||||
# only builds and uploads an artifact (upload-artifact uses the artifact
|
||||
# storage API, not the contents scope), so it never needs repo write (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / PyPI inside any build step (cargo,
|
||||
# maturin, pip) retries automatically instead of failing the job. Cargo treats
|
||||
# "couldn't resolve host" / connect / timeout as spurious and retries with
|
||||
# backoff; 10 attempts ride out a transient DNS blip on a runner.
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/bench.txt
|
||||
|
||||
- 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: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/bench.txt
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Hash-locked deps (OpenSSF Scorecard PinnedDependencies). bench.yml
|
||||
# runs on a single Python version (3.11), so one lock file suffices.
|
||||
python -m pip install --require-hashes -r .github/requirements/bench.txt
|
||||
|
||||
- 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
|
||||
# workflow_dispatch inputs are untrusted; pass them through the
|
||||
# environment and quote them rather than interpolating into the shell
|
||||
# command (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
BENCH_SIZE: ${{ github.event.inputs.size || '20000' }}
|
||||
BENCH_ITERATIONS: ${{ github.event.inputs.iterations || '10' }}
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries \
|
||||
--size "$BENCH_SIZE" \
|
||||
--iterations "$BENCH_ITERATIONS" \
|
||||
--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
|
||||
+449
-62
@@ -6,9 +6,29 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
|
||||
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
|
||||
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
|
||||
# napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
|
||||
# failing the job and needing a manual re-run. Cargo treats "couldn't resolve
|
||||
# host" / connect / timeout as spurious and retries with backoff; 10 attempts
|
||||
# ride out a transient DNS blip on a runner. Complements the setup-action /
|
||||
# cache retries (which only covered toolchain download + cache restore).
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -19,15 +39,19 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -48,9 +72,324 @@ jobs:
|
||||
run: cargo build -p wickra --benches --verbose
|
||||
|
||||
- name: Compile examples
|
||||
# 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
|
||||
|
||||
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
|
||||
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
|
||||
# browser-WASM and Python examples otherwise have no build gate, so a broken
|
||||
# edit could land unnoticed. This is a parse-only smoke — actually running the
|
||||
# examples needs the built native binding / wasm module / wheel, which the
|
||||
# binding jobs provide separately.
|
||||
examples-smoke:
|
||||
name: Examples (syntax smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Wait before Node retry
|
||||
if: steps.setup_node.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build -p wickra --example backtest
|
||||
cargo build -p wickra-data --example live_binance --features live-binance
|
||||
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: "20"
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- 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: "3.12"
|
||||
|
||||
- name: Node examples — syntax check
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/node/*.js examples/wasm/*.js; do
|
||||
echo "node --check $f"
|
||||
node --check "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "checked $count Node/WASM .js files"
|
||||
|
||||
- name: WASM demo module scripts — syntax check
|
||||
# The .html demos embed an ES module; extract it and parse-check so a
|
||||
# broken edit to the in-page strategy logic fails CI.
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/wasm/*.html; do
|
||||
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
|
||||
echo "node --check (module of) $f"
|
||||
node --check module-check.mjs
|
||||
count=$((count + 1))
|
||||
done
|
||||
rm -f module-check.mjs
|
||||
echo "checked $count WASM .html module scripts"
|
||||
|
||||
- name: Python examples — byte-compile
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/python/*.py; do
|
||||
echo "py_compile $f"
|
||||
python -m py_compile "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "compiled $count Python files"
|
||||
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- 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: "20"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
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
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
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,25 +398,61 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# 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 }}
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/ci-dev-*.txt
|
||||
|
||||
- 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 }}
|
||||
cache: pip
|
||||
cache-dependency-path: .github/requirements/ci-dev-*.txt
|
||||
|
||||
- name: Install Python dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin pytest numpy hypothesis
|
||||
# Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
|
||||
# Split by Python version: numpy ships no single release with wheels
|
||||
# for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
|
||||
if [ "${{ matrix.python-version }}" = "3.9" ]; then
|
||||
python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
|
||||
else
|
||||
python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
|
||||
fi
|
||||
|
||||
- name: Build wheel
|
||||
working-directory: bindings/python
|
||||
@@ -86,7 +461,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 +476,39 @@ jobs:
|
||||
name: WASM build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- 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
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
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,22 +524,49 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ["18", "20"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# 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 }}
|
||||
cache: npm
|
||||
cache-dependency-path: bindings/node/package-lock.json
|
||||
|
||||
- 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 }}
|
||||
cache: npm
|
||||
cache-dependency-path: bindings/node/package-lock.json
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build native module
|
||||
working-directory: bindings/node
|
||||
@@ -156,44 +580,7 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries --size 20000 --iterations 10 \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
# the every-push / every-PR critical path.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: CodeQL
|
||||
|
||||
# Static analysis security testing (findings P13.x). Analyses the Rust core and
|
||||
# the Python / JavaScript binding surfaces with GitHub's CodeQL engine. Results
|
||||
# appear under Security → Code scanning. `build-mode: none` analyses source
|
||||
# directly — no compilation step — for every language here.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '31 3 * * 0' # Sundays 03:31 UTC
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The analyze job
|
||||
# raises exactly the scopes CodeQL needs (security-events: write to upload
|
||||
# results) in its own job-level block below; this top-level read-only default
|
||||
# covers any future job (OpenSSF Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload CodeQL results to code-scanning
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: rust
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
+517
-44
@@ -5,8 +5,30 @@ on:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
|
||||
# (cargo/python/node) push to external registries via their own secrets
|
||||
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
|
||||
# they need no repo write. The jobs that genuinely write through the
|
||||
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
|
||||
# attestations (id-token / attestations: write) — declare those rights in their
|
||||
# own job-level permissions blocks, which override this default (OpenSSF
|
||||
# Scorecard: Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Network-flake resilience: retry transient registry/DNS failures at the tool
|
||||
# level so a blip fetching crates.io / npm inside any build or publish step
|
||||
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
|
||||
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
|
||||
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
npm_config_fetch_retries: "5"
|
||||
npm_config_fetch_retry_maxtimeout: "120000"
|
||||
PIP_RETRIES: "5"
|
||||
PIP_DEFAULT_TIMEOUT: "120"
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -15,10 +37,21 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# 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 +85,129 @@ 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
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cargo-cyclonedx
|
||||
|
||||
- name: Generate CycloneDX SBOMs
|
||||
run: |
|
||||
# cargo-cyclonedx walks the whole workspace in a single pass and
|
||||
# writes a <package>.cdx.json next to each member's Cargo.toml; it
|
||||
# has no -p/--package selector. Collect the three crates.io crates
|
||||
# (the .crate files published by this job) into the upload dir.
|
||||
cargo cyclonedx --format json --top-level
|
||||
mkdir -p sboms
|
||||
cp crates/wickra-core/wickra-core.cdx.json sboms/
|
||||
cp crates/wickra-data/wickra-data.cdx.json sboms/
|
||||
cp crates/wickra/wickra.cdx.json sboms/
|
||||
ls -lh sboms/
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sboms
|
||||
path: sboms/*.cdx.json
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PyPI: cross-platform wheels + sdist
|
||||
# --------------------------------------------------------------------------
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: setup_python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: PyO3/maturin-action@v1
|
||||
- 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: "3.11"
|
||||
- name: Sync root README into bindings/python so it ships with the wheel
|
||||
shell: bash
|
||||
run: cp README.md bindings/python/README.md
|
||||
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
working-directory: bindings/python
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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 +216,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,33 +242,55 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- 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: "20"
|
||||
|
||||
- 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
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build native module
|
||||
working-directory: bindings/node
|
||||
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,20 +300,48 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- 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: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Node deps
|
||||
working-directory: bindings/node
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- 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,10 +353,16 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: npx napi artifacts --dir artifacts
|
||||
|
||||
# Publish each platform package individually so one failure doesn't kill
|
||||
# the others. Skip versions that are already on npm. Tolerate spam-filter
|
||||
# 403s with a one-time retry after a short delay (spam detection is
|
||||
# often rate-limit-based and clears on the next request).
|
||||
# Publish each platform package individually. Skip versions that are
|
||||
# already on npm. A first-attempt 403 from npm's spam filter is
|
||||
# tolerated for a single 30-second retry — that historically clears
|
||||
# rate-limit-driven false positives. Anything that still fails after
|
||||
# the retry is a *real* failure (the platform binary will be missing
|
||||
# from `optionalDependencies` and Windows-style installs will break,
|
||||
# exactly the regression that produced audit finding R20) — fail the
|
||||
# job loudly so the release does not silently land in a half-published
|
||||
# state. Previously this loop swallowed the second-attempt failure with
|
||||
# a `::warning::` and `return 0`; that mask is removed.
|
||||
- name: Publish platform packages (idempotent)
|
||||
working-directory: bindings/node
|
||||
env:
|
||||
@@ -199,6 +370,7 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
fail=0
|
||||
publish_dir() {
|
||||
local dir=$1
|
||||
local pkg=$(basename "$dir")
|
||||
@@ -210,23 +382,37 @@ jobs:
|
||||
return 0
|
||||
fi
|
||||
echo "::group::publish $pkgname@$version"
|
||||
(cd "$dir" && npm publish --access public)
|
||||
# --ignore-scripts: a per-platform package must never run lifecycle
|
||||
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
|
||||
# the package being published — a malicious or stray script would
|
||||
# execute with the npm token in the environment).
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --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)
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
rc=$?
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::$pkgname could not be published; the main package will skip the missing optional dep"
|
||||
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
for dir in npm/*/; do
|
||||
publish_dir "$dir"
|
||||
publish_dir "$dir" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: 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
|
||||
@@ -247,35 +433,95 @@ jobs:
|
||||
# --ignore-scripts so any leftover prepublish hooks (which would
|
||||
# otherwise try to republish the already-published platform
|
||||
# subpackages) can't sabotage the main publish.
|
||||
npm publish --access public --ignore-scripts
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
npm publish --access public --ignore-scripts
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
- 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Set up Node
|
||||
id: setup_node
|
||||
continue-on-error: true
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- 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: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- 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
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
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
|
||||
@@ -286,19 +532,246 @@ 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 <support@wickra.org>';
|
||||
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 = 'MIT OR Apache-2.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.
|
||||
#
|
||||
# The release is created as a DRAFT here and only flipped to published by the
|
||||
# downstream publish-release job, after the provenance bundle is attached. That
|
||||
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
|
||||
# with GitHub release immutability, which locks assets at publish time (P24):
|
||||
# the old "publish, then upload provenance" order would have the provenance
|
||||
# upload rejected once immutability is enabled.
|
||||
# --------------------------------------------------------------------------
|
||||
github-release:
|
||||
name: Attach assets to the draft GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# Expose the resolved tag so the attestations job can attach the provenance
|
||||
# bundle to this same release without re-resolving it.
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve target tag
|
||||
id: tag
|
||||
# Pass the (potentially attacker-influenceable on a tag push) ref context
|
||||
# through the environment instead of interpolating it into the shell
|
||||
# script, so a crafted tag name cannot inject commands (zizmor:
|
||||
# template-injection).
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF: ${{ github.ref }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/* ]]; then
|
||||
tag="$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 the draft 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
|
||||
# Created as a draft; publish-release flips it to published + latest once
|
||||
# the provenance bundle is attached (P24, immutability-ready).
|
||||
draft: true
|
||||
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.
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Build provenance attestations (findings P13.2)
|
||||
# --------------------------------------------------------------------------
|
||||
attestations:
|
||||
name: Attest build provenance
|
||||
needs: [cargo-publish, python-wheels, python-sdist, github-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Signed SLSA build-provenance attestations for the published crates and
|
||||
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
|
||||
# from `npm publish --provenance`, so they are covered there.
|
||||
#
|
||||
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
|
||||
# of github-release, so a Sigstore hiccup here can never block or corrupt a
|
||||
# publish (the isolation the SBOM step lacked before #79). It additionally
|
||||
# `needs: github-release` so the (still-draft) GitHub Release already exists
|
||||
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
|
||||
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
|
||||
# not GitHub's separate attestations store, so the bundle has to live on the
|
||||
# release. The release is published afterwards by the publish-release job
|
||||
# whether or not this attestation succeeds (P24), so a failure here still only
|
||||
# costs the provenance asset, never the release.
|
||||
permissions:
|
||||
id-token: write # OIDC for keyless Sigstore signing
|
||||
attestations: write # write the attestations to this repo
|
||||
contents: write # upload the provenance bundle as a release asset
|
||||
steps:
|
||||
- name: Download crate files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: crate-files
|
||||
path: artifacts/crates
|
||||
- name: Download wheels + sdist
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: wheels-*
|
||||
path: artifacts/python
|
||||
merge-multiple: true
|
||||
- name: Attest build provenance
|
||||
id: attest
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/crates/*.crate
|
||||
artifacts/python/*.whl
|
||||
artifacts/python/*.tar.gz
|
||||
|
||||
# Attach the Sigstore provenance bundle to the GitHub Release as a
|
||||
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
|
||||
# signed provenance on the release itself (P21.1e). attest-build-provenance
|
||||
# writes a single JSONL bundle covering every subject above; copy it to a
|
||||
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
|
||||
# idempotent. github.token has contents: write here, which is all gh needs.
|
||||
- name: Attach provenance bundle to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.github-release.outputs.tag }}
|
||||
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::no tag resolved from github-release; cannot attach provenance."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
|
||||
echo "::error::attestation bundle not found at '$BUNDLE'."
|
||||
exit 1
|
||||
fi
|
||||
dest="wickra-${TAG}.provenance.intoto.jsonl"
|
||||
cp "$BUNDLE" "$dest"
|
||||
echo "Uploading $dest to release $TAG"
|
||||
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Publish the drafted release LAST (P24 — immutability-ready).
|
||||
#
|
||||
# github-release creates the release as a draft and attestations attaches the
|
||||
# provenance bundle to it; only now, with every asset in place, is it flipped to
|
||||
# published + latest. With GitHub release immutability enabled, assets lock at
|
||||
# this publish step — so the provenance bundle and every build artefact are
|
||||
# already present and never need a (rejected) post-publish upload.
|
||||
#
|
||||
# `if: always() && needs.github-release.result == 'success'` preserves the old
|
||||
# robustness: the release is published whenever the draft was created, even if
|
||||
# the attestations job hit a Sigstore hiccup — that only costs the provenance
|
||||
# asset, exactly as before. If github-release was skipped (a publish job failed)
|
||||
# there is no draft, so this is skipped too and no release is published.
|
||||
# --------------------------------------------------------------------------
|
||||
publish-release:
|
||||
name: Publish the GitHub Release
|
||||
needs: [github-release, attestations]
|
||||
if: always() && needs.github-release.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # flip the draft release to published
|
||||
steps:
|
||||
- name: Flip the draft release to published (latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.github-release.outputs.tag }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::no tag resolved from github-release; cannot publish."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::publishing release $TAG (draft -> published, latest)"
|
||||
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"
|
||||
@@ -0,0 +1,49 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
# Supply-chain / security-posture analysis (findings P13.1). Runs on a weekly
|
||||
# schedule, on branch-protection changes, and on push to main. `publish_results`
|
||||
# uploads the score to the public OpenSSF API so the README badge resolves, and
|
||||
# the SARIF is surfaced under the repo's Security → Code scanning tab.
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '27 7 * * 2' # Tuesdays 07:27 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Read-only by default; the analysis job widens to exactly what it needs.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload the SARIF result to code-scanning
|
||||
id-token: write # OIDC token to publish results to the OpenSSF API
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard analysis
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# Publish to the public OpenSSF endpoint that backs the README badge.
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload SARIF to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,451 @@
|
||||
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. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||
# .vitepress/config.ts) — push to main / v* tag*
|
||||
# 5. org profile README count (wickra-lib/.github, profile/README.md)
|
||||
# — synced on push to main / v* tag*
|
||||
# 6. org description ("… N indicators, install-free.")
|
||||
# — synced on push to main / v* tag*
|
||||
# 7. docs site published version (wickra-lib/wickra-docs: the
|
||||
# "Published versions" table in overview.md + the Rust quickstart prose)
|
||||
# — synced on v* tag only*
|
||||
# 8. Marketing site version (wickra-lib/webpage: api/*.md "Latest" lines, the
|
||||
# nav version label, and the wickra-wasm dep) — synced on v* tag only*
|
||||
# 9. Wiki pointer page count (wickra-lib/wickra.wiki, Home.md — the wiki was
|
||||
# collapsed to a single page that points at docs.wickra.org but still names
|
||||
# the count) — synced on push to main / v* tag*
|
||||
#
|
||||
# *Surfaces 3 + 7 need the ABOUT_SYNC_TOKEN to have write on
|
||||
# wickra-lib/wickra-docs, surfaces 4 + 8 on wickra-lib/webpage; surfaces 5 + 6
|
||||
# need write on wickra-lib/.github and admin:org for the org-description PATCH;
|
||||
# surface 9 needs write on wickra-lib/wickra (the wiki rides on the parent
|
||||
# repo's permission).
|
||||
# Until that scope is granted these steps emit a ::warning:: and soft-skip —
|
||||
# they never fail the run. The repo "About" homepage URL is also enforced in
|
||||
# step 2 (constant value, no extra scope); it points at docs.wickra.org.
|
||||
#
|
||||
# Note: surface 7 carries the release *version*, not the indicator count, so
|
||||
# it is driven by the v* tag (which is the version) rather than the count.
|
||||
#
|
||||
# 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: on PRs this workflow is a READ-ONLY check. The indicator wiring
|
||||
# (ScriptHelpers/_common.py wire_readme_counter) bumps both README.md and
|
||||
# docs/README.md inside the author's code commit, so the counter is already
|
||||
# correct by the time CI runs. If it is not, the check below fails loud and
|
||||
# asks the author to re-run the wiring — it never pushes a fix-up commit.
|
||||
#
|
||||
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
|
||||
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
|
||||
# moved the PR head onto a commit with no CI run, which hid the Codecov patch
|
||||
# status — keyed to the PR head sha — from the PR. Keeping the counter in the
|
||||
# code commit avoids that entirely.)
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN. The `contents:
|
||||
# write` the workflow needs — to push the counter fix-up commit to the PR head
|
||||
# branch — is raised at the job level below, not here, so the top-level default
|
||||
# stays read-only (OpenSSF Scorecard: Token-Permissions). The wider About /
|
||||
# docs / webpage / org writes still go through the fine-grained PAT
|
||||
# (ABOUT_SYNC_TOKEN), which the `permissions:` key does not govern at all.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
# This workflow never writes to wickra-lib/wickra with GITHUB_TOKEN: the PR
|
||||
# flow is a read-only check, and the main/tag flow writes only to other
|
||||
# repos (About metadata, docs, webpage, wiki, org) through the fine-grained
|
||||
# ABOUT_SYNC_TOKEN PAT. So GITHUB_TOKEN stays read-only (OpenSSF Scorecard:
|
||||
# Token-Permissions).
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
# On PRs we check out the PR *head* commit (the author's code, not the
|
||||
# merge ref) so the counter check validates exactly what will land. On
|
||||
# push events we check out the default ref. No push is made, so a shallow
|
||||
# checkout is enough.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
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 }}
|
||||
|
||||
- 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, read-only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
ok=true
|
||||
if ! grep -qE "^${n} streaming-first indicators" README.md; then
|
||||
echo "::error::README.md does not say '${n} streaming-first indicators' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps README.md), then push again."
|
||||
ok=false
|
||||
fi
|
||||
if ! grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
|
||||
echo "::error::docs/README.md does not say '**${n} indicators**' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps docs/README.md), then push again."
|
||||
ok=false
|
||||
fi
|
||||
if [ "$ok" = "true" ]; then
|
||||
echo "README.md + docs/README.md counter already at ${n}; nothing to do."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
# After a PR squash-merges, this workflow runs again on the push to main.
|
||||
# README.md / docs/README.md are already correct (the indicator wiring
|
||||
# bumped them in the merged code commit); the only outward syncs left are
|
||||
# the GitHub About description (repo metadata, not a commit) and the docs /
|
||||
# webpage / wiki / org repos (separate repos, no main history pollution).
|
||||
# The wickra repo's own README is not touched on main any more.
|
||||
|
||||
- name: Update GitHub About (description + homepage)
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
# Canonical homepage — the docs site (P8.3). This is enforced on every
|
||||
# run, so it must only point at docs.wickra.org once that domain is
|
||||
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||
# gated on the domain resolving, otherwise the About link would 404.
|
||||
homepage="https://docs.wickra.org"
|
||||
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."
|
||||
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||
# Same Administration-write permission as --description (no extra scope).
|
||||
gh repo edit --homepage "$homepage"
|
||||
current=$(gh repo view --json description -q .description)
|
||||
if [ "$current" = "$desc" ]; then
|
||||
echo "About description unchanged; homepage enforced."
|
||||
else
|
||||
gh repo edit --description "$desc"
|
||||
echo "About description + homepage updated."
|
||||
fi
|
||||
|
||||
# Counter sync target moved from the retired GitHub wiki to the docs site
|
||||
# repo (wickra-lib/wickra-docs). The count appears in index.md (hero),
|
||||
# overview.md prose, and Indicators-Overview.md prose. Soft-skips like the
|
||||
# org steps so a token/scope gap never fails the run. Uses its own clone
|
||||
# dir (docs-count) so it cannot collide with the tag-only version step
|
||||
# below, which clones the same repo into `docs`.
|
||||
- name: Sync docs indicator count (wickra-docs)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd docs-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
|
||||
if git diff --quiet; then
|
||||
echo "Docs indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add index.md overview.md Indicators-Overview.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Docs indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
# The GitHub wiki (wickra-lib/wickra.wiki) was collapsed to a single
|
||||
# Home.md pointer page that sends visitors to docs.wickra.org, but that
|
||||
# page still names the count ("… for all N indicators"), so keep it in
|
||||
# sync here too. Mirrors the docs/webpage count steps: own clone dir
|
||||
# (wiki-count) and the same soft-skip contract. Wiki write rides on the
|
||||
# parent repo's permission, so the PAT needs write on wickra-lib/wickra;
|
||||
# the wiki has no signing gate, so a plain wickra-bot commit is fine.
|
||||
- name: Sync wiki pointer indicator count (wickra.wiki)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra.wiki.git" wiki-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra.wiki — ABOUT_SYNC_TOKEN likely lacks write on the wiki. Skipping wiki count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd wiki-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" Home.md
|
||||
if git diff --quiet; then
|
||||
echo "Wiki pointer indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add Home.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra.wiki failed — ABOUT_SYNC_TOKEN likely lacks write on the wiki."
|
||||
else
|
||||
echo "Wiki pointer indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
# ----- org-profile sync (soft-skip until PAT scope lands) -------
|
||||
#
|
||||
# These two steps keep the org page (github.com/wickra-lib) in sync
|
||||
# with the same count. They need ABOUT_SYNC_TOKEN scope the main-repo
|
||||
# syncs do not: write on wickra-lib/.github, and admin:org for the org
|
||||
# description PATCH. Both are written to soft-skip with a ::warning::
|
||||
# (never fail the run) so this workflow stays green before the scope is
|
||||
# granted — once it is, they start syncing with no further code change.
|
||||
|
||||
- name: Sync org profile README count
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/.github.git" orgprofile 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/.github — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping org profile sync."
|
||||
exit 0
|
||||
fi
|
||||
cd orgprofile
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" profile/README.md
|
||||
if git diff --quiet; then
|
||||
echo "Org profile README count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add profile/README.md
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/.github failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Org profile README synced to ${n}."
|
||||
fi
|
||||
|
||||
- name: Sync org description
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
org="wickra-lib"
|
||||
# Reading the org description is public; the PATCH needs admin:org.
|
||||
current=$(gh api "orgs/${org}" --jq '.description // ""' 2>/dev/null || true)
|
||||
if [ -z "$current" ]; then
|
||||
echo "::warning::could not read org description (network/PAT?). Skipping."
|
||||
exit 0
|
||||
fi
|
||||
updated=$(printf '%s' "$current" | sed -E "s/[0-9]+ indicators/${n} indicators/")
|
||||
if [ "$current" = "$updated" ]; then
|
||||
echo "Org description count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
if gh api -X PATCH "orgs/${org}" -f description="$updated" >/dev/null 2>&1; then
|
||||
echo "Org description synced to ${n}."
|
||||
else
|
||||
echo "::warning::org description PATCH failed — ABOUT_SYNC_TOKEN likely lacks admin:org (findings P10.0b)."
|
||||
fi
|
||||
|
||||
# ----- docs version sync (tag-only, soft-skip until PAT scope lands) -----
|
||||
#
|
||||
# Surface 7: the docs site (wickra-lib/wickra-docs) carries the published
|
||||
# version in the "Published versions" table (overview.md) and the Rust
|
||||
# quickstart prose. Unlike the indicator count these change only on a
|
||||
# release, so this step runs on v* tag pushes only and takes the version
|
||||
# straight from the tag. It needs ABOUT_SYNC_TOKEN to have write on
|
||||
# wickra-lib/wickra-docs (findings P10.0a). Until that scope is granted it
|
||||
# soft-skips with a ::warning:: and never fails the run; once granted, every
|
||||
# release self-heals the docs version with no code change (replaces the old
|
||||
# manual P0.5 post-release wiki bump).
|
||||
- name: Sync docs version (wickra-docs)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping docs version sync."
|
||||
exit 0
|
||||
fi
|
||||
# Clone into `docs-ver`, NOT `docs`: on a tag push this job checks out
|
||||
# the wickra repo at the workspace root, which already contains a
|
||||
# top-level `docs/` directory, so `git clone … docs` fails with
|
||||
# "destination path 'docs' already exists" — silently, because of the
|
||||
# 2>/dev/null below — and the version sync never runs (this is exactly
|
||||
# why v0.4.0 did not bump the docs table). `docs-ver` mirrors the
|
||||
# `docs-count` dir used by the count step above and collides with
|
||||
# nothing in the repo.
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/wickra-docs.git" docs-ver 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/wickra-docs — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping docs version sync."
|
||||
exit 0
|
||||
fi
|
||||
cd docs-ver
|
||||
# Published-versions table rows (crates.io / PyPI / npm): replace only the
|
||||
# version number, leaving the trailing padding + pipe intact. The '.' in
|
||||
# the quickstart pattern matches the literal backtick around the version
|
||||
# without needing a backtick in this shell string. Historical "since
|
||||
# X.Y.Z" references contain no such anchor and are never matched.
|
||||
sed -i -E "s/^(\| (crates\.io|PyPI|npm) .*\| )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" overview.md
|
||||
sed -i -E "s/(published crate is at version .)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" Quickstart-Rust.md
|
||||
if git diff --quiet; then
|
||||
echo "Docs version already at ${version}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add overview.md Quickstart-Rust.md
|
||||
git commit -m "chore: sync published version to ${version}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Docs version synced to ${version}."
|
||||
fi
|
||||
|
||||
# ----- webpage (marketing site) self-update (findings P12.1) ------------
|
||||
#
|
||||
# The marketing site (wickra-lib/webpage) carries the same indicator count
|
||||
# and published version as the docs. Mirrors the docs steps above: the
|
||||
# count syncs on push-to-main + tag, the version syncs on v* tags only.
|
||||
# Distinct clone dirs (webpage-count / webpage-ver) avoid any collision on
|
||||
# a tag run. Soft-skips with a ::warning:: if the token can't reach the
|
||||
# repo, so the run never fails.
|
||||
- name: Sync webpage indicator count (wickra-lib/webpage)
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-count 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write on that repo (findings P10.0a). Skipping webpage count sync."
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
|
||||
if git diff --quiet; then
|
||||
echo "Webpage indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add index.md .vitepress/config.ts
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Webpage indicator count synced to ${n}."
|
||||
fi
|
||||
|
||||
- name: Sync webpage version (wickra-lib/webpage)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ABOUT_SYNC_TOKEN }}
|
||||
run: |
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::warning::tag '${GITHUB_REF}' is not a plain vMAJOR.MINOR.PATCH release; skipping webpage version sync."
|
||||
exit 0
|
||||
fi
|
||||
# The webpage pins wickra-wasm to the released version in package.json,
|
||||
# and its Cloudflare Pages build runs `npm clean-install`. release.yml
|
||||
# publishes wickra-wasm to npm in parallel on this same tag and finishes
|
||||
# minutes later, so committing the bump immediately would point the site
|
||||
# at a version npm cannot resolve yet (ETARGET) and break the build —
|
||||
# exactly what happened on v0.4.0. Wait until wickra-wasm@$version is
|
||||
# actually live on npm before committing; if it never appears (the wasm
|
||||
# publish failed), skip rather than push a build-breaking commit.
|
||||
echo "Waiting for wickra-wasm@${version} on npm before bumping the webpage..."
|
||||
attempts=0
|
||||
until npm view "wickra-wasm@${version}" version >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 30 ]; then
|
||||
echo "::warning::wickra-wasm@${version} not on npm after ~15 min; skipping webpage version sync to avoid a broken Cloudflare build."
|
||||
exit 0
|
||||
fi
|
||||
echo " not on npm yet (attempt ${attempts}/30); waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "wickra-wasm@${version} is live on npm; proceeding with the webpage version bump."
|
||||
if ! git clone "https://x-access-token:${GH_TOKEN}@github.com/wickra-lib/webpage.git" webpage-ver 2>/dev/null; then
|
||||
echo "::warning::cannot clone wickra-lib/webpage — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a). Skipping webpage version sync."
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-ver
|
||||
# api/*.md "Latest" lines, the nav version label, and the wickra-wasm
|
||||
# dep pin. The '.' anchors match the backtick / quote / caret without a
|
||||
# literal in this shell string; historical "Since X.Y.Z" prose has no
|
||||
# such anchor and is never matched.
|
||||
sed -i -E "s/(Latest:\*\* \[.wickra(-wasm)? )[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" api/*.md
|
||||
sed -i -E "s/(text: .v)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" .vitepress/config.ts
|
||||
sed -i -E "s/(.wickra-wasm.: .\^)[0-9]+\.[0-9]+\.[0-9]+/\1${version}/" package.json
|
||||
# Keep package-lock.json in sync with the package.json bump. The site's
|
||||
# Cloudflare build runs `npm clean-install` (npm ci), which hard-fails
|
||||
# with EUSAGE if the lockfile still pins the previous wickra-wasm —
|
||||
# editing package.json alone is not enough. The npm-wait above already
|
||||
# proved wickra-wasm@$version is resolvable, so --package-lock-only
|
||||
# regenerates the lock (version + resolved + integrity) without fetching
|
||||
# node_modules. Guard it: if the regen fails, skip the whole commit so we
|
||||
# never push a package.json/lock mismatch that would break the build.
|
||||
if ! npm install --package-lock-only --no-audit --no-fund; then
|
||||
echo "::warning::could not regenerate package-lock.json for wickra-wasm@${version}; skipping webpage version sync to avoid a lockfile-drift build break."
|
||||
exit 0
|
||||
fi
|
||||
if git diff --quiet; then
|
||||
echo "Webpage version already at ${version}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add api/*.md .vitepress/config.ts package.json package-lock.json
|
||||
git commit -m "chore: sync published version to ${version}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
else
|
||||
echo "Webpage version synced to ${version}."
|
||||
fi
|
||||
@@ -0,0 +1,24 @@
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Audit repo-metadata.toml drift
|
||||
run: python .github/scripts/sync-metadata.py --check
|
||||
@@ -0,0 +1,40 @@
|
||||
name: zizmor
|
||||
|
||||
# Static analysis of the GitHub Actions workflows themselves — the surface the
|
||||
# CodeQL pass does not cover. zizmor flags template injection, overly broad
|
||||
# GITHUB_TOKEN permissions, unpinned actions, cache poisoning, and dangerous
|
||||
# triggers. Findings appear under Security -> Code scanning alongside CodeQL.
|
||||
#
|
||||
# Report-only: with `advanced-security: true` the action runs zizmor in SARIF
|
||||
# mode, which exits 0 regardless of findings, so this job never blocks CI —
|
||||
# triage happens in the Security tab. Switch to gating later (e.g. a
|
||||
# `min-severity` input) once the existing findings are triaged.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 4 * * 1' # Mondays 04:17 UTC
|
||||
|
||||
# Least-privilege default for the auto-injected GITHUB_TOKEN; the job raises
|
||||
# exactly the scopes it needs below (matches codeql.yml's pattern).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Audit workflows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload SARIF to code-scanning
|
||||
contents: read # checkout
|
||||
actions: read # online audits resolve referenced actions
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
@@ -0,0 +1,49 @@
|
||||
# zizmor configuration — https://docs.zizmor.sh/configuration/
|
||||
#
|
||||
# cache-poisoning (release.yml):
|
||||
# The release pipeline restores build caches (Swatinem/rust-cache for the Rust
|
||||
# compilation, actions/setup-node) as a deliberate, accepted optimisation.
|
||||
# zizmor flags these under cache-poisoning because release.yml publishes
|
||||
# artifacts to crates.io / PyPI / npm, so a poisoned cache could in theory
|
||||
# reach a released build. Our caches are maintainer-controlled and the
|
||||
# restore speedup is kept on purpose; we accept this risk rather than running
|
||||
# cache-free release builds. (Six of the eight hits are actions/setup-node,
|
||||
# which zizmor reports at "Low" confidence.)
|
||||
#
|
||||
# artipacked (sync-about.yml):
|
||||
# The sync-about job checks out with persisted credentials on purpose: it
|
||||
# pushes the indicator-count fix-up back to the PR head branch (git commit +
|
||||
# git push), which needs the token in the runner's git config. It uploads no
|
||||
# artifacts, so the persisted token is never packaged or leaked; accept it.
|
||||
#
|
||||
# template-injection (sync-about.yml):
|
||||
# False positive. Every flagged expansion is steps.count.outputs.count, the
|
||||
# indicator count produced by an internal `grep -c` over lib.rs. It is not
|
||||
# attacker-controllable, so there is nothing to inject.
|
||||
#
|
||||
# use-trusted-publishing (release.yml):
|
||||
# Informational suggestion to use OIDC trusted publishing for PyPI / npm
|
||||
# instead of long-lived tokens. A worthwhile migration, but it reconfigures
|
||||
# the live publish pipeline on the registry side; tracked separately rather
|
||||
# than blocking on it here.
|
||||
#
|
||||
# superfluous-actions (release.yml):
|
||||
# The GitHub release step uses softprops/action-gh-release. The runner ships
|
||||
# `gh`, so this is replaceable by a script step, but the action is stable and
|
||||
# battle-tested; we keep it deliberately.
|
||||
rules:
|
||||
cache-poisoning:
|
||||
ignore:
|
||||
- release.yml
|
||||
artipacked:
|
||||
ignore:
|
||||
- sync-about.yml
|
||||
template-injection:
|
||||
ignore:
|
||||
- sync-about.yml
|
||||
use-trusted-publishing:
|
||||
ignore:
|
||||
- release.yml
|
||||
superfluous-actions:
|
||||
ignore:
|
||||
- release.yml
|
||||
+8
-3
@@ -42,11 +42,16 @@ 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
|
||||
# index.js + index.d.ts are generated by `napi build` but committed (a matched
|
||||
# pair) so consumers and the repo get TypeScript types; CONTRIBUTING requires
|
||||
# regenerating both when a binding's public API changes.
|
||||
# package-lock.json is committed for the tracked Node packages — bindings/node/
|
||||
# and examples/node/ — so contributors get reproducible npm installs. There is
|
||||
# no top-level npm package, and the ghost-ignored site/ keeps its lockfile local.
|
||||
# See CONTRIBUTING.md "Lockfile policy" for the full per-component breakdown.
|
||||
|
||||
# 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).
|
||||
+1192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
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: support@wickra.org
|
||||
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:
|
||||
- MIT
|
||||
- Apache-2.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 **support@wickra.org**. 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.
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# 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 dual-licensed under the [MIT](LICENSE-MIT) and
|
||||
[Apache-2.0](LICENSE-APACHE) licenses; users may choose either. Unless you
|
||||
explicitly state otherwise, any contribution you intentionally submit for
|
||||
inclusion in the work, as defined in the Apache-2.0 license, shall be dual
|
||||
licensed as above, without any additional terms or conditions.
|
||||
|
||||
## 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 documentation site (docs.wickra.org); the docs live in the `wickra-lib/wickra-docs` repo. |
|
||||
|
||||
## 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.86** for the workspace crates and
|
||||
**1.88** for `bindings/node`; the `msrv` CI job enforces both. These floors are
|
||||
not chosen freely — they are the lowest versions our dependencies allow
|
||||
(criterion 0.8.2, the bench dev-dependency, requires 1.86; napi-build 2.3.2
|
||||
requires 1.88). We keep the MSRV at that dependency-forced floor on purpose so
|
||||
the library builds for the widest possible audience; please don't raise it
|
||||
without a dependency that actually requires it.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## Lockfile policy
|
||||
|
||||
| Component | Lockfile | Tracked? | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Workspace (Rust) | `Cargo.lock` | **yes** | The workspace ships binaries (examples, fuzz harness) and CI builds, so the dependency graph is pinned for reproducible builds. |
|
||||
| `bindings/node` | `package-lock.json` | **yes** | Reproducible `npm install` for the native binding. |
|
||||
| `examples/node` | `package-lock.json` | **yes** | Same — the runnable Node examples link the binding via a `file:` dependency. |
|
||||
| `bindings/python` | — | n/a (no lockfile) | The published package pins only `numpy>=1.22` at runtime; its native code is pinned through the workspace `Cargo.lock`. The CI/bench dev tooling it installs is hash-locked separately — see the `.github/requirements` row. |
|
||||
| `.github/requirements` | `*.txt` (hash-pinned) | **yes** | CI/bench Python tooling, locked with `uv pip compile --generate-hashes` (OpenSSF Scorecard PinnedDependencies). `ci-dev` is split per Python version — `ci-dev-py39.txt` and `ci-dev-py3.txt` — because numpy ships no single release with wheels for both cp39 and cp313; `bench.txt` covers the single-version bench job. |
|
||||
| `fuzz` | `fuzz/Cargo.lock` | **no** (ignored) | `fuzz/` is a detached crate; `cargo-fuzz init` generates `fuzz/.gitignore` which ignores its `Cargo.lock`. The fuzz smoke job resolves dependencies fresh, so the lock is not needed for reproducibility here. |
|
||||
| `site` (marketing) | `package-lock.json` | **no** (ghost-ignored) | The VitePress site is a local-only project excluded via `.git/info/exclude`; its lockfile stays local. |
|
||||
|
||||
When adding a new committed Node package, commit its `package-lock.json` too and
|
||||
remove any matching ignore rule. Do **not** add a top-level `package-lock.json` —
|
||||
the repository root is not an npm package.
|
||||
|
||||
To refresh every committed lockfile in the workspace — `Cargo.lock`,
|
||||
`fuzz/Cargo.lock`, the Node binding lock, and the hash-pinned Python
|
||||
requirements — run `./scripts/update-lockfiles.sh`. It uses `uv` for the Python
|
||||
locks (and bootstraps it on Linux/macOS if absent) so each target Python
|
||||
version's hashed transitive closure can be regenerated without that interpreter
|
||||
installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
|
||||
## 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
|
||||
[documentation site](https://docs.wickra.org) and the
|
||||
`README.md` when behaviour or the public API changes. The docs live in
|
||||
a separate git repository: `https://github.com/wickra-lib/wickra-docs`.
|
||||
- **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.3"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1894,17 +1878,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.1.3"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.1.3"
|
||||
version = "0.5.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.3"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1932,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.1.3"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1941,16 +1934,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.1.3"
|
||||
version = "0.5.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"
|
||||
|
||||
+12
-11
@@ -7,23 +7,24 @@ members = [
|
||||
"bindings/python",
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"examples/rust",
|
||||
]
|
||||
exclude = []
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
authors = ["Wickra Contributors"]
|
||||
version = "0.5.0"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "PolyForm-Noncommercial-1.0.0"
|
||||
repository = "https://github.com/kingchenc/wickra"
|
||||
homepage = "https://github.com/kingchenc/wickra"
|
||||
rust-version = "1.86"
|
||||
license = "MIT OR Apache-2.0"
|
||||
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.3" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.5.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"
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license to
|
||||
distribute covers distributing the software with changes
|
||||
and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
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)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization, or
|
||||
government institution is use for a permitted purpose regardless
|
||||
of the source of funding or obligations resulting from the
|
||||
funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within 32
|
||||
days of receiving notice. Otherwise, all your licenses end
|
||||
immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control
|
||||
of, or are under common control with that organization.
|
||||
**Control** means ownership of substantially all the assets
|
||||
of an entity, or the power to direct its management and
|
||||
policies by vote, contract, or otherwise. Control can be
|
||||
direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,10 +1,18 @@
|
||||
# Wickra
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=339" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/kingchenc/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
|
||||
**Streaming-first technical indicators. Install with `pip install wickra` — no system dependencies.**
|
||||
|
||||
@@ -30,54 +38,87 @@ for price in live_feed:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
|
||||
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 339 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
[indicator chaining](https://docs.wickra.org/Indicator-Chaining), the
|
||||
[data layer](https://docs.wickra.org/Data-Layer).
|
||||
- **Guides** — [Cookbook](https://docs.wickra.org/Cookbook),
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Why Wickra exists
|
||||
|
||||
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
|
||||
talipp, tulipy — and every one of them shares the same blind spot:
|
||||
|
||||
| Library | Install pain | Streaming | Multi-language | Active |
|
||||
|--------------------|-----------------|-----------|----------------|--------|
|
||||
| TA-Lib (Python) | yes (C deps) | no | no | barely |
|
||||
| pandas-ta | clean | no | no | slow |
|
||||
| finta | clean | no | no | stale |
|
||||
| ta-lib-python | yes (C deps) | no | no | barely |
|
||||
| talipp | clean | yes | no | yes |
|
||||
| Tulip Indicators | yes (C deps) | no | partial | stale |
|
||||
| ooples (C#) | clean | no | C# only | yes |
|
||||
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
|
||||
| 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 +133,41 @@ 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
|
||||
339 streaming-first indicators across twenty families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests.
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
|
||||
| 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), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), 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, Plus DM, Minus DM, Plus DI, Minus DI, DX |
|
||||
| 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, Parabolic SAR Extended (SAREXT), 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, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, 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 |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| 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, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
|
||||
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
|
||||
| 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) |
|
||||
|
||||
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
|
||||
`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
|
||||
as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
|
||||
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
|
||||
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
@@ -113,9 +177,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 +240,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 339 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 +280,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:
|
||||
|
||||
@@ -243,25 +324,42 @@ shape together before you invest the time.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
Licensed under either of
|
||||
|
||||
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.
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
||||
|
||||
at your option. Use it, fork it, modify it, redistribute it — commercially or
|
||||
not — file issues, send pull requests; all welcome.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||||
dual licensed as above, without any additional terms or conditions.
|
||||
|
||||
## 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>
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
|
||||
version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| 0.5.x (latest) | :white_check_mark: |
|
||||
| older 0.5.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 **support@wickra.org** 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
|
||||
|
||||
+56
-30
@@ -1,45 +1,71 @@
|
||||
# @wickra/wickra
|
||||
# Wickra — Node.js
|
||||
|
||||
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://www.npmjs.com/package/wickra)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Node.js. `npm install wickra` —
|
||||
prebuilt native binary, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||
it exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
## Install
|
||||
|
||||
Once published, install per platform via the precompiled native package:
|
||||
|
||||
```bash
|
||||
npm install @wickra/wickra
|
||||
npm install wickra
|
||||
```
|
||||
|
||||
## Build from source
|
||||
The native addon ships as a prebuilt binary per platform (Linux, macOS,
|
||||
Windows — x64 and arm64), selected automatically through optional
|
||||
dependencies. There is nothing to compile.
|
||||
|
||||
```bash
|
||||
cd bindings/node
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Usage
|
||||
## Quick start
|
||||
|
||||
```js
|
||||
import { SMA, RSI, MACD, version } from '@wickra/wickra';
|
||||
const wickra = require('wickra');
|
||||
|
||||
console.log('wickra', version());
|
||||
// Batch: run an indicator over a whole array.
|
||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + i * 0.1);
|
||||
const values = new wickra.RSI(14).batch(prices); // null during warmup
|
||||
|
||||
// Batch:
|
||||
const prices = Array.from({ length: 1000 }, (_, i) => 100 + Math.sin(i * 0.1) * 5);
|
||||
const rsi = new RSI(14).batch(prices);
|
||||
|
||||
// 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');
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
const rsi = new wickra.RSI(14);
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // no recomputation over history
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `index.d.ts` for the full TypeScript surface.
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Completeness contract for the Wickra Node bindings: every exported indicator
|
||||
// class must expose the full streaming + batch + lifecycle interface. This
|
||||
// catches a new indicator being wired into the binding without the standard
|
||||
// methods (or an export silently disappearing) without needing a hand-written
|
||||
// test per indicator.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// Bar builders (Renko / Kagi / Point & Figure) implement the `BarBuilder`
|
||||
// contract, not `Indicator`: they emit a variable number of completed bars per
|
||||
// candle and have no fixed warmup or ready state. They expose update/batch/reset
|
||||
// but intentionally not isReady/warmupPeriod, so they are excluded from the
|
||||
// Indicator completeness contract below (their interface is covered by the
|
||||
// dedicated bar-builder tests).
|
||||
const BAR_BUILDERS = new Set(['RenkoBars', 'KagiBars', 'PointAndFigureBars']);
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function), the bar
|
||||
// builders, and any non-indicator export.
|
||||
function indicatorClasses() {
|
||||
return Object.keys(wickra).filter((name) => {
|
||||
const value = wickra[name];
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.prototype &&
|
||||
typeof value.prototype.update === 'function' &&
|
||||
!BAR_BUILDERS.has(name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the binding exports the full indicator catalogue', () => {
|
||||
const names = indicatorClasses();
|
||||
// The published catalogue is 214 indicators. Guard against a regression that
|
||||
// silently drops exported classes (e.g. a stale or partial native build).
|
||||
assert.ok(
|
||||
names.length >= 200,
|
||||
`expected at least 200 indicator classes, got ${names.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
|
||||
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
|
||||
const missing = [];
|
||||
for (const name of indicatorClasses()) {
|
||||
const proto = wickra[name].prototype;
|
||||
for (const method of required) {
|
||||
if (typeof proto[method] !== 'function') {
|
||||
missing.push(`${name}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`indicator classes missing required methods: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
|
||||
// Every indicator that takes no constructor arguments must still satisfy the
|
||||
// pre-warmup contract. (Indicators with required parameters are exercised by
|
||||
// the dedicated suites; here we cover the zero-arg ones generically.)
|
||||
let checked = 0;
|
||||
for (const name of indicatorClasses()) {
|
||||
let instance;
|
||||
try {
|
||||
instance = new wickra[name]();
|
||||
} catch {
|
||||
continue; // needs constructor arguments — covered elsewhere
|
||||
}
|
||||
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
|
||||
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
|
||||
checked += 1;
|
||||
}
|
||||
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
// Input-validation tests for the Wickra Node bindings: malformed constructor
|
||||
// parameters and mismatched batch inputs must raise a JS Error (the napi
|
||||
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
|
||||
// Node counterpart of bindings/python/tests/test_input_validation.py.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// --- Constructors reject invalid periods / parameters ---
|
||||
|
||||
test('ATR rejects a zero period at construction', () => {
|
||||
// ATR validates its period (it drives the Wilder-smoothing length). The
|
||||
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
|
||||
// warmup-1 pass-through rather than an error, so they are not asserted here.
|
||||
assert.throws(() => new wickra.ATR(0), /.*/);
|
||||
});
|
||||
|
||||
test('MACD rejects zero and non-increasing fast/slow periods', () => {
|
||||
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
|
||||
// fast must be strictly less than slow.
|
||||
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
|
||||
});
|
||||
|
||||
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
|
||||
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
|
||||
});
|
||||
|
||||
test('PSAR rejects a step greater than its maximum', () => {
|
||||
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
|
||||
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
|
||||
});
|
||||
|
||||
test('InitialBalance and OpeningRange reject a zero period', () => {
|
||||
assert.throws(() => new wickra.InitialBalance(0), /.*/);
|
||||
assert.throws(() => new wickra.OpeningRange(0), /.*/);
|
||||
});
|
||||
|
||||
test('Ichimoku rejects zero and non-increasing periods', () => {
|
||||
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
|
||||
// Periods must satisfy tenkan < kijun < senkouB.
|
||||
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
|
||||
});
|
||||
|
||||
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
|
||||
// InverseFisherTransform needs a non-zero scaling factor.
|
||||
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
|
||||
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
|
||||
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
|
||||
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
|
||||
// MAMA needs fast limit > slow limit.
|
||||
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
|
||||
// EmpiricalModeDecomposition needs a positive fraction.
|
||||
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
|
||||
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
|
||||
// binding treats their period 0 as a warmup-1 pass-through (same as the
|
||||
// simple moving averages) rather than an error.
|
||||
});
|
||||
|
||||
// --- Batch methods reject mismatched input lengths ---
|
||||
|
||||
test('candle batch methods reject unequal-length columns', () => {
|
||||
const high = [10, 11, 12];
|
||||
const low = [9, 10]; // one short
|
||||
const close = [9.5, 10.5, 11.5];
|
||||
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea batch rejects unequal-length columns', () => {
|
||||
const high = [1, 2, 3];
|
||||
const low = [0.5, 1.5]; // short
|
||||
const volume = [10, 10, 10];
|
||||
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
|
||||
});
|
||||
@@ -73,10 +73,10 @@ test('ATR batch shape', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('zero period is clamped to a valid window', () => {
|
||||
// Constructors cannot throw from JS (napi-rs 2.16 limitation), so they
|
||||
// clamp pathological values like period=0 to the smallest valid window.
|
||||
const sma = new wickra.SMA(0);
|
||||
assert.equal(sma.warmupPeriod(), 1);
|
||||
assert.equal(sma.update(42), 42);
|
||||
test('zero period is rejected at construction', () => {
|
||||
// The core rejects period 0 (Error::PeriodZero); the Node binding propagates
|
||||
// it as a thrown JS error, consistent with the Python and WASM bindings.
|
||||
assert.throws(() => new wickra.SMA(0), /period must be greater than zero/);
|
||||
// A valid period still constructs and runs.
|
||||
assert.equal(new wickra.SMA(1).update(42), 42);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Throughput benchmark for the Wickra Node bindings.
|
||||
//
|
||||
// Measures how many indicator updates per second the native binding sustains,
|
||||
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
|
||||
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
|
||||
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
|
||||
// O(1) streaming engine (there is no install-free TA library on npm with a
|
||||
// comparable surface to compare against), so the headline number is raw
|
||||
// throughput, not a cross-library ratio.
|
||||
//
|
||||
// Run after building the binding:
|
||||
//
|
||||
// cd bindings/node && npm install && npx napi build --platform --release
|
||||
// node benchmarks/throughput.js # 200k bars (default)
|
||||
// node benchmarks/throughput.js --bars 1000000
|
||||
|
||||
const wickra = require('..');
|
||||
|
||||
function parseBars() {
|
||||
const idx = process.argv.indexOf('--bars');
|
||||
if (idx !== -1 && process.argv[idx + 1]) {
|
||||
const n = Number(process.argv[idx + 1]);
|
||||
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
|
||||
console.error('--bars must be a number >= 1000');
|
||||
process.exit(1);
|
||||
}
|
||||
return 200_000;
|
||||
}
|
||||
|
||||
const BARS = parseBars();
|
||||
|
||||
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
|
||||
const close = new Array(BARS);
|
||||
const high = new Array(BARS);
|
||||
const low = new Array(BARS);
|
||||
const volume = new Array(BARS);
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
|
||||
close[i] = mid + Math.sin(i * 0.05) * 2;
|
||||
high[i] = Math.max(close[i], mid) + 1.5;
|
||||
low[i] = Math.min(close[i], mid) - 1.5;
|
||||
volume[i] = 1000 + (i % 97) * 13;
|
||||
}
|
||||
|
||||
// Median elapsed-ns over a few repetitions, after one warmup pass.
|
||||
function timeNs(fn, reps = 3) {
|
||||
fn(); // warmup (JIT + cache)
|
||||
const samples = [];
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
fn();
|
||||
samples.push(Number(process.hrtime.bigint() - t0));
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length / 2)];
|
||||
}
|
||||
|
||||
function mupsFromNs(ns) {
|
||||
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
|
||||
}
|
||||
|
||||
// Each indicator: a streaming step and a batch call over the full series.
|
||||
const indicators = [
|
||||
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
];
|
||||
|
||||
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
|
||||
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
for (const ind of indicators) {
|
||||
const streamNs = timeNs(() => {
|
||||
const inst = ind.make();
|
||||
for (let i = 0; i < BARS; i++) ind.step(inst, i);
|
||||
});
|
||||
const batchNs = timeNs(() => {
|
||||
ind.batch(ind.make());
|
||||
});
|
||||
console.log(
|
||||
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
|
||||
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
|
||||
'better. Numbers are machine-dependent — use them for relative comparison.',
|
||||
);
|
||||
Vendored
+3559
File diff suppressed because it is too large
Load Diff
+315
-1
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, 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, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -322,12 +322,78 @@ 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.McGinleyDynamic = McGinleyDynamic
|
||||
module.exports.FRAMA = FRAMA
|
||||
module.exports.SuperSmoother = SuperSmoother
|
||||
module.exports.FisherTransform = FisherTransform
|
||||
module.exports.Decycler = Decycler
|
||||
module.exports.CenterOfGravity = CenterOfGravity
|
||||
module.exports.CyberneticCycle = CyberneticCycle
|
||||
module.exports.InstantaneousTrendline = InstantaneousTrendline
|
||||
module.exports.EhlersStochastic = EhlersStochastic
|
||||
module.exports.RVIVolatility = RVIVolatility
|
||||
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.MIDPOINT = MIDPOINT
|
||||
module.exports.ROCP = ROCP
|
||||
module.exports.ROCR = ROCR
|
||||
module.exports.ROCR100 = ROCR100
|
||||
module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT
|
||||
module.exports.TSF = TSF
|
||||
module.exports.Autocorrelation = Autocorrelation
|
||||
module.exports.HurstExponent = HurstExponent
|
||||
module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.PairwiseBeta = PairwiseBeta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.RollingCorrelation = RollingCorrelation
|
||||
module.exports.RollingCovariance = RollingCovariance
|
||||
module.exports.OuHalfLife = OuHalfLife
|
||||
module.exports.SpreadHurst = SpreadHurst
|
||||
module.exports.DistanceSsd = DistanceSsd
|
||||
module.exports.BetaNeutralSpread = BetaNeutralSpread
|
||||
module.exports.PairSpreadZScore = PairSpreadZScore
|
||||
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
|
||||
module.exports.Cointegration = Cointegration
|
||||
module.exports.RelativeStrengthAB = RelativeStrengthAB
|
||||
module.exports.VarianceRatio = VarianceRatio
|
||||
module.exports.GrangerCausality = GrangerCausality
|
||||
module.exports.KalmanHedgeRatio = KalmanHedgeRatio
|
||||
module.exports.SpreadBollingerBands = SpreadBollingerBands
|
||||
module.exports.MACD = MACD
|
||||
module.exports.MACDFIX = MACDFIX
|
||||
module.exports.MACDEXT = MACDEXT
|
||||
module.exports.BollingerBands = BollingerBands
|
||||
module.exports.ATR = ATR
|
||||
module.exports.PLUS_DM = PLUS_DM
|
||||
module.exports.MINUS_DM = MINUS_DM
|
||||
module.exports.PLUS_DI = PLUS_DI
|
||||
module.exports.MINUS_DI = MINUS_DI
|
||||
module.exports.DX = DX
|
||||
module.exports.MIDPRICE = MIDPRICE
|
||||
module.exports.AVGPRICE = AVGPRICE
|
||||
module.exports.SAREXT = SAREXT
|
||||
module.exports.HT_PHASOR = HT_PHASOR
|
||||
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
|
||||
@@ -335,6 +401,254 @@ 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.Inertia = Inertia
|
||||
module.exports.ConnorsRSI = ConnorsRSI
|
||||
module.exports.LaguerreRSI = LaguerreRSI
|
||||
module.exports.SMI = SMI
|
||||
module.exports.KST = KST
|
||||
module.exports.PGO = PGO
|
||||
module.exports.RVI = RVI
|
||||
module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram
|
||||
module.exports.STC = STC
|
||||
module.exports.ElderImpulse = ElderImpulse
|
||||
module.exports.ZeroLagMACD = ZeroLagMACD
|
||||
module.exports.CFO = CFO
|
||||
module.exports.APO = APO
|
||||
module.exports.KAMA = KAMA
|
||||
module.exports.EVWMA = EVWMA
|
||||
module.exports.Alligator = Alligator
|
||||
module.exports.JMA = JMA
|
||||
module.exports.VIDYA = VIDYA
|
||||
module.exports.ALMA = ALMA
|
||||
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.NVI = NVI
|
||||
module.exports.PVI = PVI
|
||||
module.exports.VolumeOscillator = VolumeOscillator
|
||||
module.exports.KVO = KVO
|
||||
module.exports.WilliamsAD = WilliamsAD
|
||||
module.exports.AnchoredRSI = AnchoredRSI
|
||||
module.exports.AnchoredVWAP = AnchoredVWAP
|
||||
module.exports.DemandIndex = DemandIndex
|
||||
module.exports.TSV = TSV
|
||||
module.exports.VZO = VZO
|
||||
module.exports.MarketFacilitationIndex = MarketFacilitationIndex
|
||||
module.exports.EaseOfMovement = EaseOfMovement
|
||||
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.YangZhangVolatility = YangZhangVolatility
|
||||
module.exports.RogersSatchellVolatility = RogersSatchellVolatility
|
||||
module.exports.GarmanKlassVolatility = GarmanKlassVolatility
|
||||
module.exports.ParkinsonVolatility = ParkinsonVolatility
|
||||
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.WaveTrend = WaveTrend
|
||||
module.exports.RWI = RWI
|
||||
module.exports.Vortex = Vortex
|
||||
module.exports.MassIndex = MassIndex
|
||||
module.exports.StochRSI = StochRSI
|
||||
module.exports.UltimateOscillator = UltimateOscillator
|
||||
module.exports.PPO = PPO
|
||||
module.exports.Coppock = Coppock
|
||||
module.exports.VWMA = VWMA
|
||||
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.InverseFisherTransform = InverseFisherTransform
|
||||
module.exports.DecyclerOscillator = DecyclerOscillator
|
||||
module.exports.RoofingFilter = RoofingFilter
|
||||
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
|
||||
module.exports.HT_DCPHASE = HT_DCPHASE
|
||||
module.exports.HT_TRENDMODE = HT_TRENDMODE
|
||||
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.ValueArea = ValueArea
|
||||
module.exports.VolumeProfile = VolumeProfile
|
||||
module.exports.TpoProfile = TpoProfile
|
||||
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
|
||||
module.exports.TwoCrows = TwoCrows
|
||||
module.exports.UpsideGapTwoCrows = UpsideGapTwoCrows
|
||||
module.exports.IdenticalThreeCrows = IdenticalThreeCrows
|
||||
module.exports.ThreeLineStrike = ThreeLineStrike
|
||||
module.exports.ThreeStarsInSouth = ThreeStarsInSouth
|
||||
module.exports.AbandonedBaby = AbandonedBaby
|
||||
module.exports.AdvanceBlock = AdvanceBlock
|
||||
module.exports.BeltHold = BeltHold
|
||||
module.exports.Breakaway = Breakaway
|
||||
module.exports.Counterattack = Counterattack
|
||||
module.exports.DojiStar = DojiStar
|
||||
module.exports.DragonflyDoji = DragonflyDoji
|
||||
module.exports.GravestoneDoji = GravestoneDoji
|
||||
module.exports.LongLeggedDoji = LongLeggedDoji
|
||||
module.exports.RickshawMan = RickshawMan
|
||||
module.exports.EveningDojiStar = EveningDojiStar
|
||||
module.exports.MorningDojiStar = MorningDojiStar
|
||||
module.exports.GapSideBySideWhite = GapSideBySideWhite
|
||||
module.exports.HighWave = HighWave
|
||||
module.exports.Hikkake = Hikkake
|
||||
module.exports.HikkakeModified = HikkakeModified
|
||||
module.exports.HomingPigeon = HomingPigeon
|
||||
module.exports.OnNeck = OnNeck
|
||||
module.exports.InNeck = InNeck
|
||||
module.exports.Thrusting = Thrusting
|
||||
module.exports.SeparatingLines = SeparatingLines
|
||||
module.exports.Kicking = Kicking
|
||||
module.exports.KickingByLength = KickingByLength
|
||||
module.exports.LadderBottom = LadderBottom
|
||||
module.exports.MatHold = MatHold
|
||||
module.exports.MatchingLow = MatchingLow
|
||||
module.exports.LongLine = LongLine
|
||||
module.exports.ShortLine = ShortLine
|
||||
module.exports.RisingThreeMethods = RisingThreeMethods
|
||||
module.exports.FallingThreeMethods = FallingThreeMethods
|
||||
module.exports.UpsideGapThreeMethods = UpsideGapThreeMethods
|
||||
module.exports.DownsideGapThreeMethods = DownsideGapThreeMethods
|
||||
module.exports.StalledPattern = StalledPattern
|
||||
module.exports.StickSandwich = StickSandwich
|
||||
module.exports.Takuri = Takuri
|
||||
module.exports.ClosingMarubozu = ClosingMarubozu
|
||||
module.exports.OpeningMarubozu = OpeningMarubozu
|
||||
module.exports.TasukiGap = TasukiGap
|
||||
module.exports.UniqueThreeRiver = UniqueThreeRiver
|
||||
module.exports.ConcealingBabySwallow = ConcealingBabySwallow
|
||||
module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
|
||||
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
|
||||
module.exports.Microprice = Microprice
|
||||
module.exports.QuotedSpread = QuotedSpread
|
||||
module.exports.DepthSlope = DepthSlope
|
||||
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
|
||||
module.exports.SignedVolume = SignedVolume
|
||||
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
|
||||
module.exports.TradeImbalance = TradeImbalance
|
||||
module.exports.EffectiveSpread = EffectiveSpread
|
||||
module.exports.RealizedSpread = RealizedSpread
|
||||
module.exports.KylesLambda = KylesLambda
|
||||
module.exports.Footprint = Footprint
|
||||
module.exports.FundingRate = FundingRate
|
||||
module.exports.FundingRateMean = FundingRateMean
|
||||
module.exports.FundingRateZScore = FundingRateZScore
|
||||
module.exports.FundingBasis = FundingBasis
|
||||
module.exports.OpenInterestDelta = OpenInterestDelta
|
||||
module.exports.OIPriceDivergence = OIPriceDivergence
|
||||
module.exports.OIWeighted = OIWeighted
|
||||
module.exports.LongShortRatio = LongShortRatio
|
||||
module.exports.TakerBuySellRatio = TakerBuySellRatio
|
||||
module.exports.LiquidationFeatures = LiquidationFeatures
|
||||
module.exports.TermStructureBasis = TermStructureBasis
|
||||
module.exports.CalendarSpread = CalendarSpread
|
||||
module.exports.AdvanceDecline = AdvanceDecline
|
||||
module.exports.AdvanceDeclineRatio = AdvanceDeclineRatio
|
||||
module.exports.AdVolumeLine = AdVolumeLine
|
||||
module.exports.McClellanOscillator = McClellanOscillator
|
||||
module.exports.McClellanSummationIndex = McClellanSummationIndex
|
||||
module.exports.Trin = Trin
|
||||
module.exports.BreadthThrust = BreadthThrust
|
||||
module.exports.NewHighsNewLows = NewHighsNewLows
|
||||
module.exports.HighLowIndex = HighLowIndex
|
||||
module.exports.PercentAboveMa = PercentAboveMa
|
||||
module.exports.UpDownVolumeRatio = UpDownVolumeRatio
|
||||
module.exports.BullishPercentIndex = BullishPercentIndex
|
||||
module.exports.CumulativeVolumeIndex = CumulativeVolumeIndex
|
||||
module.exports.AbsoluteBreadthIndex = AbsoluteBreadthIndex
|
||||
module.exports.TickIndex = TickIndex
|
||||
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.RenkoBars = RenkoBars
|
||||
module.exports.KagiBars = KagiBars
|
||||
module.exports.PointAndFigureBars = PointAndFigureBars
|
||||
module.exports.Alpha = Alpha
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.1.3",
|
||||
"version": "0.5.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": "MIT OR Apache-2.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.3",
|
||||
"version": "0.5.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": "MIT OR Apache-2.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.5.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": "MIT OR Apache-2.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.3",
|
||||
"version": "0.5.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": "MIT OR Apache-2.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.5.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": "MIT OR Apache-2.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.3",
|
||||
"version": "0.5.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": "MIT OR Apache-2.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.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.5.0",
|
||||
"wickra-darwin-x64": "0.5.0",
|
||||
"wickra-linux-arm64-gnu": "0.5.0",
|
||||
"wickra-linux-x64-gnu": "0.5.0",
|
||||
"wickra-win32-arm64-msvc": "0.5.0",
|
||||
"wickra-win32-x64-msvc": "0.5.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.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.1.3",
|
||||
"version": "0.5.0",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <kingchencp@gmail.com>",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "MIT OR Apache-2.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,20 +35,24 @@
|
||||
"defaults": false,
|
||||
"additional": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc"
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc"
|
||||
]
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.1.3",
|
||||
"wickra-darwin-x64": "0.1.3",
|
||||
"wickra-darwin-arm64": "0.1.3",
|
||||
"wickra-win32-x64-msvc": "0.1.3"
|
||||
"wickra-linux-x64-gnu": "0.5.0",
|
||||
"wickra-linux-arm64-gnu": "0.5.0",
|
||||
"wickra-darwin-x64": "0.5.0",
|
||||
"wickra-darwin-arm64": "0.5.0",
|
||||
"wickra-win32-x64-msvc": "0.5.0",
|
||||
"wickra-win32-arm64-msvc": "0.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
@@ -56,7 +60,8 @@
|
||||
"artifacts": "napi artifacts",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
"test": "node --test __tests__/",
|
||||
"bench": "node benchmarks/throughput.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
|
||||
+12972
-295
File diff suppressed because it is too large
Load Diff
+45
-29
@@ -1,54 +1,70 @@
|
||||
# Wickra — Python bindings
|
||||
# Wickra — Python
|
||||
|
||||
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://pypi.org/project/wickra/)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Python. `pip install wickra` — no
|
||||
system dependencies, no C build tooling.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Python binding (PyO3); it
|
||||
exposes 200+ streaming-first indicators across sixteen families.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install wickra
|
||||
```
|
||||
|
||||
Pre-built wheels ship for Linux, macOS, and Windows — there is nothing to
|
||||
compile and no C library to track down.
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
# Batch — TA-Lib-style usage
|
||||
# Batch: classic TA-Lib-style usage over a whole array.
|
||||
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: the same indicator, fed tick by tick in O(1).
|
||||
rsi = ta.RSI(14)
|
||||
for price in live_feed:
|
||||
value = rsi.update(price) # no recomputation over history
|
||||
if value is not None and value > 70:
|
||||
print("overbought")
|
||||
```
|
||||
|
||||
## What's included
|
||||
`batch(prices)` and feeding the same prices through `update()` produce
|
||||
identical values — the equivalence is enforced by the test suite.
|
||||
|
||||
25 streaming-first indicators across four families. Every one passes a
|
||||
`batch == streaming` equivalence test and reference-value tests:
|
||||
## Documentation
|
||||
|
||||
- **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
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
## Why streaming-first matters
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||
|
||||
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.
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Full project
|
||||
## Disclaimer
|
||||
|
||||
See <https://github.com/kingchenc/wickra> for benchmarks, the Rust core,
|
||||
Node.js and WebAssembly bindings, examples, and CI.
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## 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 either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
@@ -4,22 +4,23 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.1.3"
|
||||
version = "0.5.0"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Financial and Insurance Industry",
|
||||
"License :: Free for non-commercial use",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"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 +46,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,728 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
ADX,
|
||||
ATR,
|
||||
Aroon,
|
||||
AwesomeOscillator,
|
||||
BollingerBands,
|
||||
CCI,
|
||||
DEMA,
|
||||
Donchian,
|
||||
TSF,
|
||||
LINEARREG_INTERCEPT,
|
||||
ROCR100,
|
||||
ROCR,
|
||||
ROCP,
|
||||
AVGPRICE,
|
||||
MIDPOINT,
|
||||
MIDPRICE,
|
||||
DX,
|
||||
MINUS_DI,
|
||||
PLUS_DI,
|
||||
# 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,
|
||||
AnchoredRSI,
|
||||
MACD,
|
||||
MACDFIX,
|
||||
MACDEXT,
|
||||
Stochastic,
|
||||
TEMA,
|
||||
TRIX,
|
||||
VWAP,
|
||||
CCI,
|
||||
ROC,
|
||||
WilliamsR,
|
||||
WMA,
|
||||
ADX,
|
||||
ADXR,
|
||||
PLUS_DM,
|
||||
MINUS_DM,
|
||||
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,
|
||||
SAREXT,
|
||||
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
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
VarianceRatio,
|
||||
BetaNeutralSpread,
|
||||
DistanceSsd,
|
||||
SpreadHurst,
|
||||
OuHalfLife,
|
||||
RollingCovariance,
|
||||
RollingCorrelation,
|
||||
TypicalPrice,
|
||||
MedianPrice,
|
||||
WeightedClose,
|
||||
LinearRegression,
|
||||
LinRegSlope,
|
||||
ZScore,
|
||||
LinRegAngle,
|
||||
Variance,
|
||||
CoefficientOfVariation,
|
||||
Skewness,
|
||||
Kurtosis,
|
||||
StandardError,
|
||||
DetrendedStdDev,
|
||||
RSquared,
|
||||
Autocorrelation,
|
||||
MedianAbsoluteDeviation,
|
||||
HurstExponent,
|
||||
PearsonCorrelation,
|
||||
Beta,
|
||||
PairwiseBeta,
|
||||
PairSpreadZScore,
|
||||
LeadLagCrossCorrelation,
|
||||
Cointegration,
|
||||
RelativeStrengthAB,
|
||||
SpearmanCorrelation,
|
||||
# Ehlers / Cycle
|
||||
SuperSmoother,
|
||||
FisherTransform,
|
||||
InverseFisherTransform,
|
||||
Decycler,
|
||||
DecyclerOscillator,
|
||||
RoofingFilter,
|
||||
CenterOfGravity,
|
||||
CyberneticCycle,
|
||||
InstantaneousTrendline,
|
||||
EhlersStochastic,
|
||||
EmpiricalModeDecomposition,
|
||||
HilbertDominantCycle,
|
||||
HT_DCPHASE,
|
||||
HT_PHASOR,
|
||||
HT_TRENDMODE,
|
||||
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,
|
||||
VolumeProfile,
|
||||
TpoProfile,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Alt-Chart Bars
|
||||
RenkoBars,
|
||||
KagiBars,
|
||||
PointAndFigureBars,
|
||||
# Candlestick patterns
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
HangingMan,
|
||||
ShootingStar,
|
||||
Engulfing,
|
||||
Harami,
|
||||
MorningEveningStar,
|
||||
ThreeSoldiersOrCrows,
|
||||
PiercingDarkCloud,
|
||||
Marubozu,
|
||||
Tweezer,
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
TwoCrows,
|
||||
UpsideGapTwoCrows,
|
||||
IdenticalThreeCrows,
|
||||
ThreeLineStrike,
|
||||
ThreeStarsInSouth,
|
||||
AbandonedBaby,
|
||||
AdvanceBlock,
|
||||
BeltHold,
|
||||
Breakaway,
|
||||
Counterattack,
|
||||
DojiStar,
|
||||
DragonflyDoji,
|
||||
GravestoneDoji,
|
||||
LongLeggedDoji,
|
||||
RickshawMan,
|
||||
EveningDojiStar,
|
||||
MorningDojiStar,
|
||||
GapSideBySideWhite,
|
||||
HighWave,
|
||||
Hikkake,
|
||||
HikkakeModified,
|
||||
HomingPigeon,
|
||||
OnNeck,
|
||||
InNeck,
|
||||
Thrusting,
|
||||
SeparatingLines,
|
||||
Kicking,
|
||||
KickingByLength,
|
||||
LadderBottom,
|
||||
MatHold,
|
||||
MatchingLow,
|
||||
LongLine,
|
||||
ShortLine,
|
||||
RisingThreeMethods,
|
||||
FallingThreeMethods,
|
||||
UpsideGapThreeMethods,
|
||||
DownsideGapThreeMethods,
|
||||
StalledPattern,
|
||||
StickSandwich,
|
||||
Takuri,
|
||||
ClosingMarubozu,
|
||||
OpeningMarubozu,
|
||||
TasukiGap,
|
||||
UniqueThreeRiver,
|
||||
ConcealingBabySwallow,
|
||||
# Microstructure: order book
|
||||
OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN,
|
||||
OrderBookImbalanceFull,
|
||||
Microprice,
|
||||
QuotedSpread,
|
||||
DepthSlope,
|
||||
# Microstructure: trade flow
|
||||
SignedVolume,
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Microstructure: price impact
|
||||
EffectiveSpread,
|
||||
RealizedSpread,
|
||||
KylesLambda,
|
||||
# Microstructure: footprint
|
||||
Footprint,
|
||||
# Derivatives
|
||||
FundingRate,
|
||||
FundingRateMean,
|
||||
FundingRateZScore,
|
||||
FundingBasis,
|
||||
OpenInterestDelta,
|
||||
OIPriceDivergence,
|
||||
OIWeighted,
|
||||
LongShortRatio,
|
||||
TakerBuySellRatio,
|
||||
LiquidationFeatures,
|
||||
TermStructureBasis,
|
||||
CalendarSpread,
|
||||
# Market Breadth
|
||||
TickIndex,
|
||||
AbsoluteBreadthIndex,
|
||||
CumulativeVolumeIndex,
|
||||
BullishPercentIndex,
|
||||
UpDownVolumeRatio,
|
||||
PercentAboveMa,
|
||||
HighLowIndex,
|
||||
NewHighsNewLows,
|
||||
BreadthThrust,
|
||||
Trin,
|
||||
McClellanSummationIndex,
|
||||
McClellanOscillator,
|
||||
AdVolumeLine,
|
||||
AdvanceDeclineRatio,
|
||||
AdvanceDecline,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
CalmarRatio,
|
||||
OmegaRatio,
|
||||
MaxDrawdown,
|
||||
AverageDrawdown,
|
||||
DrawdownDuration,
|
||||
PainIndex,
|
||||
ValueAtRisk,
|
||||
ConditionalValueAtRisk,
|
||||
ProfitFactor,
|
||||
GainLossRatio,
|
||||
RecoveryFactor,
|
||||
KellyCriterion,
|
||||
TreynorRatio,
|
||||
InformationRatio,
|
||||
Alpha,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TSF",
|
||||
"LINEARREG_INTERCEPT",
|
||||
"ROCR100",
|
||||
"ROCR",
|
||||
"ROCP",
|
||||
"AVGPRICE",
|
||||
"MIDPOINT",
|
||||
"MIDPRICE",
|
||||
"DX",
|
||||
"MINUS_DI",
|
||||
"PLUS_DI",
|
||||
"__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",
|
||||
"AnchoredRSI",
|
||||
"MACD",
|
||||
"MACDFIX",
|
||||
"MACDEXT",
|
||||
"Stochastic",
|
||||
"CCI",
|
||||
"ROC",
|
||||
"WilliamsR",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"PLUS_DM",
|
||||
"MINUS_DM",
|
||||
"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",
|
||||
"SAREXT",
|
||||
"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
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
"VarianceRatio",
|
||||
"BetaNeutralSpread",
|
||||
"DistanceSsd",
|
||||
"SpreadHurst",
|
||||
"OuHalfLife",
|
||||
"RollingCovariance",
|
||||
"RollingCorrelation",
|
||||
"TypicalPrice",
|
||||
"MedianPrice",
|
||||
"WeightedClose",
|
||||
"LinearRegression",
|
||||
"LinRegSlope",
|
||||
"ZScore",
|
||||
"LinRegAngle",
|
||||
"Variance",
|
||||
"CoefficientOfVariation",
|
||||
"Skewness",
|
||||
"Kurtosis",
|
||||
"StandardError",
|
||||
"DetrendedStdDev",
|
||||
"RSquared",
|
||||
"Autocorrelation",
|
||||
"MedianAbsoluteDeviation",
|
||||
"HurstExponent",
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"PairwiseBeta",
|
||||
"PairSpreadZScore",
|
||||
"LeadLagCrossCorrelation",
|
||||
"Cointegration",
|
||||
"RelativeStrengthAB",
|
||||
"SpearmanCorrelation",
|
||||
# Ehlers / Cycle
|
||||
"SuperSmoother",
|
||||
"FisherTransform",
|
||||
"InverseFisherTransform",
|
||||
"Decycler",
|
||||
"DecyclerOscillator",
|
||||
"RoofingFilter",
|
||||
"CenterOfGravity",
|
||||
"CyberneticCycle",
|
||||
"InstantaneousTrendline",
|
||||
"EhlersStochastic",
|
||||
"EmpiricalModeDecomposition",
|
||||
"HilbertDominantCycle",
|
||||
"HT_DCPHASE",
|
||||
"HT_PHASOR",
|
||||
"HT_TRENDMODE",
|
||||
"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",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Alt-Chart Bars
|
||||
"RenkoBars",
|
||||
"KagiBars",
|
||||
"PointAndFigureBars",
|
||||
# Candlestick patterns
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
"HangingMan",
|
||||
"ShootingStar",
|
||||
"Engulfing",
|
||||
"Harami",
|
||||
"MorningEveningStar",
|
||||
"ThreeSoldiersOrCrows",
|
||||
"PiercingDarkCloud",
|
||||
"Marubozu",
|
||||
"Tweezer",
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
"TwoCrows",
|
||||
"UpsideGapTwoCrows",
|
||||
"IdenticalThreeCrows",
|
||||
"ThreeLineStrike",
|
||||
"ThreeStarsInSouth",
|
||||
"AbandonedBaby",
|
||||
"AdvanceBlock",
|
||||
"BeltHold",
|
||||
"Breakaway",
|
||||
"Counterattack",
|
||||
"DojiStar",
|
||||
"DragonflyDoji",
|
||||
"GravestoneDoji",
|
||||
"LongLeggedDoji",
|
||||
"RickshawMan",
|
||||
"EveningDojiStar",
|
||||
"MorningDojiStar",
|
||||
"GapSideBySideWhite",
|
||||
"HighWave",
|
||||
"Hikkake",
|
||||
"HikkakeModified",
|
||||
"HomingPigeon",
|
||||
"OnNeck",
|
||||
"InNeck",
|
||||
"Thrusting",
|
||||
"SeparatingLines",
|
||||
"Kicking",
|
||||
"KickingByLength",
|
||||
"LadderBottom",
|
||||
"MatHold",
|
||||
"MatchingLow",
|
||||
"LongLine",
|
||||
"ShortLine",
|
||||
"RisingThreeMethods",
|
||||
"FallingThreeMethods",
|
||||
"UpsideGapThreeMethods",
|
||||
"DownsideGapThreeMethods",
|
||||
"StalledPattern",
|
||||
"StickSandwich",
|
||||
"Takuri",
|
||||
"ClosingMarubozu",
|
||||
"OpeningMarubozu",
|
||||
"TasukiGap",
|
||||
"UniqueThreeRiver",
|
||||
"ConcealingBabySwallow",
|
||||
# Microstructure: order book
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
# Microstructure: trade flow
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Microstructure: price impact
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
# Microstructure: footprint
|
||||
"Footprint",
|
||||
# Derivatives
|
||||
"FundingRate",
|
||||
"FundingRateMean",
|
||||
"FundingRateZScore",
|
||||
"FundingBasis",
|
||||
"OpenInterestDelta",
|
||||
"OIPriceDivergence",
|
||||
"OIWeighted",
|
||||
"LongShortRatio",
|
||||
"TakerBuySellRatio",
|
||||
"LiquidationFeatures",
|
||||
"TermStructureBasis",
|
||||
"CalendarSpread",
|
||||
# Market Breadth
|
||||
"TickIndex",
|
||||
"AbsoluteBreadthIndex",
|
||||
"CumulativeVolumeIndex",
|
||||
"BullishPercentIndex",
|
||||
"UpDownVolumeRatio",
|
||||
"PercentAboveMa",
|
||||
"HighLowIndex",
|
||||
"NewHighsNewLows",
|
||||
"BreadthThrust",
|
||||
"Trin",
|
||||
"McClellanSummationIndex",
|
||||
"McClellanOscillator",
|
||||
"AdVolumeLine",
|
||||
"AdvanceDeclineRatio",
|
||||
"AdvanceDecline",
|
||||
# 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: ...
|
||||
|
||||
+16305
-119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
"""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_pairwise_beta_rejects_bad_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(1)
|
||||
|
||||
|
||||
def test_unequal_length_pair_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairwiseBeta(20).batch(a, b)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(20, 20).batch(a, b)
|
||||
|
||||
|
||||
def test_pair_spread_zscore_rejects_bad_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(1, 20)
|
||||
with pytest.raises(ValueError):
|
||||
ta.PairSpreadZScore(20, 1)
|
||||
|
||||
|
||||
def test_lead_lag_rejects_bad_params():
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(1, 5)
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(10, 0)
|
||||
|
||||
|
||||
def test_lead_lag_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
|
||||
|
||||
|
||||
def test_cointegration_rejects_too_small_period():
|
||||
# period must be >= 2*adf_lags + 4.
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(3, 0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(5, 1)
|
||||
|
||||
|
||||
def test_cointegration_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.Cointegration(20, 1).batch(a, b)
|
||||
|
||||
|
||||
def test_relative_strength_rejects_zero_periods():
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(0, 14)
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(20, 0)
|
||||
|
||||
|
||||
def test_relative_strength_unequal_length_batch_raises(sine_prices):
|
||||
a = np.ascontiguousarray((sine_prices + 100.0).astype(np.float64))
|
||||
b = a[:-1]
|
||||
with pytest.raises(ValueError):
|
||||
ta.RelativeStrengthAB(10, 14).batch(a, b)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_orderbook_topn_zero_levels_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTopN(0)
|
||||
|
||||
|
||||
def test_orderbook_unequal_price_size_lengths_raise():
|
||||
# bid_px has 2 entries but bid_sz has 1 -> mismatched -> ValueError.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceTop1().update([100.0, 99.0], [1.0], [101.0], [1.0])
|
||||
with pytest.raises(ValueError):
|
||||
ta.Microprice().update([100.0], [1.0], [101.0, 102.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_crossed_book_raises():
|
||||
# best_bid (102) >= best_ask (101) is a crossed book -> rejected.
|
||||
with pytest.raises(ValueError):
|
||||
ta.QuotedSpread().update([102.0], [1.0], [101.0], [1.0])
|
||||
|
||||
|
||||
def test_orderbook_misordered_levels_raise():
|
||||
# Bids must be strictly descending in price.
|
||||
with pytest.raises(ValueError):
|
||||
ta.OrderBookImbalanceFull().update([99.0, 100.0], [1.0, 1.0], [101.0], [1.0])
|
||||
|
||||
|
||||
def test_trade_imbalance_zero_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.TradeImbalance(0)
|
||||
|
||||
|
||||
def test_trade_negative_size_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SignedVolume().update(100.0, -1.0, True)
|
||||
|
||||
|
||||
def test_trade_non_positive_price_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.CumulativeVolumeDelta().update(0.0, 1.0, True)
|
||||
|
||||
|
||||
def test_trade_batch_unequal_lengths_raise():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False])
|
||||
|
||||
|
||||
def test_effective_spread_non_positive_mid_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.EffectiveSpread().update(100.0, 1.0, True, 0.0)
|
||||
|
||||
|
||||
def test_effective_spread_batch_unequal_lengths_raise():
|
||||
with pytest.raises(ValueError):
|
||||
ta.EffectiveSpread().batch([100.0, 100.0], [1.0, 1.0], [True, False], [100.0])
|
||||
|
||||
|
||||
def test_realized_spread_zero_horizon_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.RealizedSpread(0)
|
||||
|
||||
|
||||
def test_kyles_lambda_window_below_two_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.KylesLambda(1)
|
||||
|
||||
|
||||
def test_footprint_non_positive_tick_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.Footprint(0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Footprint(-1.0)
|
||||
|
||||
|
||||
def test_funding_rate_mean_zero_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.FundingRateMean(0)
|
||||
|
||||
|
||||
def test_funding_rate_zscore_zero_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.FundingRateZScore(0)
|
||||
|
||||
|
||||
def test_funding_basis_non_positive_index_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.FundingBasis().update(100.0, 0.0)
|
||||
|
||||
|
||||
def test_funding_rate_non_finite_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.FundingRate().update(float("nan"))
|
||||
|
||||
|
||||
def test_oi_price_divergence_zero_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OIPriceDivergence(0)
|
||||
|
||||
|
||||
def test_oi_weighted_non_positive_mark_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.OIWeighted().update(0.0, 100.0)
|
||||
|
||||
|
||||
def test_term_structure_basis_non_positive_index_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.TermStructureBasis().update(100.0, 0.0)
|
||||
|
||||
|
||||
def test_calendar_spread_non_positive_mark_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.CalendarSpread().update(100.0, 0.0)
|
||||
@@ -66,6 +66,232 @@ def test_rsi_wilder_textbook_first_value():
|
||||
assert math.isclose(out[14], 70.464, abs_tol=0.05)
|
||||
|
||||
|
||||
def test_anchored_rsi_cumulative_reference():
|
||||
"""Cumulative anchored RSI: 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)."""
|
||||
out = ta.AnchoredRSI().batch(np.array([10.0, 11.0, 9.0, 12.0]))
|
||||
assert math.isclose(out[1], 100.0, abs_tol=1e-9)
|
||||
assert math.isclose(out[2], 100.0 - 100.0 / 1.5, abs_tol=1e-6)
|
||||
assert math.isclose(out[3], 100.0 - 100.0 / 3.0, abs_tol=1e-6)
|
||||
|
||||
|
||||
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 +338,699 @@ 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_pairwise_beta_squared_price_is_two():
|
||||
# a = b² ⇒ a's log-returns are exactly 2× b's ⇒ pairwise beta = 2.
|
||||
# b must have *varying* returns (a constant-return path has zero variance
|
||||
# and an undefined slope, which the indicator reports as 0).
|
||||
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
|
||||
a = b**2
|
||||
out = ta.PairwiseBeta(5).batch(a, b)
|
||||
assert math.isclose(out[-1], 2.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_pairwise_beta_inverse_price_is_minus_one():
|
||||
# a = 1/b ⇒ a's log-returns are −1× b's ⇒ pairwise beta = −1.
|
||||
b = np.array([100.0 + 10.0 * math.sin(i * 0.5) for i in range(20)])
|
||||
a = 1.0 / b
|
||||
out = ta.PairwiseBeta(5).batch(a, b)
|
||||
assert math.isclose(out[-1], -1.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_pair_spread_zscore_flat_benchmark_sign():
|
||||
# Flat b ⇒ hedge ratio 0 ⇒ spread = ln(a). With z_period = 2 the z-score
|
||||
# collapses to the sign of the last move: rising a ⇒ +1, falling a ⇒ −1.
|
||||
a = np.array([100.0, 100.0, 110.0, 105.0, 130.0])
|
||||
b = np.full_like(a, 100.0)
|
||||
out = ta.PairSpreadZScore(2, 2).batch(a, b)
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
assert math.isclose(out[-2], -1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_lead_lag_cross_correlation_negative_lead():
|
||||
# a is a delayed copy of b ⇒ b leads a ⇒ lag = −2, correlation ≈ 1.
|
||||
def sig(t):
|
||||
return math.sin(t * 0.4) + 0.4 * math.sin(t * 1.1) + 0.2 * math.cos(t * 0.27)
|
||||
|
||||
n = 60
|
||||
a = np.array([sig(t - 2) for t in range(n)])
|
||||
b = np.array([sig(t) for t in range(n)])
|
||||
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
|
||||
assert int(out[-1, 0]) == -2
|
||||
assert out[-1, 1] > 0.99
|
||||
|
||||
|
||||
def test_cointegration_perfect_pair():
|
||||
# a = 2*b + 5 exactly ⇒ hedge ratio 2, zero spread, degenerate ADF ⇒ 0.
|
||||
b = np.array([100.0 + t for t in range(40)])
|
||||
a = 2.0 * b + 5.0
|
||||
out = ta.Cointegration(20, 1).batch(a, b)
|
||||
assert math.isclose(out[-1, 0], 2.0, rel_tol=1e-9)
|
||||
assert math.isclose(out[-1, 1], 0.0, abs_tol=1e-6)
|
||||
assert math.isclose(out[-1, 2], 0.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_relative_strength_rising_ratio_is_overbought():
|
||||
# a rises while b is flat ⇒ ratio strictly increases ⇒ RSI saturates at 100.
|
||||
n = 20
|
||||
a = np.array([100.0 + 2.0 * t for t in range(n)])
|
||||
b = np.full(n, 100.0)
|
||||
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
|
||||
assert out[-1, 0] > 1.0
|
||||
assert math.isclose(out[-1, 2], 100.0, abs_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)
|
||||
|
||||
|
||||
def test_doji_default_is_directionless_flag():
|
||||
# Default Doji is a direction-less detection flag: +1 on a doji, 0 else.
|
||||
d = ta.Doji()
|
||||
assert d.is_signed() is False
|
||||
# body 0, range 2 -> doji.
|
||||
assert d.update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
|
||||
# body 2 == range -> not a doji.
|
||||
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_doji_signed_dragonfly_gravestone_neutral():
|
||||
# Signed Doji classifies by body position within the range.
|
||||
d = ta.Doji(signed=True)
|
||||
assert d.is_signed() is True
|
||||
# Dragonfly: body at the top, long lower shadow -> bullish +1.
|
||||
assert d.update((10.0, 10.05, 6.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
|
||||
# Gravestone: body at the bottom, long upper shadow -> bearish -1.
|
||||
assert d.update((10.0, 14.0, 9.95, 10.0, 1.0, 1)) == pytest.approx(-1.0)
|
||||
# Long-legged: body centred, symmetric shadows -> neutral 0.
|
||||
assert d.update((10.0, 12.0, 8.0, 10.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
# A large body is not a doji at all -> 0 regardless of position.
|
||||
assert d.update((10.0, 12.0, 10.0, 12.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_orderbook_imbalance_reference_values():
|
||||
# Top-1: (3 - 1) / (3 + 1) = 0.5.
|
||||
assert ta.OrderBookImbalanceTop1().update([100.0], [3.0], [101.0], [1.0]) == pytest.approx(0.5)
|
||||
# Top-2: bidDepth 3, askDepth 2 -> (3 - 2) / 5 = 0.2.
|
||||
topn = ta.OrderBookImbalanceTopN(2)
|
||||
assert topn.update([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0]) == pytest.approx(0.2)
|
||||
# Full: bidDepth 1, askDepth 3 -> (1 - 3) / 4 = -0.5.
|
||||
full = ta.OrderBookImbalanceFull()
|
||||
assert full.update([100.0], [1.0], [101.0, 102.0], [2.0, 1.0]) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_microprice_reference_value():
|
||||
# (100*3 + 101*1) / (1 + 3) = 401 / 4 = 100.25 — heavy ask pulls toward bid.
|
||||
mp = ta.Microprice()
|
||||
assert mp.update([100.0], [1.0], [101.0], [3.0]) == pytest.approx(100.25)
|
||||
|
||||
|
||||
def test_quoted_spread_reference_value():
|
||||
# spread 1.0, mid 100.5 -> 1 / 100.5 * 10_000 ≈ 99.5025 bps.
|
||||
qs = ta.QuotedSpread()
|
||||
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
|
||||
|
||||
|
||||
def test_depth_slope_reference_value():
|
||||
# Symmetric book, each side distances 1, 2 with cumulative sizes 1, 3.
|
||||
# OLS slope of (1->1, 2->3) = 2; mean of two equal sides = 2.
|
||||
ds = ta.DepthSlope()
|
||||
out = ds.update([99.0, 98.0], [1.0, 2.0], [101.0, 102.0], [1.0, 2.0])
|
||||
assert out == pytest.approx(2.0, abs=1e-9)
|
||||
# A book with a single level per side has no slope -> 0.
|
||||
assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_footprint_buckets_buy_and_sell_volume():
|
||||
fp = ta.Footprint(1.0)
|
||||
fp.update(100.2, 2.0, True) # bucket 100 -> ask 2
|
||||
fp.update(100.7, 3.0, False) # bucket 101 -> bid 3
|
||||
out = fp.update(100.1, 1.0, True) # bucket 100 -> ask 3
|
||||
# Columns are [price, bid_vol, ask_vol], rows sorted ascending by price.
|
||||
assert out.shape == (2, 3)
|
||||
assert list(out[0]) == [100.0, 0.0, 3.0]
|
||||
assert list(out[1]) == [101.0, 3.0, 0.0]
|
||||
|
||||
|
||||
def test_signed_volume_reference_values():
|
||||
assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0)
|
||||
assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0)
|
||||
|
||||
|
||||
def test_cumulative_volume_delta_reference_values():
|
||||
cvd = ta.CumulativeVolumeDelta()
|
||||
assert cvd.update(100.0, 5.0, True) == pytest.approx(5.0)
|
||||
assert cvd.update(100.0, 2.0, False) == pytest.approx(3.0)
|
||||
assert cvd.update(100.0, 4.0, False) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_trade_imbalance_reference_value():
|
||||
ti = ta.TradeImbalance(2)
|
||||
assert ti.update(100.0, 3.0, True) is None # warming up
|
||||
# Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
|
||||
assert ti.update(100.0, 1.0, False) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_effective_spread_reference_values():
|
||||
# Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
|
||||
assert ta.EffectiveSpread().update(100.05, 1.0, True, 100.0) == pytest.approx(10.0)
|
||||
# Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
|
||||
assert ta.EffectiveSpread().update(99.95, 1.0, False, 100.0) == pytest.approx(10.0)
|
||||
# A buy filled below the mid is price improvement -> negative.
|
||||
assert ta.EffectiveSpread().update(99.95, 1.0, True, 100.0) < 0.0
|
||||
|
||||
|
||||
def test_realized_spread_reference_value():
|
||||
rs = ta.RealizedSpread(1)
|
||||
assert rs.update(100.10, 1.0, True, 100.0) is None # buffered
|
||||
# Resolved against mid 100.20 one trade later:
|
||||
# 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps (adverse selection).
|
||||
assert rs.update(99.90, 1.0, False, 100.20) == pytest.approx(-20.0)
|
||||
|
||||
|
||||
def test_kyles_lambda_recovers_constant_impact():
|
||||
# Build a tape where each trade moves the mid by exactly 0.5 per unit of
|
||||
# signed volume -> the rolling OLS slope is 0.5.
|
||||
impact = 0.5
|
||||
mid = 100.0
|
||||
price, size, is_buy, mids = [], [], [], []
|
||||
for i in range(20):
|
||||
buy = i % 2 == 0
|
||||
sz = 1.0 + (i % 3)
|
||||
signed = sz if buy else -sz
|
||||
mid += impact * signed
|
||||
price.append(mid)
|
||||
size.append(sz)
|
||||
is_buy.append(buy)
|
||||
mids.append(mid)
|
||||
out = ta.KylesLambda(6).batch(price, size, is_buy, mids)
|
||||
assert out[-1] == pytest.approx(0.5, abs=1e-9)
|
||||
|
||||
|
||||
def test_funding_rate_reference_values():
|
||||
assert ta.FundingRate().update(0.0001) == pytest.approx(0.0001)
|
||||
assert ta.FundingRate().update(-0.0003) == pytest.approx(-0.0003)
|
||||
|
||||
|
||||
def test_funding_rate_mean_reference_value():
|
||||
frm = ta.FundingRateMean(2)
|
||||
assert frm.update(0.001) is None # warming up
|
||||
# Window [0.001, 0.003] -> mean 0.002.
|
||||
assert frm.update(0.003) == pytest.approx(0.002)
|
||||
|
||||
|
||||
def test_funding_rate_zscore_reference_value():
|
||||
z = ta.FundingRateZScore(2)
|
||||
assert z.update(0.001) is None # warming up
|
||||
# Window [0.001, 0.003]: mean 0.002, population stddev 0.001 -> +1.
|
||||
assert z.update(0.003) == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_funding_basis_reference_value():
|
||||
# mark 100.5 vs index 100.0 -> (100.5 - 100.0) / 100.0 = 0.005.
|
||||
assert ta.FundingBasis().update(100.5, 100.0) == pytest.approx(0.005)
|
||||
# A discount reads negative.
|
||||
assert ta.FundingBasis().update(99.5, 100.0) == pytest.approx(-0.005)
|
||||
|
||||
|
||||
def test_open_interest_delta_reference_value():
|
||||
oid = ta.OpenInterestDelta()
|
||||
assert oid.update(1000.0) is None # seeds the previous OI
|
||||
assert oid.update(1250.0) == pytest.approx(250.0)
|
||||
assert oid.update(1100.0) == pytest.approx(-150.0)
|
||||
|
||||
|
||||
def test_oi_price_divergence_reference_value():
|
||||
div = ta.OIPriceDivergence(1)
|
||||
assert div.update(1000.0, 100.0) is None # warming up
|
||||
# OI +10% while price flat -> divergence +0.1.
|
||||
assert div.update(1100.0, 100.0) == pytest.approx(0.1)
|
||||
|
||||
|
||||
def test_oi_weighted_reference_value():
|
||||
oiw = ta.OIWeighted()
|
||||
assert oiw.update(100.0, 10.0) == pytest.approx(100.0)
|
||||
# (100·10 + 110·30) / 40 = 107.5.
|
||||
assert oiw.update(110.0, 30.0) == pytest.approx(107.5)
|
||||
|
||||
|
||||
def test_long_short_ratio_reference_value():
|
||||
# 600 longs vs 400 shorts -> 1.5.
|
||||
assert ta.LongShortRatio().update(600.0, 400.0) == pytest.approx(1.5)
|
||||
# No short side -> 0.0.
|
||||
assert ta.LongShortRatio().update(600.0, 0.0) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_taker_buy_sell_ratio_reference_value():
|
||||
# 60 taker buys vs 40 taker sells -> 1.5.
|
||||
assert ta.TakerBuySellRatio().update(60.0, 40.0) == pytest.approx(1.5)
|
||||
# No taker sell volume -> 0.0.
|
||||
assert ta.TakerBuySellRatio().update(60.0, 0.0) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_liquidation_features_reference_value():
|
||||
# 30 long vs 10 short: (long, short, net, total, imbalance).
|
||||
out = ta.LiquidationFeatures().update(30.0, 10.0)
|
||||
assert out == pytest.approx((30.0, 10.0, 20.0, 40.0, 0.5))
|
||||
|
||||
|
||||
def test_term_structure_basis_reference_value():
|
||||
# futures 102 vs index 100 -> 0.02 (contango).
|
||||
assert ta.TermStructureBasis().update(102.0, 100.0) == pytest.approx(0.02)
|
||||
# Backwardation reads negative.
|
||||
assert ta.TermStructureBasis().update(98.0, 100.0) == pytest.approx(-0.02)
|
||||
|
||||
|
||||
def test_calendar_spread_reference_value():
|
||||
# futures 101 vs perpetual mark 100 -> 0.01.
|
||||
assert ta.CalendarSpread().update(101.0, 100.0) == pytest.approx(0.01)
|
||||
assert ta.CalendarSpread().update(99.0, 100.0) == pytest.approx(-0.01)
|
||||
|
||||
@@ -12,6 +12,7 @@ SCALAR_INDICATORS = [
|
||||
(ta.EMA, (14,)),
|
||||
(ta.WMA, (14,)),
|
||||
(ta.RSI, (14,)),
|
||||
(ta.AnchoredRSI, ()),
|
||||
(ta.MACD, ()),
|
||||
(ta.BollingerBands, ()),
|
||||
]
|
||||
@@ -42,6 +43,7 @@ def test_reset_returns_to_initial_state(cls, args):
|
||||
(ta.EMA, (14,), 14),
|
||||
(ta.WMA, (14,), 14),
|
||||
(ta.RSI, (14,), 15),
|
||||
(ta.AnchoredRSI, (), 2),
|
||||
(ta.BollingerBands, (20, 2.0), 20),
|
||||
],
|
||||
)
|
||||
@@ -86,3 +88,135 @@ 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()
|
||||
|
||||
|
||||
def test_orderbook_lifecycle():
|
||||
snapshot = ([100.0], [1.0], [101.0], [1.0])
|
||||
for ind in [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(3),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
ta.DepthSlope(),
|
||||
]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
ind.update(*snapshot)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_orderbook_topn_repr():
|
||||
assert repr(ta.OrderBookImbalanceTopN(5)) == "OrderBookImbalanceTopN(levels=5)"
|
||||
|
||||
|
||||
def test_tradeflow_lifecycle():
|
||||
for ind in [ta.SignedVolume(), ta.CumulativeVolumeDelta()]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
ind.update(100.0, 1.0, True)
|
||||
assert ind.is_ready()
|
||||
ind.reset()
|
||||
assert not ind.is_ready()
|
||||
|
||||
|
||||
def test_trade_imbalance_lifecycle_and_repr():
|
||||
ti = ta.TradeImbalance(3)
|
||||
assert ti.warmup_period() == 3
|
||||
assert not ti.is_ready()
|
||||
for _ in range(3):
|
||||
ti.update(100.0, 1.0, True)
|
||||
assert ti.is_ready()
|
||||
ti.reset()
|
||||
assert not ti.is_ready()
|
||||
assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)"
|
||||
|
||||
|
||||
def test_effective_spread_lifecycle():
|
||||
es = ta.EffectiveSpread()
|
||||
assert es.warmup_period() == 1
|
||||
assert not es.is_ready()
|
||||
es.update(100.05, 1.0, True, 100.0)
|
||||
assert es.is_ready()
|
||||
es.reset()
|
||||
assert not es.is_ready()
|
||||
|
||||
|
||||
def test_realized_spread_lifecycle_and_repr():
|
||||
rs = ta.RealizedSpread(3)
|
||||
assert rs.warmup_period() == 4
|
||||
assert not rs.is_ready()
|
||||
for _ in range(4):
|
||||
rs.update(100.0, 1.0, True, 100.0)
|
||||
assert rs.is_ready()
|
||||
rs.reset()
|
||||
assert not rs.is_ready()
|
||||
assert repr(ta.RealizedSpread(5)) == "RealizedSpread(horizon=5)"
|
||||
|
||||
|
||||
def test_kyles_lambda_lifecycle_and_repr():
|
||||
kl = ta.KylesLambda(3)
|
||||
assert kl.warmup_period() == 4
|
||||
assert not kl.is_ready()
|
||||
for i in range(4):
|
||||
kl.update(100.0 + i, 1.0 + (i % 2), i % 2 == 0, 100.0 + i)
|
||||
assert kl.is_ready()
|
||||
kl.reset()
|
||||
assert not kl.is_ready()
|
||||
assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)"
|
||||
|
||||
|
||||
def test_footprint_lifecycle_and_repr():
|
||||
fp = ta.Footprint(0.5)
|
||||
assert fp.warmup_period() == 1
|
||||
assert not fp.is_ready()
|
||||
fp.update(100.0, 1.0, True)
|
||||
assert fp.is_ready()
|
||||
fp.reset()
|
||||
assert not fp.is_ready()
|
||||
assert repr(ta.Footprint(0.25)) == "Footprint(tick_size=0.25)"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,3 +55,115 @@ 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)
|
||||
|
||||
|
||||
def test_orderbook_indicators_construct_and_emit():
|
||||
# All five order-book indicators accept a four-array snapshot and emit a float.
|
||||
snapshot = ([100.0, 99.0], [2.0, 1.0], [101.0, 102.0], [1.0, 1.0])
|
||||
indicators = [
|
||||
ta.OrderBookImbalanceTop1(),
|
||||
ta.OrderBookImbalanceTopN(2),
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
ta.DepthSlope(),
|
||||
]
|
||||
for ind in indicators:
|
||||
out = ind.update(*snapshot)
|
||||
assert isinstance(out, float)
|
||||
|
||||
|
||||
def test_orderbook_batch_returns_one_value_per_snapshot():
|
||||
snapshots = [([100.0], [3.0], [101.0], [1.0])] * 5
|
||||
out = ta.OrderBookImbalanceTop1().batch(snapshots)
|
||||
assert out.shape == (5,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_tradeflow_indicators_construct_and_emit():
|
||||
# SignedVolume and CVD emit from the first trade; TradeImbalance(1) too.
|
||||
assert isinstance(ta.SignedVolume().update(100.0, 2.0, True), float)
|
||||
assert isinstance(ta.CumulativeVolumeDelta().update(100.0, 2.0, True), float)
|
||||
assert isinstance(ta.TradeImbalance(1).update(100.0, 2.0, True), float)
|
||||
|
||||
|
||||
def test_tradeflow_batch_returns_one_value_per_trade():
|
||||
price = np.full(6, 100.0)
|
||||
size = np.array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0])
|
||||
is_buy = [True, False, True, False, True, False]
|
||||
out = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
|
||||
assert out.shape == (6,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_price_impact_indicators_construct_and_emit():
|
||||
# Price-impact indicators take a trade paired with the prevailing mid.
|
||||
assert isinstance(ta.EffectiveSpread().update(100.05, 1.0, True, 100.0), float)
|
||||
# RealizedSpread buffers until its horizon elapses.
|
||||
assert ta.RealizedSpread(1).update(100.05, 1.0, True, 100.0) is None
|
||||
|
||||
|
||||
def test_price_impact_batch_returns_one_value_per_trade():
|
||||
price = np.array([100.05, 99.95, 100.10, 99.90])
|
||||
size = np.array([1.0, 2.0, 1.0, 2.0])
|
||||
is_buy = [True, False, True, False]
|
||||
mid = np.full(4, 100.0)
|
||||
for ind in (ta.EffectiveSpread(), ta.RealizedSpread(2), ta.KylesLambda(2)):
|
||||
out = ind.batch(price, size, is_buy, mid)
|
||||
assert out.shape == (4,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_footprint_constructs_and_emits():
|
||||
out = ta.Footprint(1.0).update(100.2, 2.0, True)
|
||||
assert out.shape == (1, 3)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_footprint_batch_returns_list_of_arrays():
|
||||
res = ta.Footprint(1.0).batch([100.2, 100.7], [2.0, 3.0], [True, False])
|
||||
assert isinstance(res, list)
|
||||
assert len(res) == 2
|
||||
assert res[-1].shape[1] == 3
|
||||
|
||||
@@ -115,3 +115,136 @@ 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)
|
||||
|
||||
|
||||
def test_orderbook_streaming_matches_batch():
|
||||
snaps = [
|
||||
(
|
||||
[100.0, 99.0],
|
||||
[1.0 + (i % 5), 1.0],
|
||||
[101.0, 102.0],
|
||||
[1.0 + ((i + 1) % 3), 1.0],
|
||||
)
|
||||
for i in range(30)
|
||||
]
|
||||
batch = ta.Microprice().batch(snaps)
|
||||
streamer = ta.Microprice()
|
||||
streamed = np.array([streamer.update(*snap) for snap in snaps], dtype=np.float64)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_tradeflow_streaming_matches_batch():
|
||||
n = 30
|
||||
price = np.full(n, 100.0)
|
||||
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 3 != 0 for i in range(n)]
|
||||
batch = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
|
||||
streamer = ta.CumulativeVolumeDelta()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_price_impact_streaming_matches_batch():
|
||||
n = 30
|
||||
mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 3 != 0 for i in range(n)]
|
||||
price = np.array(
|
||||
[mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
|
||||
batch = ta.EffectiveSpread().batch(price, size, is_buy, mid)
|
||||
streamer = ta.EffectiveSpread()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
|
||||
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
|
||||
|
||||
+54
-31
@@ -1,47 +1,70 @@
|
||||
# wickra-wasm
|
||||
# Wickra — WebAssembly
|
||||
|
||||
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://www.npmjs.com/package/wickra-wasm)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
## Build
|
||||
**Streaming-first technical indicators in the browser. `npm install
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
|
||||
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 an O(1)
|
||||
streaming state machine, so live trading dashboards and historical backtests
|
||||
share the exact same implementation. This package is the WebAssembly binding
|
||||
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||
indicators across sixteen families.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-pack
|
||||
npm install wickra-wasm
|
||||
```
|
||||
|
||||
Then from the repository root:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
```
|
||||
|
||||
The compiled package lands in `bindings/wasm/pkg/`. Targets:
|
||||
|
||||
- `--target web` for native ES modules in browsers
|
||||
- `--target bundler` for webpack/Vite/Rollup
|
||||
- `--target nodejs` for Node.js
|
||||
|
||||
## Example
|
||||
The module ships a default `init` export that loads the `.wasm` payload; await
|
||||
it once before constructing indicators.
|
||||
|
||||
```js
|
||||
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
|
||||
import init, { RSI } from 'wickra-wasm';
|
||||
|
||||
await init();
|
||||
console.log("wickra:", version());
|
||||
await init(); // load the WebAssembly module once
|
||||
|
||||
// Streaming
|
||||
// Streaming: feed prices tick by tick in O(1).
|
||||
const rsi = new RSI(14);
|
||||
for (const price of livePrices) {
|
||||
const v = rsi.update(price);
|
||||
if (v !== undefined && v > 70) console.log("overbought");
|
||||
for (const price of liveFeed) {
|
||||
const value = rsi.update(price); // null during warmup
|
||||
if (value !== null && value > 70) {
|
||||
console.log('overbought');
|
||||
}
|
||||
}
|
||||
|
||||
// Batch (returns a Float64Array; NaN for warmup positions)
|
||||
const sma = new SMA(20).batch(new Float64Array(historicalPrices));
|
||||
```
|
||||
|
||||
An interactive demo lives in `bindings/wasm/examples/index.html`. After building
|
||||
the package serve the `bindings/wasm/` directory and open `examples/index.html`.
|
||||
Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`,
|
||||
`new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or
|
||||
`null` while the indicator is still warming up.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
+9609
-2
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", "McClellan", ".."]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Cross-section value type: a market-breadth snapshot across a whole universe.
|
||||
//!
|
||||
//! A [`CrossSection`] is a single tick that carries the per-symbol state of
|
||||
//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
|
||||
//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
|
||||
//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
|
||||
//! whole cross-section into a single breadth reading. This is the same
|
||||
//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
|
||||
//!
|
||||
//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
|
||||
//! need — a signed price `change` (whose sign classifies the symbol as
|
||||
//! advancing, declining or unchanged), the period `volume`, the
|
||||
//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
|
||||
//! state flags — so the indicators stay stateless per tick and never have to
|
||||
//! track per-symbol history.
|
||||
//!
|
||||
//! [`DerivativesTick`]: crate::DerivativesTick
|
||||
//! [`OrderBook`]: crate::OrderBook
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// One symbol's contribution to a [`CrossSection`] tick.
|
||||
///
|
||||
/// Field invariants enforced by [`CrossSection::new`] when the member is placed
|
||||
/// into a tick:
|
||||
///
|
||||
/// - `change` is finite (its sign classifies the symbol — positive is
|
||||
/// advancing, negative is declining, zero is unchanged).
|
||||
/// - `volume` is finite and non-negative.
|
||||
///
|
||||
/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
|
||||
/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied
|
||||
/// per-symbol state signals (whether the symbol trades above its reference moving
|
||||
/// average, and whether it is on a point-and-figure buy signal). None of the four
|
||||
/// flags carries a numeric invariant.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "the four flags are independent per-symbol breadth signals, not a state machine"
|
||||
)]
|
||||
pub struct Member {
|
||||
/// Price change versus the previous close. Sign classifies the symbol:
|
||||
/// positive is advancing, negative is declining, zero is unchanged.
|
||||
pub change: f64,
|
||||
/// Period volume for the symbol (finite, non-negative).
|
||||
pub volume: f64,
|
||||
/// Whether the symbol printed a new period high.
|
||||
pub new_high: bool,
|
||||
/// Whether the symbol printed a new period low.
|
||||
pub new_low: bool,
|
||||
/// Whether the symbol is trading above its reference moving average
|
||||
/// (consumed by the `% Above Moving Average` breadth indicator).
|
||||
pub above_ma: bool,
|
||||
/// Whether the symbol is on a point-and-figure buy signal
|
||||
/// (consumed by the `Bullish Percent Index` breadth indicator).
|
||||
pub on_buy_signal: bool,
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// Assemble a cross-section member from its core signals, leaving the
|
||||
/// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
|
||||
///
|
||||
/// The field invariants documented on [`Member`] are validated centrally by
|
||||
/// [`CrossSection::new`] when the member is placed into a tick; this
|
||||
/// constructor only assembles the value so the `#[non_exhaustive]` struct can
|
||||
/// be built from outside the crate.
|
||||
#[must_use]
|
||||
pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
|
||||
Self {
|
||||
change,
|
||||
volume,
|
||||
new_high,
|
||||
new_low,
|
||||
above_ma: false,
|
||||
on_buy_signal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a cross-section member including the extended per-symbol state
|
||||
/// signals `above_ma` and `on_buy_signal`.
|
||||
///
|
||||
/// Use this constructor for the breadth indicators that read per-symbol
|
||||
/// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
|
||||
/// is the shorthand that leaves both flags `false`.
|
||||
#[must_use]
|
||||
#[allow(
|
||||
clippy::fn_params_excessive_bools,
|
||||
reason = "mirrors the four independent per-symbol flag fields of Member"
|
||||
)]
|
||||
pub const fn with_signals(
|
||||
change: f64,
|
||||
volume: f64,
|
||||
new_high: bool,
|
||||
new_low: bool,
|
||||
above_ma: bool,
|
||||
on_buy_signal: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
change,
|
||||
volume,
|
||||
new_high,
|
||||
new_low,
|
||||
above_ma,
|
||||
on_buy_signal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A market-breadth cross-section: the per-symbol state of an entire universe at
|
||||
/// a single point in time.
|
||||
///
|
||||
/// Invariants enforced by [`new`](CrossSection::new):
|
||||
///
|
||||
/// - `members` is non-empty (a breadth reading needs at least one symbol).
|
||||
/// - every member's `change` is finite, and `volume` is finite and non-negative.
|
||||
///
|
||||
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CrossSection {
|
||||
/// Per-symbol members of the universe for this tick.
|
||||
pub members: Vec<Member>,
|
||||
/// Tick timestamp (caller-defined epoch / resolution).
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl CrossSection {
|
||||
/// Construct a cross-section, validating every member invariant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
|
||||
/// member has a non-finite `change`, or if any member has a `volume` that is
|
||||
/// not a finite non-negative number.
|
||||
pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
|
||||
if members.is_empty() {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "cross-section must contain at least one member",
|
||||
});
|
||||
}
|
||||
for member in &members {
|
||||
if !member.change.is_finite() {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "member change must be finite",
|
||||
});
|
||||
}
|
||||
if !member.volume.is_finite() || member.volume < 0.0 {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "member volume must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self { members, timestamp })
|
||||
}
|
||||
|
||||
/// Construct a cross-section without validation. The caller asserts that
|
||||
/// every invariant documented on [`CrossSection`] holds.
|
||||
#[must_use]
|
||||
pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
|
||||
Self { members, timestamp }
|
||||
}
|
||||
|
||||
/// Number of advancing symbols (those with a strictly positive `change`).
|
||||
#[must_use]
|
||||
pub fn advancers(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.change > 0.0).count()
|
||||
}
|
||||
|
||||
/// Number of declining symbols (those with a strictly negative `change`).
|
||||
#[must_use]
|
||||
pub fn decliners(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.change < 0.0).count()
|
||||
}
|
||||
|
||||
/// Total volume traded by advancing symbols (those with positive `change`).
|
||||
#[must_use]
|
||||
pub fn advancing_volume(&self) -> f64 {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|m| m.change > 0.0)
|
||||
.map(|m| m.volume)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Total volume traded by declining symbols (those with negative `change`).
|
||||
#[must_use]
|
||||
pub fn declining_volume(&self) -> f64 {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|m| m.change < 0.0)
|
||||
.map(|m| m.volume)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Total volume traded across the whole universe.
|
||||
#[must_use]
|
||||
pub fn total_volume(&self) -> f64 {
|
||||
self.members.iter().map(|m| m.volume).sum()
|
||||
}
|
||||
|
||||
/// Number of symbols that printed a new period high.
|
||||
#[must_use]
|
||||
pub fn new_highs(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.new_high).count()
|
||||
}
|
||||
|
||||
/// Number of symbols that printed a new period low.
|
||||
#[must_use]
|
||||
pub fn new_lows(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.new_low).count()
|
||||
}
|
||||
|
||||
/// Number of symbols trading above their reference moving average.
|
||||
#[must_use]
|
||||
pub fn above_ma_count(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.above_ma).count()
|
||||
}
|
||||
|
||||
/// Number of symbols on a point-and-figure buy signal.
|
||||
#[must_use]
|
||||
pub fn on_buy_signal_count(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.on_buy_signal).count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn members() -> Vec<Member> {
|
||||
vec![
|
||||
Member::new(1.5, 100.0, true, false),
|
||||
Member::new(-0.5, 50.0, false, true),
|
||||
Member::new(0.0, 0.0, false, false),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid() {
|
||||
let cs = CrossSection::new(members(), 42).unwrap();
|
||||
assert_eq!(cs.members.len(), 3);
|
||||
assert_eq!(cs.timestamp, 42);
|
||||
assert_eq!(cs.members[0].change, 1.5);
|
||||
assert_eq!(cs.members[0].volume, 100.0);
|
||||
assert!(cs.members[0].new_high);
|
||||
assert!(cs.members[1].new_low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_new_assembles_fields() {
|
||||
let m = Member::new(2.0, 10.0, true, false);
|
||||
assert_eq!(m.change, 2.0);
|
||||
assert_eq!(m.volume, 10.0);
|
||||
assert!(m.new_high);
|
||||
assert!(!m.new_low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(Vec::new(), 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_change() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_negative_volume() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_volume() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_unchecked_skips_validation() {
|
||||
let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
|
||||
assert_eq!(cs.members.len(), 1);
|
||||
assert_eq!(cs.timestamp, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advancers_and_decliners_count_by_sign() {
|
||||
let cs = CrossSection::new(members(), 0).unwrap();
|
||||
assert_eq!(cs.advancers(), 1);
|
||||
assert_eq!(cs.decliners(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_members_count_as_neither() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(0.0, 1.0, false, false),
|
||||
Member::new(0.0, 1.0, false, false),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.advancers(), 0);
|
||||
assert_eq!(cs.decliners(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_leaves_extended_flags_cleared() {
|
||||
let m = Member::new(1.0, 10.0, true, false);
|
||||
assert!(!m.above_ma);
|
||||
assert!(!m.on_buy_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_signals_assembles_all_fields() {
|
||||
let m = Member::with_signals(2.0, 10.0, true, false, true, true);
|
||||
assert_eq!(m.change, 2.0);
|
||||
assert_eq!(m.volume, 10.0);
|
||||
assert!(m.new_high);
|
||||
assert!(!m.new_low);
|
||||
assert!(m.above_ma);
|
||||
assert!(m.on_buy_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_helpers_bucket_by_change_sign() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(1.5, 100.0, false, false), // advancing
|
||||
Member::new(2.0, 40.0, false, false), // advancing
|
||||
Member::new(-0.5, 50.0, false, false), // declining
|
||||
Member::new(0.0, 7.0, false, false), // unchanged
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.advancing_volume(), 140.0);
|
||||
assert_eq!(cs.declining_volume(), 50.0);
|
||||
assert_eq!(cs.total_volume(), 197.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_low_helpers_count_flags() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(1.0, 1.0, true, false),
|
||||
Member::new(1.0, 1.0, true, false),
|
||||
Member::new(-1.0, 1.0, false, true),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.new_highs(), 2);
|
||||
assert_eq!(cs.new_lows(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_helpers_count_extended_flags() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::with_signals(1.0, 1.0, false, false, true, true),
|
||||
Member::with_signals(1.0, 1.0, false, false, true, false),
|
||||
Member::with_signals(-1.0, 1.0, false, false, false, true),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.above_ma_count(), 2);
|
||||
assert_eq!(cs.on_buy_signal_count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Derivatives value type: the perpetual / futures tick.
|
||||
//!
|
||||
//! [`DerivativesTick`] is the non-OHLCV input consumed by the derivatives /
|
||||
//! perpetual-futures indicator family. A single tick bundles the funding,
|
||||
//! price, open-interest, positioning, taker-flow and liquidation fields a
|
||||
//! perp/futures venue publishes per update; each indicator reads only the
|
||||
//! subset it needs (the same one-rich-type-per-family pattern as [`Trade`] /
|
||||
//! [`OrderBook`] in [`crate::microstructure`]).
|
||||
//!
|
||||
//! [`Trade`]: crate::microstructure::Trade
|
||||
//! [`OrderBook`]: crate::microstructure::OrderBook
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A single derivatives / perpetual-futures market tick.
|
||||
///
|
||||
/// Field invariants enforced by [`new`](DerivativesTick::new):
|
||||
///
|
||||
/// - `funding_rate` is finite and **may be negative** (a negative funding rate
|
||||
/// means shorts pay longs).
|
||||
/// - `mark_price`, `index_price` and `futures_price` are finite and strictly
|
||||
/// positive.
|
||||
/// - `open_interest`, `long_size`, `short_size`, `taker_buy_volume`,
|
||||
/// `taker_sell_volume`, `long_liquidation` and `short_liquidation` are finite
|
||||
/// and non-negative.
|
||||
///
|
||||
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DerivativesTick {
|
||||
/// Current funding rate for the interval (finite; may be negative).
|
||||
pub funding_rate: f64,
|
||||
/// Perpetual mark price (finite, strictly positive).
|
||||
pub mark_price: f64,
|
||||
/// Spot / index price the perpetual tracks (finite, strictly positive).
|
||||
pub index_price: f64,
|
||||
/// Dated (e.g. quarterly) futures mark price (finite, strictly positive).
|
||||
pub futures_price: f64,
|
||||
/// Open interest — outstanding contracts / notional (finite, non-negative).
|
||||
pub open_interest: f64,
|
||||
/// Aggregate long size / long account count (finite, non-negative).
|
||||
pub long_size: f64,
|
||||
/// Aggregate short size / short account count (finite, non-negative).
|
||||
pub short_size: f64,
|
||||
/// Taker buy (ask-lifting) volume (finite, non-negative).
|
||||
pub taker_buy_volume: f64,
|
||||
/// Taker sell (bid-hitting) volume (finite, non-negative).
|
||||
pub taker_sell_volume: f64,
|
||||
/// Long-side liquidation notional (finite, non-negative).
|
||||
pub long_liquidation: f64,
|
||||
/// Short-side liquidation notional (finite, non-negative).
|
||||
pub short_liquidation: f64,
|
||||
/// Tick timestamp (caller-defined epoch / resolution).
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl DerivativesTick {
|
||||
/// Construct a derivatives tick, validating every field invariant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite;
|
||||
/// any of `mark_price`, `index_price`, `futures_price` is not a finite
|
||||
/// positive number; or any of the six size / volume / liquidation fields is
|
||||
/// not a finite non-negative number.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
funding_rate: f64,
|
||||
mark_price: f64,
|
||||
index_price: f64,
|
||||
futures_price: f64,
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
long_liquidation: f64,
|
||||
short_liquidation: f64,
|
||||
timestamp: i64,
|
||||
) -> Result<Self> {
|
||||
if !funding_rate.is_finite() {
|
||||
return Err(Error::InvalidDerivatives {
|
||||
message: "funding_rate must be finite",
|
||||
});
|
||||
}
|
||||
for price in [mark_price, index_price, futures_price] {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(Error::InvalidDerivatives {
|
||||
message:
|
||||
"mark_price, index_price and futures_price must be finite and positive",
|
||||
});
|
||||
}
|
||||
}
|
||||
for amount in [
|
||||
open_interest,
|
||||
long_size,
|
||||
short_size,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
long_liquidation,
|
||||
short_liquidation,
|
||||
] {
|
||||
if !amount.is_finite() || amount < 0.0 {
|
||||
return Err(Error::InvalidDerivatives {
|
||||
message: "open interest, sizes, volumes and liquidations must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
funding_rate,
|
||||
mark_price,
|
||||
index_price,
|
||||
futures_price,
|
||||
open_interest,
|
||||
long_size,
|
||||
short_size,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
long_liquidation,
|
||||
short_liquidation,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a derivatives tick without validation. The caller asserts that
|
||||
/// every field invariant documented on [`DerivativesTick`] holds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub const fn new_unchecked(
|
||||
funding_rate: f64,
|
||||
mark_price: f64,
|
||||
index_price: f64,
|
||||
futures_price: f64,
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
long_liquidation: f64,
|
||||
short_liquidation: f64,
|
||||
timestamp: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
funding_rate,
|
||||
mark_price,
|
||||
index_price,
|
||||
futures_price,
|
||||
open_interest,
|
||||
long_size,
|
||||
short_size,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
long_liquidation,
|
||||
short_liquidation,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fully valid tick used as a baseline; individual tests override one
|
||||
/// field to exercise a single reject branch.
|
||||
fn valid() -> DerivativesTick {
|
||||
DerivativesTick::new(
|
||||
0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid() {
|
||||
let tick = valid();
|
||||
assert_eq!(tick.funding_rate, 0.0001);
|
||||
assert_eq!(tick.mark_price, 100.0);
|
||||
assert_eq!(tick.index_price, 99.5);
|
||||
assert_eq!(tick.futures_price, 100.5);
|
||||
assert_eq!(tick.open_interest, 1_000.0);
|
||||
assert_eq!(tick.long_size, 600.0);
|
||||
assert_eq!(tick.short_size, 400.0);
|
||||
assert_eq!(tick.taker_buy_volume, 50.0);
|
||||
assert_eq!(tick.taker_sell_volume, 40.0);
|
||||
assert_eq!(tick.long_liquidation, 5.0);
|
||||
assert_eq!(tick.short_liquidation, 3.0);
|
||||
assert_eq!(tick.timestamp, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_negative_funding_and_zero_amounts() {
|
||||
let tick = DerivativesTick::new(
|
||||
-0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tick.funding_rate, -0.0005);
|
||||
assert_eq!(tick.open_interest, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_funding() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(
|
||||
f64::NAN,
|
||||
100.0,
|
||||
100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0
|
||||
),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(
|
||||
f64::INFINITY,
|
||||
100.0,
|
||||
100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0
|
||||
),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_positive_mark() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_positive_index() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(0.0, 100.0, -1.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_futures() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(
|
||||
0.0,
|
||||
100.0,
|
||||
100.0,
|
||||
f64::NAN,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0
|
||||
),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_negative_open_interest() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(0.0, 100.0, 100.0, 100.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_size() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(
|
||||
0.0,
|
||||
100.0,
|
||||
100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
f64::INFINITY,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0
|
||||
),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_negative_liquidation() {
|
||||
assert!(matches!(
|
||||
DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, 0),
|
||||
Err(Error::InvalidDerivatives { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_unchecked_preserves_fields() {
|
||||
let tick = DerivativesTick::new_unchecked(
|
||||
-1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7,
|
||||
);
|
||||
assert_eq!(tick.funding_rate, -1.0);
|
||||
assert_eq!(tick.mark_price, -2.0);
|
||||
assert_eq!(tick.short_liquidation, -11.0);
|
||||
assert_eq!(tick.timestamp, 7);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,52 @@ 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,
|
||||
|
||||
/// An order-book snapshot whose levels do not satisfy the book invariants
|
||||
/// (e.g. a crossed book, non-finite price, negative size, or mis-sorted
|
||||
/// levels) was provided. Order books are a microstructure input distinct
|
||||
/// from candles and ticks, so they surface as their own variant.
|
||||
#[error("invalid order book: {message}")]
|
||||
InvalidOrderBook { message: &'static str },
|
||||
|
||||
/// A trade whose components do not satisfy the trade invariants (e.g.
|
||||
/// non-finite price or negative size) was provided.
|
||||
#[error("invalid trade: {message}")]
|
||||
InvalidTrade { message: &'static str },
|
||||
|
||||
/// A derivatives tick whose components do not satisfy the tick invariants
|
||||
/// (e.g. a non-positive price, a non-finite funding rate, or a negative
|
||||
/// size/volume/liquidation) was provided. Derivatives ticks (funding /
|
||||
/// open-interest / liquidation feeds) are a perpetual-futures input
|
||||
/// distinct from candles, order books and trades, so they surface as their
|
||||
/// own variant.
|
||||
#[error("invalid derivatives tick: {message}")]
|
||||
InvalidDerivatives { message: &'static str },
|
||||
|
||||
/// A market-breadth cross-section whose members do not satisfy the
|
||||
/// cross-section invariants (an empty universe, a non-finite change, or a
|
||||
/// negative / non-finite volume) was provided. A cross-section is a
|
||||
/// breadth input distinct from candles, ticks, order books and trades, so
|
||||
/// it surfaces as its own variant.
|
||||
#[error("invalid cross-section: {message}")]
|
||||
InvalidCrossSection { message: &'static str },
|
||||
|
||||
/// A real-valued configuration parameter was outside its admissible range
|
||||
/// (e.g. a non-positive standard-deviation multiplier, or a Kalman filter
|
||||
/// covariance that is not strictly positive). This is the floating-point
|
||||
/// analogue of [`Error::InvalidPeriod`], which only covers integer windows.
|
||||
#[error("invalid parameter: {message}")]
|
||||
InvalidParameter { message: &'static str },
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, wickra_core::Error>`.
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Abandoned Baby candlestick pattern.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Abandoned Baby — a strong 3-bar reversal where a doji is "abandoned" by price
|
||||
/// gaps on both sides, isolating it from the candles before and after.
|
||||
///
|
||||
/// ```text
|
||||
/// tol = tolerance * max(|bar2.open|, |bar2.close|)
|
||||
/// bar2 doji (|bar2.close − bar2.open| <= tol)
|
||||
///
|
||||
/// bullish (+1.0): bar1 red, bar2 gaps fully below bar1 (bar2.high < bar1.low),
|
||||
/// bar3 green and gaps fully above bar2 (bar3.low > bar2.high)
|
||||
/// bearish (−1.0): bar1 green, bar2 gaps fully above bar1 (bar2.low > bar1.high),
|
||||
/// bar3 red and gaps fully below bar2 (bar3.high < bar2.low)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `0.0` otherwise. The first two bars always return `0.0` because the
|
||||
/// three-bar window is not yet filled. `tolerance` defaults to `0.001` (10 bps
|
||||
/// relative) and bounds how flat the middle candle must be to count as a doji; it
|
||||
/// must lie in `[0, 1)`. Pattern-shape check only — no trend filter is applied;
|
||||
/// combine with a trend indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector emits the uniform candlestick sign convention shared across the
|
||||
/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it
|
||||
/// drops straight into a machine-learning feature matrix where the bullish and
|
||||
/// bearish variants occupy a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AbandonedBaby, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AbandonedBaby::new();
|
||||
/// indicator.update(Candle::new(20.0, 20.1, 14.9, 15.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(16.0, 18.1, 15.9, 18.0, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AbandonedBaby {
|
||||
tolerance: f64,
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Default for AbandonedBaby {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AbandonedBaby {
|
||||
/// Construct a detector with the default relative doji tolerance (1e-3).
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
tolerance: 0.001,
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a detector with a custom relative doji tolerance.
|
||||
///
|
||||
/// `tolerance` must lie in `[0, 1)`.
|
||||
pub fn with_tolerance(tolerance: f64) -> Result<Self> {
|
||||
if !(0.0..1.0).contains(&tolerance) {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "abandoned baby tolerance must lie in [0, 1)",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
tolerance,
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured relative doji tolerance.
|
||||
pub fn tolerance(&self) -> f64 {
|
||||
self.tolerance
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AbandonedBaby {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(bar1), Some(bar2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let tol = self.tolerance * bar2.open.abs().max(bar2.close.abs());
|
||||
let bar2_is_doji = (bar2.close - bar2.open).abs() <= tol;
|
||||
if !bar2_is_doji {
|
||||
return Some(0.0);
|
||||
}
|
||||
// Bullish: red bar1, doji gaps below, green bar3 gaps above.
|
||||
if bar1.close < bar1.open
|
||||
&& bar2.high < bar1.low
|
||||
&& candle.close > candle.open
|
||||
&& candle.low > bar2.high
|
||||
{
|
||||
return Some(1.0);
|
||||
}
|
||||
// Bearish: green bar1, doji gaps above, red bar3 gaps below.
|
||||
if bar1.close > bar1.open
|
||||
&& bar2.low > bar1.high
|
||||
&& candle.close < candle.open
|
||||
&& candle.high < bar2.low
|
||||
{
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.prev_prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AbandonedBaby"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tolerance() {
|
||||
assert!(AbandonedBaby::with_tolerance(-0.01).is_err());
|
||||
assert!(AbandonedBaby::with_tolerance(1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_tolerance() {
|
||||
let t = AbandonedBaby::with_tolerance(0.0).unwrap();
|
||||
assert!((t.tolerance() - 0.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = AbandonedBaby::default();
|
||||
assert_eq!(t.name(), "AbandonedBaby");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
assert!((t.tolerance() - 0.001).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_abandoned_baby_is_plus_one() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_abandoned_baby_is_minus_one() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
assert_eq!(t.update(c(15.0, 20.1, 14.9, 20.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(19.0, 19.1, 16.9, 17.0, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_not_doji_yields_zero() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
|
||||
// Middle bar has a wide body -> not a doji.
|
||||
assert_eq!(t.update(c(13.0, 14.0, 11.0, 11.5, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_gap_yields_zero() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
|
||||
// Doji overlaps bar1's range -> no gap.
|
||||
assert_eq!(t.update(c(15.0, 15.1, 14.9, 15.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(16.0, 18.1, 15.9, 18.0, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.3).sin() * 5.0;
|
||||
c(base, base + 1.0, base - 1.0, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AbandonedBaby::new();
|
||||
let mut b = AbandonedBaby::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = AbandonedBaby::new();
|
||||
t.update(c(20.0, 20.1, 14.9, 15.0, 0));
|
||||
t.update(c(13.0, 13.1, 12.9, 13.0, 1));
|
||||
t.update(c(16.0, 18.1, 15.9, 18.0, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(20.0, 20.1, 14.9, 15.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
|
||||
/// `|advancers - decliners|`.
|
||||
///
|
||||
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
|
||||
/// a high reading means the universe moved decisively one way or the other (high
|
||||
/// internal activity / volatility), while a low reading means advances and
|
||||
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
|
||||
/// called a "market thermometer" because elevated readings often cluster around
|
||||
/// turning points.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut abi = AbsoluteBreadthIndex::new();
|
||||
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(abi.update(tick), Some(3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AbsoluteBreadthIndex {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AbsoluteBreadthIndex {
|
||||
/// Construct a new Absolute Breadth Index indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AbsoluteBreadthIndex {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancers() as f64 - section.decliners() as f64;
|
||||
self.has_emitted = true;
|
||||
Some(net.abs())
|
||||
}
|
||||
|
||||
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 {
|
||||
"AbsoluteBreadthIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..up {
|
||||
members.push(Member::new(1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..down {
|
||||
members.push(Member::new(-1.0, 10.0, false, false));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
|
||||
assert_eq!(abi.warmup_period(), 1);
|
||||
assert!(!abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnitude_ignores_direction() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.update(section(2, 5)), Some(3.0));
|
||||
// Same magnitude with the direction reversed.
|
||||
let mut abi2 = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_universe_yields_zero() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.update(section(3, 3)), Some(0.0));
|
||||
assert!(abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
abi.update(section(2, 5));
|
||||
assert!(abi.is_ready());
|
||||
abi.reset();
|
||||
assert!(!abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
|
||||
let mut a = AbsoluteBreadthIndex::new();
|
||||
let mut b = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,157 @@
|
||||
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
|
||||
/// net advancing volume across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
|
||||
/// where advancing volume is the total volume of symbols with a positive change
|
||||
/// and declining volume the total volume of symbols with a negative change. The
|
||||
/// line accumulates this net over time, so a rising line means volume is flowing
|
||||
/// into advancing issues (healthy participation) while a falling line warns that
|
||||
/// declining issues are carrying the volume — the volume-weighted analogue of the
|
||||
/// plain Advance/Decline Line.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
|
||||
/// first tick).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut adv = AdVolumeLine::new();
|
||||
/// // advancing volume 150, declining volume 50 -> net +100.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 150.0, false, false),
|
||||
/// Member::new(-1.0, 50.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(adv.update(tick), Some(100.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdVolumeLine {
|
||||
line: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdVolumeLine {
|
||||
/// Construct a new Advance/Decline Volume Line indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
line: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdVolumeLine {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancing_volume() - section.declining_volume();
|
||||
self.line += net;
|
||||
self.has_emitted = true;
|
||||
Some(self.line)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.line = 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 {
|
||||
"AdVolumeLine"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tick(items: &[(f64, f64)]) -> CrossSection {
|
||||
CrossSection::new(
|
||||
items
|
||||
.iter()
|
||||
.map(|&(change, volume)| Member::new(change, volume, false, false))
|
||||
.collect(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.name(), "AdVolumeLine");
|
||||
assert_eq!(adv.warmup_period(), 1);
|
||||
assert!(!adv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_net_volume() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
|
||||
assert!(adv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_accumulates_across_ticks() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
|
||||
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
|
||||
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_volume_is_ignored() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
// Unchanged symbols (zero change) contribute to neither bucket.
|
||||
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
adv.update(tick(&[(1.0, 100.0)]));
|
||||
assert!(adv.is_ready());
|
||||
adv.reset();
|
||||
assert!(!adv.is_ready());
|
||||
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![
|
||||
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
|
||||
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
|
||||
tick(&[(1.0, 30.0)]),
|
||||
];
|
||||
let mut a = AdVolumeLine::new();
|
||||
let mut b = AdVolumeLine::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Advance Block candlestick pattern.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance Block — a 3-bar bearish warning: three green candles still pushing to
|
||||
/// higher closes, but visibly running out of steam — each real body shrinks while
|
||||
/// the upper shadows lengthen, hinting the advance is about to stall.
|
||||
///
|
||||
/// ```text
|
||||
/// all three green & higher closes
|
||||
/// each opens inside the prior body
|
||||
/// shrinking bodies (body3 < body2 < body1)
|
||||
/// upper shadow of bar3 >= upper shadow of bar2 and bar3 has an upper shadow
|
||||
/// ```
|
||||
///
|
||||
/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Advance Block
|
||||
/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The
|
||||
/// first two bars always return `0.0` because the three-bar window is not yet
|
||||
/// filled. Pattern-shape check only — no trend filter is applied; combine with a
|
||||
/// trend indicator for actionable signals.
|
||||
///
|
||||
/// # Signed ±1 encoding
|
||||
///
|
||||
/// This detector emits the uniform candlestick sign convention shared across the
|
||||
/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
|
||||
/// a machine-learning feature matrix as a single dimension.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdvanceBlock, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AdvanceBlock::new();
|
||||
/// indicator.update(Candle::new(10.0, 13.1, 9.9, 13.0, 1.0, 0).unwrap());
|
||||
/// indicator.update(Candle::new(12.0, 14.3, 11.9, 14.0, 1.0, 1).unwrap());
|
||||
/// let out = indicator
|
||||
/// .update(Candle::new(13.5, 15.0, 13.4, 14.5, 1.0, 2).unwrap());
|
||||
/// assert_eq!(out, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdvanceBlock {
|
||||
prev: Option<Candle>,
|
||||
prev_prev: Option<Candle>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdvanceBlock {
|
||||
/// Construct a new Advance Block detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev: None,
|
||||
prev_prev: None,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdvanceBlock {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let pp = self.prev_prev;
|
||||
let p = self.prev;
|
||||
self.prev_prev = self.prev;
|
||||
self.prev = Some(candle);
|
||||
let (Some(bar1), Some(bar2)) = (pp, p) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let body1 = bar1.close - bar1.open;
|
||||
let body2 = bar2.close - bar2.open;
|
||||
let body3 = candle.close - candle.open;
|
||||
let upper2 = bar2.high - bar2.close;
|
||||
let upper3 = candle.high - candle.close;
|
||||
if bar1.close > bar1.open
|
||||
&& bar2.close > bar2.open
|
||||
&& candle.close > candle.open
|
||||
&& bar2.close > bar1.close
|
||||
&& candle.close > bar2.close
|
||||
&& bar2.open >= bar1.open
|
||||
&& bar2.open <= bar1.close
|
||||
&& candle.open >= bar2.open
|
||||
&& candle.open <= bar2.close
|
||||
&& body2 < body1
|
||||
&& body3 < body2
|
||||
&& upper3 >= upper2
|
||||
&& upper3 > 0.0
|
||||
{
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.prev_prev = None;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdvanceBlock"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = AdvanceBlock::new();
|
||||
assert_eq!(t.name(), "AdvanceBlock");
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_block_is_minus_one() {
|
||||
let mut t = AdvanceBlock::new();
|
||||
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(13.5, 15.0, 13.4, 14.5, 2)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_advance_yields_zero() {
|
||||
let mut t = AdvanceBlock::new();
|
||||
// Bodies grow instead of shrinking -> a strong advance, not blocked.
|
||||
assert_eq!(t.update(c(10.0, 11.1, 9.9, 11.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(10.5, 12.6, 10.4, 12.5, 1)), Some(0.0));
|
||||
assert_eq!(t.update(c(11.5, 14.1, 11.4, 14.0, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_upper_shadow_growth_yields_zero() {
|
||||
let mut t = AdvanceBlock::new();
|
||||
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
|
||||
t.update(c(12.0, 14.3, 11.9, 14.0, 1));
|
||||
// bar3 shrinking body but no upper shadow -> not blocked.
|
||||
assert_eq!(t.update(c(13.5, 14.5, 13.4, 14.5, 2)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_return_zero() {
|
||||
let mut t = AdvanceBlock::new();
|
||||
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
|
||||
assert_eq!(t.update(c(12.0, 14.3, 11.9, 14.0, 1)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base, base + 2.0, base - 0.2, base + 1.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AdvanceBlock::new();
|
||||
let mut b = AdvanceBlock::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = AdvanceBlock::new();
|
||||
t.update(c(10.0, 13.1, 9.9, 13.0, 0));
|
||||
t.update(c(12.0, 14.3, 11.9, 14.0, 1));
|
||||
t.update(c(13.5, 15.0, 13.4, 14.5, 2));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(c(10.0, 13.1, 9.9, 13.0, 0)), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Advance/Decline Line — cumulative net advancing-minus-declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing
|
||||
/// issues across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`:
|
||||
/// the number of symbols with a positive price change minus the number with a
|
||||
/// negative change (unchanged symbols are ignored). The line accumulates this
|
||||
/// net value over time, so a rising line means advancers have persistently
|
||||
/// outnumbered decliners — broad participation — while a falling line warns that
|
||||
/// a rally is being carried by fewer and fewer names (a breadth divergence when
|
||||
/// the index itself is still rising).
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`. The line is defined from the very
|
||||
/// first tick, so `warmup_period == 1` and the indicator is ready after one
|
||||
/// update.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut ad = AdvanceDecline::new();
|
||||
/// // 3 advancers, 1 decliner -> net +2.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(0.5, 10.0, false, false),
|
||||
/// Member::new(2.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(ad.update(tick), Some(2.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdvanceDecline {
|
||||
line: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdvanceDecline {
|
||||
/// Construct a new Advance/Decline Line indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
line: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdvanceDecline {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancers() as f64 - section.decliners() as f64;
|
||||
self.line += net;
|
||||
self.has_emitted = true;
|
||||
Some(self.line)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.line = 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 {
|
||||
"AdvanceDecline"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// Build a cross-section with `up` advancers, `down` decliners and `flat`
|
||||
/// unchanged symbols.
|
||||
fn section(up: usize, down: usize, flat: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..up {
|
||||
members.push(Member::new(1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..down {
|
||||
members.push(Member::new(-1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..flat {
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
}
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.name(), "AdvanceDecline");
|
||||
assert_eq!(ad.warmup_period(), 1);
|
||||
assert!(!ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_net_breadth() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0));
|
||||
assert!(ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_accumulates_across_ticks() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2
|
||||
assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1
|
||||
assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_symbols_are_ignored() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
// 2 up, 2 down, 5 unchanged -> net 0, line stays flat.
|
||||
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
|
||||
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
ad.update(section(5, 0, 0));
|
||||
assert!(ad.is_ready());
|
||||
ad.reset();
|
||||
assert!(!ad.is_ready());
|
||||
// Line restarts from zero, not from the pre-reset value.
|
||||
assert_eq!(ad.update(section(1, 0, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![
|
||||
section(3, 1, 2),
|
||||
section(1, 4, 0),
|
||||
section(2, 2, 1),
|
||||
section(5, 0, 3),
|
||||
];
|
||||
let mut a = AdvanceDecline::new();
|
||||
let mut b = AdvanceDecline::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Advance/Decline Ratio — advancing issues divided by declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
|
||||
/// number of declining symbols across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
|
||||
/// above one means advancing issues outnumber declining ones (broad strength),
|
||||
/// while a reading below one signals broad weakness. Because it is a ratio rather
|
||||
/// than a difference, the ADR is comparable across universes of different sizes.
|
||||
///
|
||||
/// When a tick has no declining symbols the denominator is floored to one, so the
|
||||
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
|
||||
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut adr = AdvanceDeclineRatio::new();
|
||||
/// // 3 advancers, 1 decliner -> ratio 3.0.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(0.5, 10.0, false, false),
|
||||
/// Member::new(2.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(adr.update(tick), Some(3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdvanceDeclineRatio {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdvanceDeclineRatio {
|
||||
/// Construct a new Advance/Decline Ratio indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdvanceDeclineRatio {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let advancers = section.advancers() as f64;
|
||||
let decliners = section.decliners().max(1) as f64;
|
||||
self.has_emitted = true;
|
||||
Some(advancers / decliners)
|
||||
}
|
||||
|
||||
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 {
|
||||
"AdvanceDeclineRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..up {
|
||||
members.push(Member::new(1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..down {
|
||||
members.push(Member::new(-1.0, 10.0, false, false));
|
||||
}
|
||||
// A non-empty unchanged member guarantees a valid universe when both
|
||||
// counts are zero.
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.name(), "AdvanceDeclineRatio");
|
||||
assert_eq!(adr.warmup_period(), 1);
|
||||
assert!(!adr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_ratio() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.update(section(3, 1)), Some(3.0));
|
||||
assert!(adr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_decliners_floors_denominator() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
|
||||
assert_eq!(adr.update(section(4, 0)), Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_advancers_yields_zero() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.update(section(0, 5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
adr.update(section(3, 1));
|
||||
assert!(adr.is_ready());
|
||||
adr.reset();
|
||||
assert!(!adr.is_ready());
|
||||
assert_eq!(adr.update(section(2, 1)), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
|
||||
let mut a = AdvanceDeclineRatio::new();
|
||||
let mut b = AdvanceDeclineRatio::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -75,7 +91,7 @@ impl Adx {
|
||||
}
|
||||
}
|
||||
|
||||
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
|
||||
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
|
||||
let up = current.high - prev.high;
|
||||
let down = prev.low - current.low;
|
||||
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
|
||||
@@ -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,284 @@
|
||||
//! Anchored Relative Strength Index.
|
||||
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Anchored RSI — a cumulative Relative Strength Index whose averaging begins at
|
||||
/// a user-chosen anchor bar rather than over a fixed Wilder period.
|
||||
///
|
||||
/// Where [`crate::Rsi`] uses Wilder's `period`-length smoothing, Anchored RSI
|
||||
/// accumulates *every* up- and down-move since the anchor with equal weight, so
|
||||
/// it answers "what is the RSI of the entire move since the anchor point?". The
|
||||
/// running relative strength is `Σ gains / Σ losses` over all bars in the
|
||||
/// current anchor window (the bar count cancels, so this equals
|
||||
/// `avg_gain / avg_loss`):
|
||||
///
|
||||
/// ```text
|
||||
/// RSI_t = 100 - 100 / (1 + Σ_{i ≥ anchor} gain_i / Σ_{i ≥ anchor} loss_i)
|
||||
/// ```
|
||||
///
|
||||
/// As with [`crate::AnchoredVwap`], the anchor is chosen at runtime:
|
||||
/// [`AnchoredRsi::set_anchor`] re-anchors at the **next** bar that arrives,
|
||||
/// clearing the running sums. Because RSI needs a price *change*, the first bar
|
||||
/// of a fresh anchor window only seeds the previous close and emits `None`; the
|
||||
/// first value follows on the second bar (warmup period 2).
|
||||
///
|
||||
/// Saturation follows the standard convention: a window with no losses yet (and
|
||||
/// at least one gain) reads 100, no gains yet reads 0, and a perfectly flat
|
||||
/// window reads the neutral 50. Non-finite inputs are ignored, leaving the last
|
||||
/// value unchanged.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AnchoredRsi, Indicator};
|
||||
///
|
||||
/// let mut indicator = AnchoredRsi::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let price = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
|
||||
/// // Re-anchor at bar 40 (e.g. a major swing low).
|
||||
/// if i == 40 {
|
||||
/// indicator.set_anchor();
|
||||
/// }
|
||||
/// last = indicator.update(price);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AnchoredRsi {
|
||||
prev_close: Option<f64>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
last_value: Option<f64>,
|
||||
pending_anchor: bool,
|
||||
}
|
||||
|
||||
impl AnchoredRsi {
|
||||
/// Construct a fresh Anchored RSI. The first bar to arrive is the anchor.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev_close: None,
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
last_value: None,
|
||||
pending_anchor: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a re-anchor: the **next** [`Indicator::update`] call clears the
|
||||
/// running sums and previous close before folding in its own bar, starting
|
||||
/// a fresh anchored window.
|
||||
pub fn set_anchor(&mut self) {
|
||||
self.pending_anchor = true;
|
||||
}
|
||||
|
||||
/// Current anchored RSI value if at least one price change has been
|
||||
/// observed in the current anchor window.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
fn rsi_from_sums(sum_gain: f64, sum_loss: f64) -> f64 {
|
||||
if sum_loss == 0.0 {
|
||||
if sum_gain == 0.0 {
|
||||
// No movement at all -> RSI undefined; standard convention returns 50.
|
||||
50.0
|
||||
} else {
|
||||
100.0
|
||||
}
|
||||
} else {
|
||||
let rs = sum_gain / sum_loss;
|
||||
100.0 - 100.0 / (1.0 + rs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AnchoredRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
|
||||
if self.pending_anchor {
|
||||
self.prev_close = None;
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
self.last_value = None;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(input);
|
||||
return None;
|
||||
};
|
||||
self.prev_close = Some(input);
|
||||
|
||||
let diff = input - prev;
|
||||
if diff > 0.0 {
|
||||
self.sum_gain += diff;
|
||||
} else if diff < 0.0 {
|
||||
self.sum_loss -= diff;
|
||||
}
|
||||
|
||||
let value = Self::rsi_from_sums(self.sum_gain, self.sum_loss);
|
||||
self.last_value = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
self.last_value = None;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AnchoredRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = AnchoredRsi::new();
|
||||
assert_eq!(indicator.name(), "AnchoredRSI");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert_eq!(indicator.value(), None);
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_and_returns_none() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
assert_eq!(indicator.update(100.0), None);
|
||||
assert!(!indicator.is_ready());
|
||||
// Second bar produces the first value.
|
||||
assert!(indicator.update(101.0).is_some());
|
||||
assert!(indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_at_100() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
let out = indicator.batch(&[10.0, 11.0, 12.0, 13.0]);
|
||||
assert_relative_eq!(out[3].unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_saturates_at_0() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
let out = indicator.batch(&[13.0, 12.0, 11.0, 10.0]);
|
||||
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_reads_50() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
let out = indicator.batch(&[42.0, 42.0, 42.0]);
|
||||
assert_relative_eq!(out[2].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_reference_values() {
|
||||
// prices 10 -> 11 (+1) -> 9 (-2) -> 12 (+3)
|
||||
// after bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
|
||||
// after bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
let out = indicator.batch(&[10.0, 11.0, 9.0, 12.0]);
|
||||
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), 33.333_333_333, epsilon = 1e-6);
|
||||
assert_relative_eq!(out[3].unwrap(), 66.666_666_666, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_clears_old_window() {
|
||||
// Downtrend, then re-anchor and pump an uptrend: the new window must
|
||||
// read 100, not the blended value.
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
indicator.batch(&[20.0, 19.0, 18.0, 17.0]);
|
||||
assert_relative_eq!(indicator.value().unwrap(), 0.0, epsilon = 1e-12);
|
||||
indicator.set_anchor();
|
||||
// First bar after anchor re-seeds (None), second bar emits.
|
||||
assert_eq!(indicator.update(50.0), None);
|
||||
let after = indicator.update(51.0).unwrap();
|
||||
assert_relative_eq!(after, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_before_first_bar_acts_as_normal_start() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
indicator.set_anchor();
|
||||
assert_eq!(indicator.update(10.0), None);
|
||||
assert_relative_eq!(indicator.update(11.0).unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
indicator.batch(&[10.0, 11.0, 12.0]);
|
||||
let before = indicator.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(indicator.update(f64::NAN), before);
|
||||
assert_eq!(indicator.update(f64::INFINITY), before);
|
||||
assert_eq!(indicator.value(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_before_any_bar_returns_none() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
assert_eq!(indicator.update(f64::NAN), None);
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
indicator.batch(&[10.0, 11.0, 12.0]);
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
assert_eq!(indicator.value(), None);
|
||||
assert_eq!(indicator.update(50.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_in_0_100_range() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 10.0)
|
||||
.collect();
|
||||
let mut indicator = AnchoredRsi::new();
|
||||
for value in indicator.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&value), "RSI out of range: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i))
|
||||
.collect();
|
||||
let mut a = AnchoredRsi::new();
|
||||
let mut b = AnchoredRsi::new();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user