Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 529f302f73 | |||
| 5c40acd288 | |||
| de39415413 | |||
| 827cdc7a6b | |||
| ccf2b46482 | |||
| 1aa7df1c97 |
@@ -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 (PolyForm-NC-1.0.0)
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything reviewers should look at first, known follow-ups, deliberately out-of-scope items. -->
|
||||
@@ -0,0 +1,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
|
||||
+454
-63
@@ -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,69 +524,63 @@ 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
|
||||
run: npx napi build --release
|
||||
# --platform puts the target triple into the filename so the loader's
|
||||
# `wickra.<target>.node` lookup finds the freshly built binary instead
|
||||
# of falling back to the per-platform npm subpackage (which doesn't
|
||||
# exist yet for win32-x64-msvc).
|
||||
run: npx napi build --platform --release
|
||||
|
||||
- name: Run Node tests
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
cross-library-bench:
|
||||
name: Cross-library benchmark report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python deps + peer libs
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install maturin numpy pandas talipp finta
|
||||
|
||||
- name: Build Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: maturin build --release --out dist
|
||||
|
||||
- name: Install Wickra wheel
|
||||
working-directory: bindings/python
|
||||
run: python -m pip install --find-links dist --force-reinstall wickra
|
||||
|
||||
- name: Run cross-library benchmark
|
||||
working-directory: bindings/python
|
||||
run: |
|
||||
python -m benchmarks.compare_libraries --size 20000 --iterations 10 \
|
||||
--streaming-window 5000 --streaming-iterations 2 \
|
||||
| tee benchmark.txt
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cross-library-bench
|
||||
path: bindings/python/benchmark.txt
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
# the every-push / every-PR critical path.
|
||||
|
||||
@@ -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 }}"
|
||||
+571
-46
@@ -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,67 +300,228 @@ 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-*
|
||||
|
||||
- name: Create npm/<platform>/ scaffolding (one per target)
|
||||
working-directory: bindings/node
|
||||
run: |
|
||||
for t in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-msvc; do
|
||||
npx napi create-npm-dir -t "$t" .
|
||||
done
|
||||
|
||||
# The npm/<platform>/package.json templates are committed to the
|
||||
# repo (see bindings/node/npm/), so `napi artifacts` only needs to
|
||||
# drop the freshly built .node binary into the matching directory.
|
||||
- name: Move binaries into platform package layout
|
||||
working-directory: bindings/node
|
||||
run: npx napi artifacts --dir artifacts
|
||||
|
||||
- name: Prepublish platform packages to npm
|
||||
# Publish each platform package individually. Skip versions that are
|
||||
# already on npm. A first-attempt 403 from npm's spam filter is
|
||||
# tolerated for a single 30-second retry — that historically clears
|
||||
# rate-limit-driven false positives. Anything that still fails after
|
||||
# the retry is a *real* failure (the platform binary will be missing
|
||||
# from `optionalDependencies` and Windows-style installs will break,
|
||||
# exactly the regression that produced audit finding R20) — fail the
|
||||
# job loudly so the release does not silently land in a half-published
|
||||
# state. Previously this loop swallowed the second-attempt failure with
|
||||
# a `::warning::` and `return 0`; that mask is removed.
|
||||
- name: Publish platform packages (idempotent)
|
||||
working-directory: bindings/node
|
||||
run: npx napi prepublish -t npm --skip-gh-release
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
fail=0
|
||||
publish_dir() {
|
||||
local dir=$1
|
||||
local pkg=$(basename "$dir")
|
||||
local pkgname="wickra-$pkg"
|
||||
local existing
|
||||
existing=$(npm view "$pkgname@$version" version 2>/dev/null)
|
||||
if [ "$existing" = "$version" ]; then
|
||||
echo "::notice::skip $pkgname@$version (already on npm)"
|
||||
return 0
|
||||
fi
|
||||
echo "::group::publish $pkgname@$version"
|
||||
# --ignore-scripts: a per-platform package must never run lifecycle
|
||||
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
|
||||
# the package being published — a malicious or stray script would
|
||||
# execute with the npm token in the environment).
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
local rc=$?
|
||||
echo "::endgroup::"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
|
||||
rc=$?
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
for dir in npm/*/; do
|
||||
publish_dir "$dir" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Publish main package to npm
|
||||
- name: Sync root README into bindings/node so it ships with the npm tarball
|
||||
# npm reads README.md from the package directory at publish time. Copy
|
||||
# the canonical root README in just before the publish so every
|
||||
# registry shows the same project page.
|
||||
shell: bash
|
||||
run: cp ../../README.md README.md
|
||||
working-directory: bindings/node
|
||||
|
||||
- name: Publish main package to npm (idempotent)
|
||||
working-directory: bindings/node
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
# GitHub Actions defaults to `bash -e`. `npm view` on a not-yet-
|
||||
# published package returns 1, which would kill the step before
|
||||
# we got to publish. Force a graceful empty result instead.
|
||||
set +e
|
||||
version=$(node -p "require('./package.json').version")
|
||||
existing=$(npm view "wickra@$version" version 2>/dev/null)
|
||||
if [ "$existing" = "$version" ]; then
|
||||
echo "::notice::skip wickra@$version (already on npm)"
|
||||
exit 0
|
||||
fi
|
||||
# --ignore-scripts so any leftover prepublish hooks (which would
|
||||
# otherwise try to republish the already-published platform
|
||||
# subpackages) can't sabotage the main publish.
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
|
||||
sleep 30
|
||||
npm publish --access public --ignore-scripts --provenance
|
||||
rc=$?
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
- name: Pack node tarballs for release attachment
|
||||
working-directory: bindings/node
|
||||
run: |
|
||||
# Main package
|
||||
npm pack --ignore-scripts
|
||||
# Each per-platform package (the binaries were already moved in by
|
||||
# napi artifacts). --ignore-scripts for the same reason as publish:
|
||||
# packing must not execute lifecycle scripts from the packed dir.
|
||||
for d in npm/*/; do
|
||||
(cd "$d" && npm pack --ignore-scripts)
|
||||
done
|
||||
|
||||
- name: Upload Node tarballs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: node-tarballs
|
||||
path: |
|
||||
bindings/node/*.tgz
|
||||
bindings/node/npm/*/*.tgz
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
|
||||
# --------------------------------------------------------------------------
|
||||
# Note: this job's npm publish call uses `--provenance` (see below),
|
||||
# which requires the `id-token: write` permission set at the job level.
|
||||
wasm-publish:
|
||||
name: Publish wickra-wasm to npm
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# `id-token: write` lets npm publish embed a Sigstore provenance
|
||||
# attestation generated from the GitHub Actions OIDC token (same
|
||||
# mechanism as the node-publish job above).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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
|
||||
@@ -234,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 = 'PolyForm-Noncommercial-1.0.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
- name: Pack wickra-wasm for release attachment
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: npm pack
|
||||
|
||||
- name: Upload WASM tarball
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wasm-tarball
|
||||
path: bindings/wasm/pkg/wickra-wasm-*.tgz
|
||||
|
||||
- name: Publish wickra-wasm to npm (idempotent)
|
||||
working-directory: bindings/wasm/pkg
|
||||
run: |
|
||||
out=$(npm publish --access public 2>&1) && echo "$out" \
|
||||
out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
|
||||
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|
||||
|| (echo "$out"; exit 1))
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GitHub Release: attach every built artefact to the tag's release page.
|
||||
#
|
||||
# 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,516 @@
|
||||
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: keep README in sync *before* a PR is merged, by pushing a
|
||||
# fix-up commit to the PR head branch. After squash-merge into main
|
||||
# the bot commit is folded into the single signed merge commit, so
|
||||
# main's history never shows an unsigned "sync indicator count" entry.
|
||||
#
|
||||
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
|
||||
# explicitly do NOT trigger downstream workflows (anti-recursion
|
||||
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
|
||||
# — it does, however, re-trigger sync-about.yml on the next PR
|
||||
# `synchronize` event, which is what we want (a no-op if the counter
|
||||
# is now correct).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# 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
|
||||
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
|
||||
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
# On PRs from forks the head ref lives in another repo; pushing
|
||||
# back to it from this workflow is blocked by GitHub. We still
|
||||
# want the PR to surface the missing counter, so the check below
|
||||
# falls back to a hard failure when push isn't possible.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
|
||||
# controlled on fork PRs) are passed through the environment, never
|
||||
# interpolated straight into the shell, so a crafted branch name cannot
|
||||
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# On PRs we check out the *head* commit (not the merge ref) so
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
|
||||
# push; the About / Wiki steps re-authenticate with the PAT
|
||||
# below where needed.
|
||||
|
||||
- name: Count indicators
|
||||
id: count
|
||||
run: |
|
||||
# Parse the `pub use indicators::{ ... }` block from lib.rs, strip
|
||||
# the FAMILIES constant and any `*Output` companion structs, count
|
||||
# the remaining identifiers. Pure-shell so the workflow doesn't
|
||||
# require a python runtime.
|
||||
n=$(sed -n '/^pub use indicators::{/,/^};/p' crates/wickra-core/src/lib.rs \
|
||||
| tr ',{}' '\n' \
|
||||
| sed 's/[[:space:]]//g' \
|
||||
| grep -E '^[A-Z][A-Za-z0-9_]*$' \
|
||||
| grep -vE '^FAMILIES$|Output$' \
|
||||
| sort -u | wc -l)
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "Indicator count: $n"
|
||||
|
||||
# ----- PR flow ---------------------------------------------------
|
||||
|
||||
- name: Check README counter (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_check
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if grep -qE "^${n} streaming-first indicators" README.md \
|
||||
&& grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
|
||||
echo "matches=true" >> "$GITHUB_OUTPUT"
|
||||
echo "README + docs/README counter already at ${n}; nothing to do."
|
||||
else
|
||||
echo "matches=false" >> "$GITHUB_OUTPUT"
|
||||
echo "README/docs counter does not match ${n}; will fix up."
|
||||
fi
|
||||
|
||||
- name: Fix counter on fork PR head (read-only, fail loud)
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
echo "::error::README.md / docs/README.md say a different indicator count than lib.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please set README.md to '${n} streaming-first indicators' and docs/README.md to '**${n} indicators**', then push again."
|
||||
exit 1
|
||||
|
||||
- name: Patch README on PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
|
||||
id: pr_patch
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
# docs/README.md carries the count in its docs.wickra.org pointer prose
|
||||
# ("**N indicators**"); keep it in sync with README's prose count.
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md docs/README.md
|
||||
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
|
||||
# profile image (regenerated with the new count by .github/banner.yml)
|
||||
# instead of serving a stale cached copy. (README only — docs has no banner.)
|
||||
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
|
||||
if git diff --quiet; then
|
||||
echo "No README changes after sed (counter regex did not match anything); skipping push."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
# head_ref still carries the (untrusted) PR branch name forwarded by the
|
||||
# ctx step; pass it through the environment so the push refspec cannot be
|
||||
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
COUNT: ${{ steps.count.outputs.count }}
|
||||
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md docs/README.md
|
||||
git commit -m "chore: sync indicator count to ${COUNT}"
|
||||
git push origin "HEAD:${HEAD_REF}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
# After a PR squash-merges, this workflow runs again on the push
|
||||
# to main. README is already correct (it was fixed on the PR
|
||||
# branch before the merge); the only outward syncs left are the
|
||||
# GitHub About description (repo metadata, not a commit) and the
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
|
||||
- name: Update GitHub About (description + 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).
|
||||
+1146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
cff-version: 1.2.0
|
||||
title: Wickra
|
||||
message: >-
|
||||
If you use Wickra in academic work, please cite it using the metadata
|
||||
below.
|
||||
type: software
|
||||
authors:
|
||||
- alias: kingchenc
|
||||
email: 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: PolyForm-Noncommercial-1.0.0
|
||||
@@ -0,0 +1,47 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in
|
||||
the Wickra project a respectful and welcoming experience for everyone,
|
||||
regardless of background or identity.
|
||||
|
||||
## Our standards
|
||||
|
||||
Behaviour that helps build a positive community includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Respecting differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility for mistakes and learning from them.
|
||||
- Focusing on what is best for the project and the community.
|
||||
|
||||
Behaviour that is not acceptable includes:
|
||||
|
||||
- Personal attacks, insults, or derogatory comments.
|
||||
- Harassment of any kind, public or private.
|
||||
- Publishing others' private information without explicit permission.
|
||||
- Other conduct that would reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies in all project spaces — the repository, issues,
|
||||
pull requests, and discussions — and when an individual is representing the
|
||||
project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behaviour may be reported to the project maintainer
|
||||
at **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 licensed under the **PolyForm Noncommercial License 1.0.0** (see
|
||||
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
|
||||
licensed to the project under those same terms. The Noncommercial license
|
||||
permits use for any purpose **other than** a commercial one; keep that in mind
|
||||
when proposing features or depending on Wickra elsewhere.
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `crates/wickra-core` | The indicator engine — every indicator lives here. |
|
||||
| `crates/wickra` | Thin umbrella crate re-exporting `wickra-core`. |
|
||||
| `crates/wickra-data` | CSV reader, tick aggregator, resampler, Binance feed. |
|
||||
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/` | Pointer to the 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.1"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1894,17 +1878,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.1.1"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.1.1"
|
||||
version = "0.4.6"
|
||||
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.1"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1932,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.1.1"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1941,16 +1934,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.1.1"
|
||||
version = "0.4.6"
|
||||
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.1"
|
||||
authors = ["Wickra Contributors"]
|
||||
version = "0.4.6"
|
||||
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-file = "LICENSE"
|
||||
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.1" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.6" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
@@ -31,11 +32,11 @@ rayon = "1.10"
|
||||
# Testing
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
# Python binding
|
||||
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.22"
|
||||
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39"] }
|
||||
numpy = "0.28"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -35,7 +35,7 @@ URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
@@ -131,6 +131,31 @@ software under these terms.
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
|
||||
## Additional Permissions Granted by the Licensor
|
||||
|
||||
These additional permissions supplement the PolyForm Noncommercial
|
||||
License 1.0.0 above. They only broaden, and never narrow, the
|
||||
licenses granted to you. The text of the PolyForm Noncommercial
|
||||
License 1.0.0 above is unmodified.
|
||||
|
||||
Use by a natural person, acting for their own personal account and
|
||||
not on behalf of any third party, is use for a permitted purpose.
|
||||
This includes operating an automated trading bot or trading strategy
|
||||
on that person's own capital, whether or not it earns that person
|
||||
money.
|
||||
|
||||
For the avoidance of doubt, the licenses above already let you use,
|
||||
fork, modify, and redistribute the software, and file issues and
|
||||
contribute changes, for any permitted purpose. Personal projects,
|
||||
research, education, nonprofit organizations, government use, and
|
||||
hobby trading bots are permitted purposes.
|
||||
|
||||
Any other commercial use — in particular the commercial sale of the
|
||||
software itself, or the commercial sale of services built around it —
|
||||
requires a separate commercial license from the licensor. If you want
|
||||
to use Wickra commercially, get in touch about a license at
|
||||
<https://github.com/wickra-lib/wickra>.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/kingchenc/wickra)
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
@@ -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=314" 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)
|
||||
[](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 314 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,40 @@ 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
|
||||
314 streaming-first indicators across nineteen 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 |
|
||||
| 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 |
|
||||
| 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 +176,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 +239,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 314 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 +279,28 @@ wasm-pack build bindings/wasm --target web --release --features panic-hook
|
||||
cd bindings/node && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## Test counts
|
||||
## Testing
|
||||
|
||||
- `wickra-core`: 171 unit tests + 2 doctests, including textbook-value tests
|
||||
for Wilder RSI, Bollinger Bands, MACD, ATR, and Stochastic.
|
||||
- `wickra-data`: 11 unit tests + 1 doctest, covers CSV decoding, the tick
|
||||
aggregator, the resampler, and the Binance payload parser.
|
||||
- `bindings/python`: 56 pytest tests covering smoke checks, streaming==batch
|
||||
equivalence, reference values, lifecycle, and dict/tuple candle inputs.
|
||||
- `bindings/node`: 7 Node test-runner cases via `node --test`.
|
||||
Every layer is covered; run the suites with the commands in
|
||||
[Building everything from source](#building-everything-from-source).
|
||||
|
||||
- `wickra-core`: unit tests per indicator — textbook reference values
|
||||
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
|
||||
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
|
||||
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
|
||||
resampler, and the Binance payload parser.
|
||||
- `bindings/python`: pytest covering smoke checks, streaming/batch
|
||||
equivalence, reference values, lifecycle, input validation, and
|
||||
dict/tuple candle inputs.
|
||||
- `bindings/node`: `node --test` cases for batch, streaming, and reference
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome — issues, bug reports, ideas, and pull requests
|
||||
all land in the same place: <https://github.com/kingchenc/wickra>.
|
||||
all land in the same place: <https://github.com/wickra-lib/wickra>.
|
||||
|
||||
A short orientation for first-time contributors:
|
||||
|
||||
@@ -251,17 +331,27 @@ government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. Values it computes are
|
||||
deterministic transforms of the input data — they are not financial advice and
|
||||
they do not predict the market. Any use of this library in a production
|
||||
trading context is at your own risk.
|
||||
|
||||
The library is provided **as is**, without warranty of any kind; see
|
||||
[LICENSE](LICENSE) for the full terms.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kingchenc/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
<a href="https://github.com/wickra-lib/wickra/stargazers">
|
||||
<img alt="GitHub stars" src="https://img.shields.io/github/stars/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ffd866">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
<a href="https://github.com/wickra-lib/wickra/network/members">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=78dce8">
|
||||
</a>
|
||||
<a href="https://github.com/kingchenc/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/kingchenc/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
<a href="https://github.com/wickra-lib/wickra/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/wickra-lib/wickra?style=for-the-badge&logo=github&logoColor=white&color=ff6188">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
|
||||
version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| 0.1.x (latest) | :white_check_mark: |
|
||||
| older 0.1.x | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue for a security vulnerability.**
|
||||
|
||||
Report it privately through one of:
|
||||
|
||||
- GitHub's [private vulnerability reporting](https://github.com/wickra-lib/wickra/security/advisories/new)
|
||||
("Report a vulnerability" under the repository's *Security* tab), or
|
||||
- email to **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,10 +4,12 @@ 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"
|
||||
license.workspace = true
|
||||
# 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-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
+58
-30
@@ -1,45 +1,73 @@
|
||||
# @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/blob/main/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 the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -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
+3264
File diff suppressed because it is too large
Load Diff
+619
-40
@@ -1,50 +1,629 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// Platform-aware loader for the Wickra Node native binding.
|
||||
//
|
||||
// In production (after `npm install wickra`) the actual `.node` binary
|
||||
// lives inside a per-platform subpackage that npm installs as an
|
||||
// optional dependency — e.g. `wickra-linux-x64-gnu`. In development
|
||||
// (after `napi build --platform`) the binary sits next to this file.
|
||||
// We try the local path first and fall back to the subpackage so the
|
||||
// same loader works in both modes.
|
||||
|
||||
const { platform, arch } = process;
|
||||
const { join } = require('node:path');
|
||||
const { existsSync } = require('node:fs');
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
const TARGETS = {
|
||||
'linux-x64': 'linux-x64-gnu',
|
||||
'darwin-x64': 'darwin-x64',
|
||||
'darwin-arm64': 'darwin-arm64',
|
||||
'win32-x64': 'win32-x64-msvc',
|
||||
};
|
||||
const { existsSync, readFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
function load() {
|
||||
const key = `${platform}-${arch}`;
|
||||
const target = TARGETS[key];
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`wickra: this platform/architecture combination is not supported (${key}). ` +
|
||||
'Open an issue at https://github.com/kingchenc/wickra/issues with your platform details.'
|
||||
);
|
||||
}
|
||||
const { platform, arch } = process
|
||||
|
||||
const localBinary = join(__dirname, `wickra.${target}.node`);
|
||||
if (existsSync(localBinary)) {
|
||||
return require(localBinary);
|
||||
}
|
||||
let nativeBinding = null
|
||||
let localFileExisted = false
|
||||
let loadError = null
|
||||
|
||||
try {
|
||||
return require(`wickra-${target}`);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`wickra: failed to load the native binding for ${key}. ` +
|
||||
`Expected either ${localBinary} or the wickra-${target} package to be installed. ` +
|
||||
`Run \`npm install\` again, or build from source with \`npm run build\`. ` +
|
||||
`Underlying error: ${err.message}`
|
||||
);
|
||||
function isMusl() {
|
||||
// For Node 10
|
||||
if (!process.report || typeof process.report.getReport !== 'function') {
|
||||
try {
|
||||
const lddPath = require('child_process').execSync('which ldd').toString().trim()
|
||||
return readFileSync(lddPath, 'utf8').includes('musl')
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const { glibcVersionRuntime } = process.report.getReport().header
|
||||
return !glibcVersionRuntime
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = load();
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
switch (arch) {
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.android-arm64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.android-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-android-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.android-arm-eabi.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.android-arm-eabi.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-android-arm-eabi')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Android ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'win32':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-x64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-x64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-x64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'ia32':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-ia32-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-ia32-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-ia32-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.win32-arm64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.win32-arm64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-win32-arm64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Windows: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'darwin':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.darwin-universal.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-universal.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-universal')
|
||||
}
|
||||
break
|
||||
} catch {}
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.darwin-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.darwin-arm64.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.darwin-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-darwin-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on macOS: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'freebsd':
|
||||
if (arch !== 'x64') {
|
||||
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
|
||||
}
|
||||
localFileExisted = existsSync(join(__dirname, 'wickra.freebsd-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.freebsd-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-freebsd-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'linux':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-x64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-x64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-x64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-x64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-x64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-x64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm-musleabihf.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm-musleabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm-musleabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-arm-gnueabihf.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-arm-gnueabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-arm-gnueabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'riscv64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-riscv64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-riscv64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-riscv64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-riscv64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-riscv64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-riscv64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 's390x':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'wickra.linux-s390x-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./wickra.linux-s390x-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('wickra-linux-s390x-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Linux: ${arch}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadError) {
|
||||
throw loadError
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, 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, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, 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, 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
|
||||
module.exports.EMA = EMA
|
||||
module.exports.WMA = WMA
|
||||
module.exports.RSI = RSI
|
||||
module.exports.DEMA = DEMA
|
||||
module.exports.TEMA = TEMA
|
||||
module.exports.HMA = HMA
|
||||
module.exports.ROC = ROC
|
||||
module.exports.TRIX = TRIX
|
||||
module.exports.SMMA = SMMA
|
||||
module.exports.TRIMA = TRIMA
|
||||
module.exports.ZLEMA = ZLEMA
|
||||
module.exports.MOM = MOM
|
||||
module.exports.CMO = CMO
|
||||
module.exports.DPO = DPO
|
||||
module.exports.StdDev = StdDev
|
||||
module.exports.UlcerIndex = UlcerIndex
|
||||
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
|
||||
module.exports.ZScore = ZScore
|
||||
module.exports.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.PairSpreadZScore = PairSpreadZScore
|
||||
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
|
||||
module.exports.Cointegration = Cointegration
|
||||
module.exports.RelativeStrengthAB = RelativeStrengthAB
|
||||
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
|
||||
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.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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.4.6",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
"wickra.darwin-arm64.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.4.6",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
"wickra.darwin-x64.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.4.6",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.4.6",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
"wickra.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.4.6",
|
||||
"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": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.4.6",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"wickra.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"homepage": "https://github.com/wickra-lib/wickra"
|
||||
}
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.6",
|
||||
"wickra-darwin-x64": "0.4.6",
|
||||
"wickra-linux-arm64-gnu": "0.4.6",
|
||||
"wickra-linux-x64-gnu": "0.4.6",
|
||||
"wickra-win32-arm64-msvc": "0.4.6",
|
||||
"wickra-win32-x64-msvc": "0.4.6"
|
||||
}
|
||||
},
|
||||
"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.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.6.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.6.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.6.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.6.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.6.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.6.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.1.1",
|
||||
"version": "0.4.6",
|
||||
"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": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"keywords": [
|
||||
"trading",
|
||||
"indicators",
|
||||
@@ -17,12 +17,12 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kingchenc/wickra"
|
||||
"url": "https://github.com/wickra-lib/wickra"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kingchenc/wickra/issues"
|
||||
"url": "https://github.com/wickra-lib/wickra/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kingchenc/wickra",
|
||||
"homepage": "https://github.com/wickra-lib/wickra",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
@@ -35,23 +35,33 @@
|
||||
"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.4.6",
|
||||
"wickra-linux-arm64-gnu": "0.4.6",
|
||||
"wickra-darwin-x64": "0.4.6",
|
||||
"wickra-darwin-arm64": "0.4.6",
|
||||
"wickra-win32-x64-msvc": "0.4.6",
|
||||
"wickra-win32-arm64-msvc": "0.4.6"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
"build:debug": "napi build --platform",
|
||||
"artifacts": "napi artifacts",
|
||||
"prepublishOnly": "napi prepublish -t npm",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
"test": "node --test __tests__/",
|
||||
"bench": "node benchmarks/throughput.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
|
||||
+11588
-295
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
+47
-29
@@ -1,54 +1,72 @@
|
||||
# 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/blob/main/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 the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
@@ -4,10 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.1.1"
|
||||
version = "0.4.6"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0" }
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
|
||||
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
@@ -20,6 +21,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Rust",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
"Topic :: Scientific/Engineering :: Mathematics",
|
||||
@@ -45,9 +47,9 @@ bench = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kingchenc/wickra"
|
||||
Repository = "https://github.com/kingchenc/wickra"
|
||||
Issues = "https://github.com/kingchenc/wickra/issues"
|
||||
Homepage = "https://github.com/wickra-lib/wickra"
|
||||
Repository = "https://github.com/wickra-lib/wickra"
|
||||
Issues = "https://github.com/wickra-lib/wickra/issues"
|
||||
|
||||
[tool.maturin]
|
||||
manifest-path = "Cargo.toml"
|
||||
|
||||
@@ -25,58 +25,676 @@ 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
|
||||
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,
|
||||
# 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
|
||||
"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",
|
||||
# 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: ...
|
||||
|
||||
+14514
-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)
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
@@ -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
|
||||
|
||||
+56
-31
@@ -1,47 +1,72 @@
|
||||
# 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/blob/main/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 the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
|
||||
+8657
-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", ".."]
|
||||
@@ -5,12 +5,18 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
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,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,37 @@ 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 },
|
||||
}
|
||||
|
||||
/// 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,277 @@
|
||||
//! Acceleration Bands (Price Headley).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Acceleration Bands output: SMA of close with momentum-biased envelopes
|
||||
/// driven by the bar's high/low geometry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AccelerationBandsOutput {
|
||||
/// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`.
|
||||
pub upper: f64,
|
||||
/// Middle band: SMA of close.
|
||||
pub middle: f64,
|
||||
/// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
|
||||
/// bar's relative range `(high − low) / (high + low)`.
|
||||
///
|
||||
/// ```text
|
||||
/// ratio = (high − low) / (high + low)
|
||||
/// raw_up = high · (1 + factor · ratio)
|
||||
/// raw_lo = low · (1 − factor · ratio)
|
||||
/// upper = SMA(raw_up, period)
|
||||
/// middle = SMA(close, period)
|
||||
/// lower = SMA(raw_lo, period)
|
||||
/// ```
|
||||
///
|
||||
/// Headley's reference parameters are `period = 20`, `factor = 0.001` for
|
||||
/// intraday equity markets — the geometric `ratio` term tends to scale on
|
||||
/// fractional moves, so the literal `factor` is small. The bands compress in
|
||||
/// quiet markets and flare on impulsive bars, making them a momentum-biased
|
||||
/// alternative to the volatility-driven Bollinger or Keltner envelopes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AccelerationBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccelerationBands {
|
||||
upper_sma: Sma,
|
||||
middle_sma: Sma,
|
||||
lower_sma: Sma,
|
||||
factor: f64,
|
||||
period: usize,
|
||||
}
|
||||
|
||||
impl AccelerationBands {
|
||||
/// Construct a new Acceleration Bands indicator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
|
||||
/// and finite.
|
||||
pub fn new(period: usize, factor: f64) -> Result<Self> {
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
upper_sma: Sma::new(period)?,
|
||||
middle_sma: Sma::new(period)?,
|
||||
lower_sma: Sma::new(period)?,
|
||||
factor,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Headley's classic configuration: `period = 20`, `factor = 0.001`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(period, factor)`.
|
||||
pub const fn parameters(&self) -> (usize, f64) {
|
||||
(self.period, self.factor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AccelerationBands {
|
||||
type Input = Candle;
|
||||
type Output = AccelerationBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
|
||||
// (high + low) == 0 is geometrically impossible for valid OHLC
|
||||
// (high >= low and a zero-sum requires both equal to 0, which would
|
||||
// make the bar degenerate). Guard anyway so a hypothetical zero-price
|
||||
// bar collapses the ratio to zero rather than emitting NaN.
|
||||
let sum_hl = candle.high + candle.low;
|
||||
let ratio = if sum_hl == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(candle.high - candle.low) / sum_hl
|
||||
};
|
||||
let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
|
||||
let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
|
||||
|
||||
// Feed all three SMAs unconditionally so they warm up in lock-step.
|
||||
let upper = self.upper_sma.update(raw_up);
|
||||
let middle = self.middle_sma.update(candle.close);
|
||||
let lower = self.lower_sma.update(raw_lo);
|
||||
let (upper, middle, lower) = (upper?, middle?, lower?);
|
||||
Some(AccelerationBandsOutput {
|
||||
upper,
|
||||
middle,
|
||||
lower,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.upper_sma.reset();
|
||||
self.middle_sma.reset();
|
||||
self.lower_sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.middle_sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AccelerationBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(0, 0.001),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_factor() {
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AccelerationBands::new(20, f64::NAN),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AccelerationBands::classic();
|
||||
let (p, f) = ab.parameters();
|
||||
assert_eq!(p, 20);
|
||||
assert_relative_eq!(f, 0.001, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 20);
|
||||
assert_eq!(ab.name(), "AccelerationBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_to_constant() {
|
||||
// high == low so the ratio term is zero; all three SMAs converge to
|
||||
// the same constant.
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut ab = AccelerationBands::new(5, 0.001).unwrap();
|
||||
for i in 0..4 {
|
||||
let base = 100.0 + f64::from(i);
|
||||
assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
|
||||
}
|
||||
assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(20, 0.5).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
|
||||
assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AccelerationBands::new(10, 0.5).unwrap();
|
||||
let mut b = AccelerationBands::new(10, 0.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut ab = AccelerationBands::new(5, 0.5).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_price_candle_collapses_ratio_to_zero() {
|
||||
// `high + low == 0` is geometrically only reachable with a fully-zero
|
||||
// bar (high >= low and both non-negative for a real market, but
|
||||
// `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
|
||||
// ratio guard must fire and the bands all collapse to zero.
|
||||
let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(zero).unwrap();
|
||||
assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
/// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
|
||||
/// `close = 10`, `factor = 0.5`, `period = 1`.
|
||||
/// `ratio = (12 − 8) / (12 + 8) = 0.2`
|
||||
/// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
|
||||
/// `raw_lo = 8 · (1 − 0.5 · 0.2) = 8 · 0.9 = 7.2`
|
||||
/// `middle = SMA(close, 1) = 10`
|
||||
#[test]
|
||||
fn reference_value_single_bar() {
|
||||
let mut ab = AccelerationBands::new(1, 0.5).unwrap();
|
||||
let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
|
||||
assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Accelerator Oscillator (Bill Williams).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::awesome_oscillator::AwesomeOscillator;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
|
||||
///
|
||||
/// ```text
|
||||
/// AO = SMA(median, fast) − SMA(median, slow) (the Awesome Oscillator)
|
||||
/// AC = AO − SMA(AO, signal)
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
|
||||
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
|
||||
/// moving average of itself. Because acceleration leads speed, `AC` tends to
|
||||
/// turn before the `AO` does. Bill Williams' classic configuration is the
|
||||
/// `(5, 34)` AO with a `5`-period signal average.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
|
||||
///
|
||||
/// let mut indicator = AcceleratorOscillator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcceleratorOscillator {
|
||||
ao: AwesomeOscillator,
|
||||
signal: Sma,
|
||||
ao_fast: usize,
|
||||
ao_slow: usize,
|
||||
signal_period: usize,
|
||||
}
|
||||
|
||||
impl AcceleratorOscillator {
|
||||
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
|
||||
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
|
||||
/// AO `fast` period is not strictly below `slow`.
|
||||
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
ao_fast,
|
||||
ao_slow,
|
||||
signal_period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(ao_fast, ao_slow, signal_period)`.
|
||||
pub const fn params(&self) -> (usize, usize, usize) {
|
||||
(self.ao_fast, self.ao_slow, self.signal_period)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AcceleratorOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ao = self.ao.update(candle)?;
|
||||
let signal = self.signal.update(ao)?;
|
||||
Some(ao - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ao.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The AO emits at candle `ao_slow`; the signal SMA then needs
|
||||
// `signal_period` AO values.
|
||||
self.ao_slow + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AcceleratorOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// A flat market gives AO = 0, so its signal average and AC are 0 too.
|
||||
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
for v in ac.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_independent_ao_and_signal() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.3, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let mut ao = AwesomeOscillator::classic();
|
||||
let mut signal = Sma::new(5).unwrap();
|
||||
for (i, candle) in candles.iter().enumerate() {
|
||||
let got = ac.update(*candle);
|
||||
match ao.update(*candle) {
|
||||
Some(ao_val) => match signal.update(ao_val) {
|
||||
Some(sig) => {
|
||||
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
|
||||
}
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
},
|
||||
None => assert!(got.is_none(), "i={i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
let out = ac.batch(&candles);
|
||||
assert_eq!(ac.warmup_period(), 38);
|
||||
for (i, v) in out.iter().enumerate().take(37) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
|
||||
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
|
||||
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `params` (69-71) and the Indicator-impl
|
||||
/// `name` body (99-101). Existing tests inspect numeric output but
|
||||
/// never query the metadata.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ac = AcceleratorOscillator::classic();
|
||||
assert_eq!(ac.params(), (5, 34, 5));
|
||||
assert_eq!(ac.name(), "AcceleratorOscillator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ac = AcceleratorOscillator::classic();
|
||||
ac.batch(&candles);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
assert_eq!(ac.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..90)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(m + 1.5, m - 1.5, m + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AcceleratorOscillator::classic();
|
||||
let mut b = AcceleratorOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Williams Accumulation/Distribution.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
|
||||
/// flow that classifies each bar as accumulation or distribution based on its
|
||||
/// close relative to the previous close, then sums the directional component.
|
||||
///
|
||||
/// Williams' definition (1972) uses a *true* high/low that includes the prior
|
||||
/// close as an anchor — the same idea that motivates true range:
|
||||
///
|
||||
/// ```text
|
||||
/// TR_h_t = max(close_{t−1}, high_t)
|
||||
/// TR_l_t = min(close_{t−1}, low_t)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_l_t) if close_t > close_{t−1} (accumulation)
|
||||
/// AD_t = AD_{t−1} + (close_t − TR_h_t) if close_t < close_{t−1} (distribution)
|
||||
/// AD_t = AD_{t−1} if close_t == close_{t−1} (no change)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
|
||||
/// volume entirely — Williams argued that the relative position of the close
|
||||
/// already encodes the day's "true" buying or selling pressure. The series is
|
||||
/// unbounded and used primarily for divergence analysis. The first candle only
|
||||
/// seeds the previous close; the first emission lands at bar 2.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdOscillator};
|
||||
///
|
||||
/// let mut indicator = AdOscillator::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdOscillator {
|
||||
prev_close: Option<f64>,
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdOscillator {
|
||||
/// Construct a new Williams A/D starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
prev_close: None,
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one emission has happened.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev_close else {
|
||||
// The first bar only establishes the previous close anchor.
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let delta = if candle.close > prev {
|
||||
// Accumulation: distance from the true low.
|
||||
let tr_l = prev.min(candle.low);
|
||||
candle.close - tr_l
|
||||
} else if candle.close < prev {
|
||||
// Distribution: distance from the true high (negative).
|
||||
let tr_h = prev.max(candle.high);
|
||||
candle.close - tr_h
|
||||
} else {
|
||||
// Unchanged close contributes nothing.
|
||||
0.0
|
||||
};
|
||||
self.total += delta;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One seed bar; the second bar is the first emission.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WilliamsAD"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 100.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ad = AdOscillator::new();
|
||||
assert_eq!(ad.name(), "WilliamsAD");
|
||||
assert_eq!(ad.warmup_period(), 2);
|
||||
assert_eq!(ad.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_returns_total_after_first_emission() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_only_seeds() {
|
||||
let mut ad = AdOscillator::new();
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
|
||||
assert!(!ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulation_adds_distance_from_true_low() {
|
||||
// prev close = 10, today low = 8, today close = 12 (up day).
|
||||
// TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_adds_distance_from_true_high() {
|
||||
// prev close = 10, today high = 11, today close = 7 (down day).
|
||||
// TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, -4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_keeps_total() {
|
||||
// close equals prev close -> no contribution.
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
|
||||
let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// Every close equals the previous -> AD stays at zero forever.
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ad = AdOscillator::new();
|
||||
for v in ad.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80i64)
|
||||
.map(|i| {
|
||||
let f = i as f64;
|
||||
let mid = 100.0 + (f * 0.3).sin() * 5.0;
|
||||
c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AdOscillator::new();
|
||||
let mut b = AdOscillator::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ad = AdOscillator::new();
|
||||
ad.batch(&[
|
||||
c(10.0, 11.0, 9.0, 10.0, 0),
|
||||
c(10.0, 12.0, 9.0, 11.0, 1),
|
||||
c(11.0, 13.0, 10.0, 12.0, 2),
|
||||
]);
|
||||
assert!(ad.is_ready());
|
||||
ad.reset();
|
||||
assert!(!ad.is_ready());
|
||||
assert_eq!(ad.value(), None);
|
||||
assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
|
||||
|
||||
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Adaptive Cycle Indicator.
|
||||
///
|
||||
/// Returns half the current dominant cycle period — the "best" lookback for
|
||||
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
|
||||
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
|
||||
/// the fact that an oscillator over a half-cycle captures the full peak-to-
|
||||
/// trough swing without aliasing.
|
||||
///
|
||||
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
|
||||
/// matching the typical operating range of period-adaptive oscillators.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveCycle};
|
||||
///
|
||||
/// let mut ac = AdaptiveCycle::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdaptiveCycle {
|
||||
cycle: HilbertDominantCycle,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCycle {
|
||||
/// Construct a new adaptive cycle estimator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current adaptive period if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCycle {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let period = self.cycle.update(input)?;
|
||||
let half = (period * 0.5).round().clamp(3.0, 25.0);
|
||||
self.last_value = Some(half);
|
||||
Some(half)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cycle.reset();
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.cycle.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCycle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
assert_eq!(ac.warmup_period(), 50);
|
||||
assert_eq!(ac.name(), "AdaptiveCycle");
|
||||
assert!(!ac.is_ready());
|
||||
assert!(ac.value().is_none());
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
assert!(ac.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_clamp_band() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
|
||||
.collect();
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
for v in ac.batch(&prices).into_iter().flatten() {
|
||||
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
|
||||
assert_eq!(v, v.round(), "expected integer-valued output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = AdaptiveCycle::new();
|
||||
let mut b = AdaptiveCycle::new();
|
||||
let batch = a.batch(&prices);
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
let before = ac.value();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(ac.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ac = AdaptiveCycle::new();
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
|
||||
.collect();
|
||||
ac.batch(&prices);
|
||||
assert!(ac.is_ready());
|
||||
ac.reset();
|
||||
assert!(!ac.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Accumulation/Distribution Line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
|
||||
/// indicator.
|
||||
///
|
||||
/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
|
||||
/// where the close fell within the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1)
|
||||
/// MFV_t = MFM_t · volume_t
|
||||
/// ADL_t = ADL_{t−1} + MFV_t
|
||||
/// ```
|
||||
///
|
||||
/// A close near the high makes the multiplier near `+1` (accumulation), near
|
||||
/// the low near `−1` (distribution). The running total is unbounded and drifts
|
||||
/// with cumulative volume — what matters is its slope and its divergence from
|
||||
/// price. A bar with `high == low` contributes `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Adl};
|
||||
///
|
||||
/// let mut indicator = Adl::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Adl {
|
||||
total: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
/// Construct a new Accumulation/Distribution Line starting at zero.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
total: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current cumulative value if at least one candle has been ingested.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
if self.has_emitted {
|
||||
Some(self.total)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adl {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let mfv = if range == 0.0 {
|
||||
// A zero-range bar carries no positional information.
|
||||
0.0
|
||||
} else {
|
||||
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
|
||||
mfm * candle.volume
|
||||
};
|
||||
self.total += mfv;
|
||||
self.has_emitted = true;
|
||||
Some(self.total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.total = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADL"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
|
||||
// bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
|
||||
// ADL = 100 - 100 = 0.
|
||||
let mut adl = Adl::new();
|
||||
let out = adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
|
||||
candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
|
||||
]);
|
||||
assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_candle() {
|
||||
let mut adl = Adl::new();
|
||||
assert_eq!(adl.warmup_period(), 1);
|
||||
assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
|
||||
}
|
||||
|
||||
/// Cover the Indicator-impl `name` body (94-96). The other accessors
|
||||
/// are exercised by existing tests; `name` was never queried.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let adl = Adl::new();
|
||||
assert_eq!(adl.name(), "ADL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_high_accumulates_full_volume() {
|
||||
// Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
|
||||
let mut adl = Adl::new();
|
||||
let mut expected = 0.0;
|
||||
for i in 0..10 {
|
||||
let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
|
||||
expected += 25.0;
|
||||
assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_contributes_nothing() {
|
||||
let mut adl = Adl::new();
|
||||
adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
|
||||
let before = adl.value().unwrap();
|
||||
// A flat candle (high == low) adds zero.
|
||||
let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
|
||||
assert_relative_eq!(after, before, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adl = Adl::new();
|
||||
adl.batch(&[
|
||||
candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
|
||||
candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
|
||||
]);
|
||||
assert!(adl.is_ready());
|
||||
adl.reset();
|
||||
assert!(!adl.is_ready());
|
||||
assert_eq!(adl.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(
|
||||
mid,
|
||||
mid + 2.0,
|
||||
mid - 2.0,
|
||||
mid + 0.5,
|
||||
10.0 + (i % 5) as f64,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Adl::new().batch(&candles);
|
||||
let mut b = Adl::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,22 @@ pub struct AroonOutput {
|
||||
|
||||
/// Aroon indicator: tracks how many bars since the highest high and lowest low
|
||||
/// inside a `period + 1`-bar window. Returned as a percentage.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Aroon};
|
||||
///
|
||||
/// let mut indicator = Aroon::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Aroon {
|
||||
period: usize,
|
||||
@@ -153,4 +169,26 @@ mod tests {
|
||||
assert!((0.0..=100.0).contains(&o.down));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (1..=20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut a = Aroon::new(14).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` (56-58) and the Indicator-impl
|
||||
/// `name` body (104-106). `warmup_period` is exercised elsewhere.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Aroon::new(14).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.name(), "Aroon");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Aroon Oscillator.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Aroon;
|
||||
|
||||
/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
|
||||
///
|
||||
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
|
||||
/// collapses them into one value in `[−100, 100]`:
|
||||
///
|
||||
/// ```text
|
||||
/// AroonOscillator = AroonUp − AroonDown
|
||||
/// ```
|
||||
///
|
||||
/// Strongly positive means the most recent high is much fresher than the most
|
||||
/// recent low (an up-trend); strongly negative is the mirror image. Readings
|
||||
/// near zero mean neither extreme is recent — a range.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AroonOscillator};
|
||||
///
|
||||
/// let mut indicator = AroonOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(100.0)); // pure uptrend
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AroonOscillator {
|
||||
aroon: Aroon,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AroonOscillator {
|
||||
/// Construct a new Aroon Oscillator with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
aroon: Aroon::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.aroon.period()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AroonOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
|
||||
self.last = Some(osc);
|
||||
Some(osc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.aroon.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.aroon.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AroonOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(AroonOscillator::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` / `value` (57-64) and the
|
||||
/// Indicator-impl `name` body (90-92). `warmup_period` is covered
|
||||
/// already by `warmup_period_matches_aroon`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut osc = AroonOscillator::new(7).unwrap();
|
||||
assert_eq!(osc.period(), 7);
|
||||
assert_eq!(osc.name(), "AroonOscillator");
|
||||
assert_eq!(osc.value(), None);
|
||||
for i in 0..8 {
|
||||
osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
|
||||
}
|
||||
assert!(osc.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_plus_100() {
|
||||
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 + i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_yields_minus_100() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 - i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_within_minus_100_and_100() {
|
||||
let mut osc = AroonOscillator::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_matches_aroon() {
|
||||
let osc = AroonOscillator::new(7).unwrap();
|
||||
assert_eq!(osc.warmup_period(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
|
||||
.collect();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
|
||||
let mut b = AroonOscillator::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,22 @@ use crate::traits::Indicator;
|
||||
/// The first emitted value, by convention, appears after `period` candles: the
|
||||
/// first `period − 1` true-range values seed the Wilder average alongside the
|
||||
/// `period`-th, then the smoothed update begins.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Atr};
|
||||
///
|
||||
/// let mut indicator = Atr::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Atr {
|
||||
period: usize,
|
||||
@@ -100,11 +116,56 @@ mod tests {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Independent reference: Wilder ATR computed straight from the definition.
|
||||
fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec<Option<f64>> {
|
||||
let n = period as f64;
|
||||
let mut out = Vec::with_capacity(hlc.len());
|
||||
let mut trs: Vec<f64> = Vec::new();
|
||||
let mut avg: Option<f64> = None;
|
||||
let mut prev_close: Option<f64> = None;
|
||||
for &(h, l, cl) in hlc {
|
||||
let tr = match prev_close {
|
||||
None => h - l,
|
||||
Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()),
|
||||
};
|
||||
prev_close = Some(cl);
|
||||
if let Some(a) = avg {
|
||||
let na = (a * (n - 1.0) + tr) / n;
|
||||
avg = Some(na);
|
||||
out.push(Some(na));
|
||||
} else {
|
||||
trs.push(tr);
|
||||
if trs.len() == period {
|
||||
avg = Some(trs.iter().sum::<f64>() / n);
|
||||
out.push(avg);
|
||||
} else {
|
||||
out.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` / `value` (54-62) and the
|
||||
/// Indicator-impl `name` body (103-105). Existing tests inspect
|
||||
/// numeric ATR output but never query the metadata.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
assert_eq!(atr.period(), 14);
|
||||
assert_eq!(atr.name(), "ATR");
|
||||
assert_eq!(atr.value(), None);
|
||||
for _ in 0..14 {
|
||||
atr.update(c(11.0, 9.0, 10.0));
|
||||
}
|
||||
assert!(atr.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_on_period_th_candle() {
|
||||
let candles = vec![
|
||||
@@ -187,4 +248,37 @@ mod tests {
|
||||
assert!(v >= 0.0, "ATR must be non-negative: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
fn atr_matches_naive(
|
||||
period in 1usize..15,
|
||||
bars in proptest::collection::vec(
|
||||
(10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0),
|
||||
0..120,
|
||||
),
|
||||
) {
|
||||
// bars: (low, range, close_fraction) -> a valid OHLC candle.
|
||||
let hlc: Vec<(f64, f64, f64)> = bars
|
||||
.iter()
|
||||
.map(|&(low, range, frac)| (low + range, low, low + range * frac))
|
||||
.collect();
|
||||
let candles: Vec<Candle> = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect();
|
||||
let mut atr = Atr::new(period).unwrap();
|
||||
let got = atr.batch(&candles);
|
||||
let want = atr_naive(&hlc, period);
|
||||
proptest::prop_assert_eq!(got.len(), want.len());
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
match (g, w) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => proptest::prop_assert!(
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(1.0),
|
||||
"got={a} want={b}"
|
||||
),
|
||||
_ => proptest::prop_assert!(false, "warmup mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! ATR Bands.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Bands output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AtrBandsOutput {
|
||||
/// Upper band: `close + multiplier · ATR`.
|
||||
pub upper: f64,
|
||||
/// Middle band: the current close.
|
||||
pub middle: f64,
|
||||
/// Lower band: `close − multiplier · ATR`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
|
||||
///
|
||||
/// ```text
|
||||
/// upper = close + multiplier · ATR(period)
|
||||
/// lower = close − multiplier · ATR(period)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
|
||||
/// centerline is the *raw close* rather than a smoothed average — the band
|
||||
/// rides the price tick-for-tick. This is the standard volatility-targeting
|
||||
/// envelope traders use to set initial stop-loss and profit targets: an entry
|
||||
/// at the close sets a `multiplier · ATR` stop and the symmetric target
|
||||
/// without ever needing to wait for a moving average to warm up.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AtrBands, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..30 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrBands {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
}
|
||||
|
||||
impl AtrBands {
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
|
||||
/// invalid inputs.
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(period)?,
|
||||
multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured ATR period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.atr.period()
|
||||
}
|
||||
|
||||
/// Configured ATR multiplier.
|
||||
pub const fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrBands {
|
||||
type Input = Candle;
|
||||
type Output = AtrBandsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
Some(AtrBandsOutput {
|
||||
upper: candle.close + self.multiplier * atr,
|
||||
middle: candle.close,
|
||||
lower: candle.close - self.multiplier * atr,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.atr.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(h: f64, l: f64, cl: f64) -> Candle {
|
||||
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_multiplier() {
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, 0.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, -1.0),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
assert!(matches!(
|
||||
AtrBands::new(14, f64::INFINITY),
|
||||
Err(Error::NonPositiveMultiplier)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ab = AtrBands::new(14, 3.0).unwrap();
|
||||
assert_eq!(ab.period(), 14);
|
||||
assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
|
||||
assert_eq!(ab.warmup_period(), 14);
|
||||
assert_eq!(ab.name(), "AtrBands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_collapses_bands() {
|
||||
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_above_middle_above_lower() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
c(m + 1.0, m - 1.0, m)
|
||||
})
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(14, 3.0).unwrap();
|
||||
for o in ab.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.upper >= o.middle);
|
||||
assert!(o.middle >= o.lower);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let mut a = AtrBands::new(10, 2.5).unwrap();
|
||||
let mut b = AtrBands::new(10, 2.5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
ab.batch(&candles);
|
||||
assert!(ab.is_ready());
|
||||
ab.reset();
|
||||
assert!(!ab.is_ready());
|
||||
assert_eq!(ab.update(candles[0]), None);
|
||||
}
|
||||
|
||||
/// Reference: with constant high-low spread of 2, ATR(period) converges to
|
||||
/// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
|
||||
#[test]
|
||||
fn reference_values_constant_spread() {
|
||||
// Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
|
||||
let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
|
||||
let mut ab = AtrBands::new(5, 3.0).unwrap();
|
||||
let out = ab.batch(&candles);
|
||||
assert!(out[0].is_none() && out[3].is_none());
|
||||
let v = out[4].unwrap();
|
||||
assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! ATR Trailing Stop.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::atr::Atr;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
|
||||
/// and ratchets in the direction of the trend.
|
||||
///
|
||||
/// ```text
|
||||
/// loss = multiplier · ATR
|
||||
///
|
||||
/// stop_t = max(stop_{t−1}, close − loss) while price holds above the stop
|
||||
/// = min(stop_{t−1}, close + loss) while price holds below the stop
|
||||
/// = close − loss on a fresh break above the stop
|
||||
/// = close + loss on a fresh break below the stop
|
||||
/// ```
|
||||
///
|
||||
/// While price stays on one side of the stop the level only ratchets toward
|
||||
/// price — up in an uptrend, down in a downtrend — never away from it. When a
|
||||
/// close crosses the stop the level snaps to the opposite side, `loss` away
|
||||
/// from the new close, flipping the trade. This is the trailing stop used by
|
||||
/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
|
||||
/// price (a long).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
|
||||
///
|
||||
/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtrTrailingStop {
|
||||
atr: Atr,
|
||||
multiplier: f64,
|
||||
atr_period: usize,
|
||||
prev_close: Option<f64>,
|
||||
prev_stop: Option<f64>,
|
||||
}
|
||||
|
||||
impl AtrTrailingStop {
|
||||
/// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
|
||||
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
|
||||
/// positive and finite.
|
||||
pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::NonPositiveMultiplier);
|
||||
}
|
||||
Ok(Self {
|
||||
atr: Atr::new(atr_period)?,
|
||||
multiplier,
|
||||
atr_period,
|
||||
prev_close: None,
|
||||
prev_stop: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A common configuration: `ATR(14)` with a `3.0` multiplier.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
|
||||
}
|
||||
|
||||
/// Configured `(atr_period, multiplier)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.atr_period, self.multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AtrTrailingStop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let atr = self.atr.update(candle)?;
|
||||
let loss = self.multiplier * atr;
|
||||
let close = candle.close;
|
||||
|
||||
let stop = match (self.prev_stop, self.prev_close) {
|
||||
(Some(prev_stop), Some(prev_close)) => {
|
||||
if close > prev_stop && prev_close > prev_stop {
|
||||
// Holding above the stop — ratchet it up only.
|
||||
(close - loss).max(prev_stop)
|
||||
} else if close < prev_stop && prev_close < prev_stop {
|
||||
// Holding below the stop — ratchet it down only.
|
||||
(close + loss).min(prev_stop)
|
||||
} else if close > prev_stop {
|
||||
// Fresh break above — place the stop below the new close.
|
||||
close - loss
|
||||
} else {
|
||||
// Fresh break below — place the stop above the new close.
|
||||
close + loss
|
||||
}
|
||||
}
|
||||
// First ATR-ready bar: seed the stop below price (a long).
|
||||
_ => close - loss,
|
||||
};
|
||||
|
||||
self.prev_close = Some(close);
|
||||
self.prev_stop = Some(stop);
|
||||
Some(stop)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.prev_close = None;
|
||||
self.prev_stop = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.atr_period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.prev_stop.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AtrTrailingStop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values_flat_market() {
|
||||
// Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
|
||||
// Seed stop = close - loss = 10 - 6 = 4, and it holds there.
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
|
||||
for v in ts.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_stop_ratchets_up_and_stays_below_price() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let emitted: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
for w in emitted.windows(2) {
|
||||
assert!(
|
||||
w[1].0 >= w[0].0 - 1e-9,
|
||||
"stop must not loosen in an uptrend"
|
||||
);
|
||||
}
|
||||
for &(stop, close) in &emitted {
|
||||
assert!(stop < close, "uptrend stop should sit below the close");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_flips_to_the_other_side_when_price_reverses() {
|
||||
let mut candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
// A steep decline drags price through the trailing stop.
|
||||
candles.extend((0..40).map(|i| {
|
||||
let base = 140.0 - 3.0 * i as f64;
|
||||
c(base + 1.0, base - 1.0, base, 40 + i)
|
||||
}));
|
||||
let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
|
||||
let paired: Vec<(f64, f64)> = ts
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.zip(candles.iter())
|
||||
.filter_map(|(o, c)| o.map(|v| (v, c.close)))
|
||||
.collect();
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop < close),
|
||||
"expected a long stretch with the stop below price"
|
||||
);
|
||||
assert!(
|
||||
paired.iter().any(|&(stop, close)| stop > close),
|
||||
"expected the stop to flip above price after the reversal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
|
||||
let out = ts.batch(&candles);
|
||||
assert_eq!(ts.warmup_period(), 8);
|
||||
for (i, v) in out.iter().enumerate().take(7) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(AtrTrailingStop::new(0, 3.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, 0.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, -1.0).is_err());
|
||||
assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `params` (77-79) and the Indicator-impl
|
||||
/// `name` body (130-132). `warmup_period` is exercised elsewhere.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = AtrTrailingStop::classic();
|
||||
let (atr_p, mult) = s.params();
|
||||
assert_eq!(atr_p, 14);
|
||||
assert!((mult - 3.0).abs() < 1e-12);
|
||||
assert_eq!(s.name(), "AtrTrailingStop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
c(base + 1.0, base - 1.0, base, i)
|
||||
})
|
||||
.collect();
|
||||
let mut ts = AtrTrailingStop::classic();
|
||||
ts.batch(&candles);
|
||||
assert!(ts.is_ready());
|
||||
ts.reset();
|
||||
assert!(!ts.is_ready());
|
||||
assert_eq!(ts.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AtrTrailingStop::classic();
|
||||
let mut b = AtrTrailingStop::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Rolling lag-`k` autocorrelation.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
|
||||
///
|
||||
/// Over the trailing window the Pearson correlation between the series and
|
||||
/// itself shifted by `lag` is computed:
|
||||
///
|
||||
/// ```text
|
||||
/// y_i for i = 0..period − 1
|
||||
/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )²
|
||||
/// ```
|
||||
///
|
||||
/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a
|
||||
/// perfect alternation. Values near `0` mean the series at `t` and `t −
|
||||
/// lag` carry no linear relationship — a clean white-noise proxy. The
|
||||
/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
|
||||
/// flags a cycle of that length) or testing whether returns are
|
||||
/// uncorrelated (a key efficient-markets diagnostic).
|
||||
///
|
||||
/// `period` must be strictly greater than `lag` so that at least two
|
||||
/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
|
||||
/// indicator returns `0` rather than dividing by zero.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Autocorrelation, Indicator};
|
||||
///
|
||||
/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Autocorrelation {
|
||||
period: usize,
|
||||
lag: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl Autocorrelation {
|
||||
/// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
|
||||
pub fn new(period: usize, lag: usize) -> Result<Self> {
|
||||
if lag == 0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation lag must be >= 1",
|
||||
});
|
||||
}
|
||||
if period <= lag {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation needs period > lag",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
lag,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured lag.
|
||||
pub const fn lag(&self) -> usize {
|
||||
self.lag
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Autocorrelation {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, value: f64) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// ACF over the current window with a single inner pass. The window is
|
||||
// small relative to a typical input stream so the O(period) per-bar
|
||||
// cost is bounded by the user-chosen `period`; the constant factor
|
||||
// is dominated by two adds and one multiply per element.
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut denom = 0.0;
|
||||
let mut numer = 0.0;
|
||||
// The window is a deque; index via slices for cache-friendly access.
|
||||
let (front, back) = self.window.as_slices();
|
||||
let get = |i: usize| -> f64 {
|
||||
if i < front.len() {
|
||||
front[i]
|
||||
} else {
|
||||
back[i - front.len()]
|
||||
}
|
||||
};
|
||||
for i in 0..self.period {
|
||||
let d = get(i) - mean;
|
||||
denom += d * d;
|
||||
}
|
||||
let lag = self.lag;
|
||||
for i in 0..(self.period - lag) {
|
||||
numer += (get(i) - mean) * (get(i + lag) - mean);
|
||||
}
|
||||
if denom == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(numer / denom)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Autocorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_lag() {
|
||||
assert!(Autocorrelation::new(10, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_lag_geq_period() {
|
||||
assert!(Autocorrelation::new(5, 5).is_err());
|
||||
assert!(Autocorrelation::new(5, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let a = Autocorrelation::new(14, 2).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.lag(), 2);
|
||||
assert_eq!(a.warmup_period(), 14);
|
||||
assert_eq!(a.name(), "Autocorrelation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
for v in a.batch(&[42.0; 30]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_series_lag_one_is_strongly_negative() {
|
||||
// [−1, 1, −1, 1, …] alternates each step.
|
||||
let prices: Vec<f64> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
|
||||
.collect();
|
||||
let mut a = Autocorrelation::new(10, 1).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < -0.5,
|
||||
"alternating series should be strongly negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_series_is_strongly_positive_at_period() {
|
||||
// A series that repeats every 4 steps must have ACF(4) ≈ +1.
|
||||
let pattern = [1.0, 2.0, 3.0, 4.0];
|
||||
let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
|
||||
let mut a = Autocorrelation::new(16, 4).unwrap();
|
||||
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.5,
|
||||
"period-4 repeat should ACF(4) > 0.5, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut a = Autocorrelation::new(5, 1).unwrap();
|
||||
a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
|
||||
let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
|
||||
let mut b = Autocorrelation::new(14, 2).unwrap();
|
||||
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user