Wickra 0.1.0: streaming-first technical indicators

A multi-language technical analysis library: 25 indicators across trend,
momentum, volatility, and volume families, every one a state machine with
O(1) per-tick updates. Batch evaluation is provided by a blanket extension
trait over the streaming primitive, so live trading bots and historical
backtests run the same code path.

What ships in this initial drop:

  crates/wickra-core   - 25 indicators, Indicator/BatchExt/Chain traits,
                          OHLCV types with validation; 171 unit tests,
                          property tests, Wilder/Bollinger textbook tests.
  crates/wickra        - top-level facade + criterion benches for every
                          indicator at 1K/10K/100K series sizes.
  crates/wickra-data   - streaming CSV reader, tick-to-candle aggregator,
                          multi-timeframe resampler, Binance Spot kline
                          WebSocket adapter behind feature live-binance;
                          11 unit + 1 doctest.
  bindings/python      - PyO3 + maturin, NumPy I/O, type stubs (.pyi),
                          56 pytest tests including streaming==batch
                          equivalence, Wilder reference values, lifecycle.
  bindings/node        - napi-rs native module, TypeScript .d.ts
                          auto-generated, 7 node --test cases.
  bindings/wasm        - wasm-bindgen ES module for browser/bundler/Node;
                          interactive HTML demo at examples/index.html.
  examples/            - Python and Rust scripts: backtest, live trading,
                          parallel multi-asset, multi-timeframe, Binance.
  benchmarks/          - cross-library comparison against TA-Lib,
                          pandas-ta, finta, talipp; Wickra wins every
                          category by 11-1030x (batch) and 17x+ streaming.
  .github/workflows/   - CI matrix (Rust + Python + Node + WASM on
                          Linux/macOS/Windows), release pipeline for
                          PyPI wheels and npm.

Indicators (25):
  Trend       SMA EMA WMA DEMA TEMA HMA KAMA
  Momentum    RSI MACD Stochastic CCI ROC WilliamsR ADX MFI TRIX
              AwesomeOscillator Aroon
  Volatility  BollingerBands ATR Keltner Donchian PSAR
  Volume      OBV VWAP (cumulative + rolling)

cargo clippy --workspace --all-targets -D warnings is clean. License: Apache-2.0.
This commit is contained in:
kingchenc
2026-05-21 17:50:45 +02:00
commit 3be267cb03
81 changed files with 14453 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
rust:
name: Rust ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (workspace, all targets)
run: cargo clippy -p wickra-core -p wickra -p wickra-data -p wickra-wasm --all-targets -- -D warnings
- name: Build
run: cargo build -p wickra-core -p wickra -p wickra-data --verbose
- name: Tests (default features)
run: cargo test -p wickra-core -p wickra -p wickra-data --verbose
- name: Tests (live-binance feature)
run: cargo test -p wickra-data --features live-binance --verbose
- name: Compile benches (smoke)
run: cargo build -p wickra --benches --verbose
- name: Compile examples
run: |
cargo build -p wickra --example backtest
cargo build -p wickra-data --example live_binance --features live-binance
python:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.9", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dev dependencies
run: |
python -m pip install --upgrade pip
python -m pip install maturin pytest numpy hypothesis
- name: Build extension in release mode
working-directory: bindings/python
run: maturin develop --release
- name: Run Python tests
working-directory: bindings/python
run: pytest -v
wasm:
name: WASM build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain (with wasm target)
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install wasm-pack
uses: jetli/wasm-pack-action@v0.4.0
- name: Build WASM package
run: wasm-pack build bindings/wasm --target web --release --features panic-hook
- name: Verify generated artefacts
run: |
test -f bindings/wasm/pkg/wickra_wasm.js
test -f bindings/wasm/pkg/wickra_wasm_bg.wasm
test -f bindings/wasm/pkg/wickra_wasm.d.ts
node:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ["18", "20"]
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install Node dependencies
working-directory: bindings/node
run: npm install
- name: Build native module
working-directory: bindings/node
run: npx napi build --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 extension
working-directory: bindings/python
run: maturin develop --release
- 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
+84
View File
@@ -0,0 +1,84 @@
name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-wheels:
name: Wheels ${{ matrix.os }} ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64
- os: ubuntu-latest
target: aarch64
- os: macos-latest
target: x86_64
- os: macos-latest
target: aarch64
- os: windows-latest
target: x64
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
working-directory: bindings/python
target: ${{ matrix.target }}
args: --release --strip --out dist
sccache: "true"
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.target }}
path: bindings/python/dist/
sdist:
name: Source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
working-directory: bindings/python
command: sdist
args: --out dist
- uses: actions/upload-artifact@v4
with:
name: sdist
path: bindings/python/dist/
publish:
name: Publish to PyPI
needs: [build-wheels, sdist]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
+56
View File
@@ -0,0 +1,56 @@
# Rust
/target/
**/target/
Cargo.lock.bak
*.pdb
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
*.egg-info/
.pytest_cache/
.venv/
venv/
.python-version
dist/
build/
wheels/
*.whl
# Editor / OS
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# Maturin
target/wheels/
# Benchmarks
criterion/
# Coverage
*.profraw
lcov.info
tarpaulin-report.html
# Local config that should not be committed
*.local.toml
# Node binding artifacts
bindings/node/node_modules/
bindings/node/*.node
bindings/node/index.d.ts
bindings/node/npm-debug.log*
package-lock.json
# WASM build output
bindings/wasm/pkg/
# Python venv
.venv/
venv/
Generated
+2179
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
[workspace]
resolver = "2"
members = [
"crates/wickra-core",
"crates/wickra",
"crates/wickra-data",
"bindings/python",
"bindings/wasm",
"bindings/node",
]
exclude = []
[workspace.package]
version = "0.1.0"
authors = ["Wickra Contributors"]
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
repository = "https://github.com/wickra/wickra"
homepage = "https://github.com/wickra/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.0" }
thiserror = "2"
rayon = "1.10"
# Testing
proptest = "1.5"
approx = "0.5"
criterion = { version = "0.5", features = ["html_reports"] }
# Python binding
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
numpy = "0.22"
[workspace.lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
# Pedantic exceptions for an indicator library that uses floats and small functions everywhere.
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
cast_precision_loss = "allow"
cast_possible_truncation = "allow"
cast_sign_loss = "allow"
similar_names = "allow"
float_cmp = "allow"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[profile.bench]
opt-level = 3
lto = "fat"
codegen-units = 1
debug = false
[profile.dev]
opt-level = 0
debug = true
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may accept and offer the acceptance of
warranty, support, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting
such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and
only if You agree to indemnify, defend, and hold each Contributor
harmless for any liability incurred by, or claims asserted against,
such Contributor by reason of your accepting any such warranty or
support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Wickra Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions
and limitations under the License.
+216
View File
@@ -0,0 +1,216 @@
# Wickra
**Streaming-first technical indicators. Install with `pip install wickra` — 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 a state
machine that updates in O(1) per new data point, so live trading bots and
historical backtests share the exact same implementation.
```python
import numpy as np
import wickra as ta
# Batch: classic TA-Lib-style usage
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Streaming: same indicator, fed tick by tick
rsi = ta.RSI(14)
for price in live_feed:
value = rsi.update(price) # O(1) — no recomputation over history
if value is not None and value > 70:
print("overbought")
```
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
| Library | Install pain | Streaming | Multi-language | Active |
|--------------------|-----------------|-----------|----------------|--------|
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
| **Wickra** | **clean** | **yes** | **Python+Node+WASM+Rust** | **yes** |
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
## Benchmark: how much faster is "streaming-first"?
Reproduced on this machine with `python -m benchmarks.compare_libraries`.
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
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) |
### Streaming — per-tick latency after seeding with 2 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | Wickra (per tick) | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.07 µs ★** | 1.16 µs (17.5× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
## Indicators in 0.1.0
25 streaming-first indicators across four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests.
| Family | Indicators |
|-------------|-----------|
| Trend | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA |
| Momentum | RSI (Wilder), MACD, Stochastic, CCI, ROC, Williams %R, ADX (+DI/-DI), MFI, TRIX, Awesome Oscillator, Aroon |
| Volatility | Bollinger Bands, ATR, Keltner Channels, Donchian Channels, Parabolic SAR |
| Volume | OBV, VWAP (cumulative + rolling) |
Adding a new indicator means implementing one trait in Rust; all four bindings
inherit it automatically.
## Languages
| Binding | Install | Example |
|-------------------|-----------------------------------------------|---------|
| Python (PyO3) | `pip install wickra` | `examples/python/backtest.py` |
| Node.js (napi-rs) | `npm install @wickra/wickra` | `bindings/node/__tests__/smoke.test.js` |
| Browser / WASM | `wasm-pack build bindings/wasm --target web` | `bindings/wasm/examples/index.html` |
| Rust | `cargo add wickra` | `examples/rust/backtest.rs` |
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
memory-safe implementation.
## Rust API
```rust
use wickra::{Indicator, BatchExt, Chain, Ema, Rsi, Sma};
// Streaming or batch — same trait, same code.
let mut sma = Sma::new(14)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let mut rsi = Rsi::new(14)?;
for price in live_feed {
if let Some(v) = rsi.update(price) {
println!("RSI = {v}");
}
}
// Compose indicators: RSI(7) on top of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
chain.update(price);
```
## Live data sources
`wickra-data` (separate crate, opt-in) ships:
- A streaming OHLCV **CSV reader**.
- A **tick-to-candle aggregator** with arbitrary timeframes.
- A **candle resampler** for multi-timeframe analysis (1m → 5m → 1h on the fly).
- A **Binance Spot WebSocket** kline adapter (feature `live-binance`).
```rust
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
let mut stream = BinanceKlineStream::connect(&["BTCUSDT".into()], Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
while let Some(event) = stream.next_event().await? {
if event.is_closed {
if let Some(v) = rsi.update(event.candle.close) {
println!("RSI = {v:.2}");
}
}
}
```
A Python live-trading example using the public `websockets` package lives at
`examples/python/live_trading.py`.
## Project layout
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 25 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io)
│ └── 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/ backtest, live Binance
├── benches/ cargo bench targets
└── .github/workflows/ CI and release pipelines
```
## Building everything from source
```bash
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
maturin develop --release
pytest
# WASM binding (requires wasm-pack + wasm32-unknown-unknown target)
wasm-pack build bindings/wasm --target web --release --features panic-hook
# Node binding (requires @napi-rs/cli)
cd bindings/node && npm install && npm run build && npm test
```
## Test counts
- `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`.
## License
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE).
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "wickra-node"
description = "Node.js bindings for the Wickra streaming-first technical indicators library."
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
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
publish = false
[lib]
crate-type = ["cdylib"]
[lints]
workspace = true
[dependencies]
wickra-core = { workspace = true }
napi = { version = "2.16", features = ["napi8"] }
napi-derive = "2.16"
[build-dependencies]
napi-build = "2"
+45
View File
@@ -0,0 +1,45 @@
# @wickra/wickra
Node.js bindings for the Wickra streaming-first technical indicators library.
## Install
Once published, install per platform via the precompiled native package:
```bash
npm install @wickra/wickra
```
## Build from source
```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
```js
import { SMA, RSI, MACD, version } from '@wickra/wickra';
console.log('wickra', version());
// 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');
}
```
See `index.d.ts` for the full TypeScript surface.
+82
View File
@@ -0,0 +1,82 @@
// Smoke tests for the Wickra Node bindings.
//
// Run with:
// cd bindings/node && npm install && npm run build && npm test
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
test('version is non-empty', () => {
assert.ok(typeof wickra.version() === 'string');
assert.ok(wickra.version().length > 0);
});
test('SMA batch matches reference values', () => {
const sma = new wickra.SMA(3);
const out = sma.batch([2, 4, 6, 8, 10]);
assert.ok(Number.isNaN(out[0]));
assert.ok(Number.isNaN(out[1]));
assert.equal(out[2], 4);
assert.equal(out[3], 6);
assert.equal(out[4], 8);
});
test('RSI pure uptrend yields 100', () => {
const rsi = new wickra.RSI(14);
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
const out = rsi.batch(prices);
for (let i = 14; i < out.length; i++) {
assert.equal(out[i], 100);
}
});
test('streaming and batch agree on EMA', () => {
const prices = Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i * 0.3) * 5);
const batch = new wickra.EMA(14).batch(prices);
const ema = new wickra.EMA(14);
const streamed = prices.map((p) => {
const v = ema.update(p);
return v === null || v === undefined ? NaN : v;
});
for (let i = 0; i < prices.length; i++) {
if (Number.isNaN(batch[i])) {
assert.ok(Number.isNaN(streamed[i]));
} else {
assert.ok(Math.abs(batch[i] - streamed[i]) < 1e-9);
}
}
});
test('MACD returns macd/signal/histogram object', () => {
const macd = new wickra.MACD(12, 26, 9);
let value = null;
for (let i = 1; i <= 60; i++) {
value = macd.update(i);
}
assert.ok(value);
assert.equal(typeof value.macd, 'number');
assert.equal(typeof value.signal, 'number');
assert.equal(typeof value.histogram, 'number');
assert.ok(Math.abs(value.histogram - (value.macd - value.signal)) < 1e-9);
});
test('ATR batch shape', () => {
const high = Array.from({ length: 30 }, () => 11);
const low = Array.from({ length: 30 }, () => 9);
const close = Array.from({ length: 30 }, () => 10);
const out = new wickra.ATR(14).batch(high, low, close);
assert.equal(out.length, 30);
// Once seeded, ATR is the constant TR of 2.
for (let i = 13; i < 30; i++) {
assert.ok(Math.abs(out[i] - 2) < 1e-9);
}
});
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);
});
+5
View File
@@ -0,0 +1,5 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable */
/* prettier-ignore */
// This loader is generated by `napi build --platform` at publish time. For
// editable development just `require` the local debug binary that napi places
// at the package root.
const { join } = require('node:path');
const { existsSync } = require('node:fs');
const { platform, arch } = process;
function loadNative() {
// Try precompiled per-platform binary first (published wheels do this).
const candidates = [
`./wickra.${platform}-${arch}.node`,
`./wickra.${platform}-${arch}-musl.node`,
'./wickra.node',
];
for (const c of candidates) {
const p = join(__dirname, c);
if (existsSync(p)) {
return require(p);
}
}
throw new Error(
`Wickra: no precompiled binary found for ${platform}-${arch}. ` +
'Build from source with `napi build --release` or install a platform-specific package.'
);
}
module.exports = loadNative();
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@wickra/wickra",
"version": "0.1.0",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"main": "index.js",
"types": "index.d.ts",
"license": "Apache-2.0",
"keywords": [
"trading",
"indicators",
"technical-analysis",
"ta-lib",
"finance",
"streaming",
"rust"
],
"repository": {
"type": "git",
"url": "https://github.com/wickra/wickra"
},
"files": [
"index.js",
"index.d.ts",
"npm",
"*.node"
],
"napi": {
"name": "wickra",
"triples": {
"defaults": true,
"additional": [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"i686-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"aarch64-apple-darwin",
"aarch64-linux-android",
"x86_64-unknown-freebsd",
"aarch64-unknown-linux-musl",
"aarch64-pc-windows-msvc"
]
}
},
"engines": {
"node": ">= 16"
},
"scripts": {
"build": "napi build --platform --release",
"build:debug": "napi build --platform",
"prepublishOnly": "napi prepublish -t npm",
"test": "node --test __tests__/"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
}
}
+723
View File
@@ -0,0 +1,723 @@
//! Node.js bindings for Wickra via napi-rs.
//!
//! Build with:
//! ```text
//! cd bindings/node && npm install && npm run build
//! ```
//!
//! Then `require("@wickra/wickra")` from Node.
#![allow(clippy::needless_pass_by_value)]
#![allow(missing_debug_implementations)] // napi-derive auto-generates the Node-facing types.
#![allow(clippy::unused_self)]
#![allow(clippy::missing_const_for_fn)]
use napi::Error as NapiError;
use napi::Status;
use napi_derive::napi;
use wickra_core as wc;
use wickra_core::{BatchExt, Indicator};
fn map_err(e: wc::Error) -> NapiError {
NapiError::new(Status::InvalidArg, e.to_string())
}
/// Helper for constructors. `#[napi(constructor)]` in napi-rs 2.16 only accepts
/// an infallible `Self` return, so we clamp parameters to the smallest valid value
/// (which only matters for the pathological `period = 0` case) and then `expect`
/// the rest, which can only fail for invariants that hold by construction.
fn must<T>(r: Result<T, wc::Error>) -> T {
r.expect("wickra: invalid indicator parameters")
}
/// Clamp a period parameter so the underlying indicator never sees zero. JS
/// callers who pass `0` get a window of `1` instead of a thrown exception —
/// effectively a pass-through indicator that still produces valid outputs.
const fn clamp_period(p: u32) -> usize {
if p == 0 {
1
} else {
p as usize
}
}
fn flatten(v: Vec<Option<f64>>) -> Vec<f64> {
v.into_iter().map(|x| x.unwrap_or(f64::NAN)).collect()
}
/// Library version (matches the Rust crate version).
#[napi]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
// ============================== Scalar indicators ==============================
macro_rules! node_scalar_indicator {
($wrapper:ident, $node_name:literal, $rust_ty:ty) => {
#[napi(js_name = $node_name)]
pub struct $wrapper {
inner: $rust_ty,
}
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(<$rust_ty>::new(clamp_period(period))),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
};
}
node_scalar_indicator!(SmaNode, "SMA", wc::Sma);
node_scalar_indicator!(EmaNode, "EMA", wc::Ema);
node_scalar_indicator!(WmaNode, "WMA", wc::Wma);
node_scalar_indicator!(RsiNode, "RSI", wc::Rsi);
node_scalar_indicator!(DemaNode, "DEMA", wc::Dema);
node_scalar_indicator!(TemaNode, "TEMA", wc::Tema);
node_scalar_indicator!(HmaNode, "HMA", wc::Hma);
node_scalar_indicator!(RocNode, "ROC", wc::Roc);
node_scalar_indicator!(TrixNode, "TRIX", wc::Trix);
// ============================== MACD ==============================
/// MACD triple: macd line, signal line, histogram.
#[napi(object)]
pub struct MacdValue {
pub macd: f64,
pub signal: f64,
pub histogram: f64,
}
#[napi(js_name = "MACD")]
pub struct MacdNode {
inner: wc::MacdIndicator,
}
#[napi]
impl MacdNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32, signal: u32) -> Self {
Self {
inner: must(wc::MacdIndicator::new(
fast as usize,
slow as usize,
signal as usize,
)),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MacdValue> {
self.inner.update(value).map(|o| MacdValue {
macd: o.macd,
signal: o.signal,
histogram: o.histogram,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
}
// ============================== Bollinger ==============================
#[napi(object)]
pub struct BollingerValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
pub stddev: f64,
}
#[napi(js_name = "BollingerBands")]
pub struct BollingerNode {
inner: wc::BollingerBands,
}
#[napi]
impl BollingerNode {
#[napi(constructor)]
pub fn new(period: u32, multiplier: f64) -> Self {
Self {
inner: must(wc::BollingerBands::new(period as usize, multiplier)),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<BollingerValue> {
self.inner.update(value).map(|o| BollingerValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
stddev: o.stddev,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 4];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 4] = o.upper;
out[i * 4 + 1] = o.middle;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.stddev;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
}
// ============================== Candle-input helpers ==============================
fn cnd(h: f64, l: f64, c: f64, v: f64) -> napi::Result<wc::Candle> {
wc::Candle::new(c, h, l, c, v, 0).map_err(map_err)
}
#[napi(js_name = "ATR")]
pub struct AtrNode {
inner: wc::Atr,
}
#[napi]
impl AtrNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Atr::new(period as usize)),
}
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
}
#[napi(object)]
pub struct StochValue {
pub k: f64,
pub d: f64,
}
#[napi(js_name = "Stochastic")]
pub struct StochNode {
inner: wc::Stochastic,
}
#[napi]
impl StochNode {
#[napi(constructor)]
pub fn new(k_period: u32, d_period: u32) -> Self {
Self {
inner: must(wc::Stochastic::new(k_period as usize, d_period as usize)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.k;
out[i * 2 + 1] = o.d;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[napi(js_name = "OBV")]
pub struct ObvNode {
inner: wc::Obv,
}
impl Default for ObvNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl ObvNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::Obv::new(),
}
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[napi(object)]
pub struct AdxValue {
#[napi(js_name = "plusDi")]
pub plus_di: f64,
#[napi(js_name = "minusDi")]
pub minus_di: f64,
pub adx: f64,
}
#[napi(js_name = "ADX")]
pub struct AdxNode {
inner: wc::Adx,
}
#[napi]
impl AdxNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Adx::new(period as usize)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 3] = o.plus_di;
out[i * 3 + 1] = o.minus_di;
out[i * 3 + 2] = o.adx;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[napi(js_name = "CCI")]
pub struct CciNode {
inner: wc::Cci,
}
#[napi]
impl CciNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Cci::new(period as usize)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(js_name = "WilliamsR")]
pub struct WilliamsRNode {
inner: wc::WilliamsR,
}
#[napi]
impl WilliamsRNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::WilliamsR::new(period as usize)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(js_name = "MFI")]
pub struct MfiNode {
inner: wc::Mfi,
}
#[napi]
impl MfiNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Mfi::new(period as usize)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(js_name = "PSAR")]
pub struct PsarNode {
inner: wc::Psar,
}
#[napi]
impl PsarNode {
#[napi(constructor)]
pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Self {
Self {
inner: must(wc::Psar::new(af_start, af_step, af_max)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(object)]
pub struct KeltnerValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "Keltner")]
pub struct KeltnerNode {
inner: wc::Keltner,
}
#[napi]
impl KeltnerNode {
#[napi(constructor)]
pub fn new(ema_period: u32, atr_period: u32, multiplier: f64) -> Self {
Self {
inner: must(wc::Keltner::new(
ema_period as usize,
atr_period as usize,
multiplier,
)),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
}
#[napi(object)]
pub struct DonchianValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "Donchian")]
pub struct DonchianNode {
inner: wc::Donchian,
}
#[napi]
impl DonchianNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Donchian::new(period as usize)),
}
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
}
#[napi(js_name = "VWAP")]
pub struct VwapNode {
inner: wc::Vwap,
}
impl Default for VwapNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl VwapNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::Vwap::new(),
}
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(js_name = "AwesomeOscillator")]
pub struct AoNode {
inner: wc::AwesomeOscillator,
}
#[napi]
impl AoNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32) -> Self {
Self {
inner: must(wc::AwesomeOscillator::new(fast as usize, slow as usize)),
}
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], low[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
}
#[napi(object)]
pub struct AroonValue {
pub up: f64,
pub down: f64,
}
#[napi(js_name = "Aroon")]
pub struct AroonNode {
inner: wc::Aroon,
}
#[napi]
impl AroonNode {
#[napi(constructor)]
pub fn new(period: u32) -> Self {
Self {
inner: must(wc::Aroon::new(period as usize)),
}
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 2] = o.up;
out[i * 2 + 1] = o.down;
}
}
Ok(out)
}
}
#[napi(js_name = "KAMA")]
pub struct KamaNode {
inner: wc::Kama,
}
#[napi]
impl KamaNode {
#[napi(constructor)]
pub fn new(er_period: u32, fast: u32, slow: u32) -> Self {
Self {
inner: must(wc::Kama::new(
er_period as usize,
fast as usize,
slow as usize,
)),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "wickra-python"
description = "Python bindings for the Wickra streaming-first technical indicators library."
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
publish = false
[lib]
name = "_wickra"
crate-type = ["cdylib"]
[lints]
workspace = true
[dependencies]
wickra-core = { workspace = true }
pyo3 = { workspace = true }
numpy = { workspace = true }
@@ -0,0 +1,520 @@
"""Cross-library benchmark: Wickra vs TA-Lib vs pandas-ta vs talipp vs finta.
Runs each library through identical batch and streaming workloads, then prints a
table of timings. Libraries that are not installed are skipped automatically, so
the script always produces output regardless of the local environment.
Usage::
python -m benchmarks.compare_libraries
python -m benchmarks.compare_libraries --size 50000 --streaming-window 5000
Notes:
- "Batch" means computing the indicator over the whole price series in one call,
which is what classic libraries support.
- "Streaming" simulates live trading: after seeding with ``streaming_window``
historical bars, we keep appending one new price and recomputing the latest
indicator value. Libraries without an incremental API have to recompute the
whole indicator on every tick; Wickra updates in O(1). This is the gap the
library was built to expose.
"""
from __future__ import annotations
import argparse
import contextlib
import importlib
import statistics
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
import numpy as np
# --------------------------------------------------------------------------- #
# Library availability detection
# --------------------------------------------------------------------------- #
def _try_import(name: str):
try:
return importlib.import_module(name)
except Exception:
return None
TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
# --------------------------------------------------------------------------- #
# Timing helpers
# --------------------------------------------------------------------------- #
@dataclass
class Sample:
library: str
indicator: str
mode: str
seconds: float
iterations: int
@property
def per_iter_us(self) -> float:
return (self.seconds / self.iterations) * 1_000_000
def time_call(fn: Callable[[], None], iterations: int) -> float:
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
fn() # one warmup call to populate caches
start = time.perf_counter()
for _ in range(iterations):
fn()
return time.perf_counter() - start
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
rng = np.random.default_rng(seed)
walk = rng.standard_normal(n) * 0.4
return 100.0 + np.cumsum(walk)
def gen_ohlc(n: int, seed: int = 0xC0FFEE) -> tuple:
close = gen_prices(n, seed)
spread = 0.5 + np.abs(np.sin(np.arange(n) * 0.07))
high = close + spread
low = close - spread
volume = np.full(n, 1_000.0)
return high, low, close, volume
# --------------------------------------------------------------------------- #
# Per-library indicator runners. Each returns ``None`` to skip when unavailable.
# --------------------------------------------------------------------------- #
def wickra_sma_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.SMA(20).batch(prices)
def talib_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.SMA(prices, timeperiod=20))
def pandas_ta_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.sma(s, length=20)
def finta_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.SMA(df, period=20)
def talipp_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
# talipp's SMA accepts an initial list of values
from talipp.indicators import SMA # type: ignore
return lambda: SMA(period=20, input_values=list(prices))
def wickra_rsi_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.RSI(14).batch(prices)
def talib_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.RSI(prices, timeperiod=14))
def pandas_ta_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.rsi(s, length=14)
def finta_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.RSI(df, period=14)
def talipp_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import RSI # type: ignore
return lambda: RSI(period=14, input_values=list(prices))
def wickra_bollinger_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.BollingerBands(20, 2.0).batch(prices)
def wickra_ema_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.EMA(20).batch(prices)
def talib_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.EMA(prices, timeperiod=20))
def pandas_ta_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.ema(s, length=20)
def finta_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.EMA(df, period=20)
def talipp_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
return lambda: EMA(period=20, input_values=list(prices))
def wickra_macd_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.MACD().batch(prices)
def talib_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.MACD(prices))
def pandas_ta_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.macd(s)
def finta_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.MACD(df)
def talipp_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
return lambda: MACD(fast_period=12, slow_period=26, signal_period=9, input_values=list(prices))
def wickra_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.ATR(14).batch(high, low, close)
def talib_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.ATR(high, low, close, timeperiod=14))
def finta_atr_batch(_high: np.ndarray, _low: np.ndarray, _close: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": _close, "high": _high, "low": _low, "close": _close, "volume": np.ones_like(_close)})
return lambda: FINTA.TA.ATR(df, period=14)
def talipp_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import ATR # type: ignore
from talipp.ohlcv import OHLCV
bars = [OHLCV(open=c, high=h, low=l, close=c, volume=1.0, time=i) for i, (h, l, c) in enumerate(zip(high, low, close))]
return lambda: ATR(period=14, input_values=bars)
def talib_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIB is None:
return None
return lambda: TALIB.BBANDS(prices, timeperiod=20, nbdevup=2, nbdevdn=2)
def pandas_ta_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.bbands(s, length=20, std=2.0)
def finta_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0)
def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
def wickra_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
rsi = WICKRA.RSI(14)
rsi.batch(seed) # warm up
for p in live:
rsi.update(float(p))
return run
def talib_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIB is None:
return None
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
TALIB.RSI(np.asarray(history), timeperiod=14)
return run
def pandas_ta_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
PANDAS_TA.rsi(PD.Series(history), length=14)
return run
def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import RSI # type: ignore
def run() -> None:
rsi = RSI(period=14, input_values=list(seed))
for p in live:
rsi.add(float(p))
return run
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
BATCH_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
("EMA(20)", [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
("RSI(14)", [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
]
OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming),
]),
]
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in BATCH_INDICATORS:
for lib_name, factory in libs:
runner = factory(prices)
if runner is None:
continue
secs = time_call(runner, iterations)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_ohlc(
high: np.ndarray,
low: np.ndarray,
close: np.ndarray,
iterations: int,
) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in OHLC_INDICATORS:
for lib_name, factory in libs:
runner = factory(high, low, close)
if runner is None:
continue
secs = time_call(runner, iterations)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) -> List[Sample]:
out: List[Sample] = []
seed = prices[:streaming_window]
live = prices[streaming_window:]
if len(live) == 0:
return out
for indicator_name, libs in STREAMING_INDICATORS:
for lib_name, factory in libs:
runner = factory(seed, live)
if runner is None:
continue
secs = time_call(runner, iterations)
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
sample.iterations = iterations * len(live) # per-tick normalization
out.append(sample)
return out
def render_table(rows: List[Sample]) -> str:
if not rows:
return "(no results)"
grouped: Dict[str, List[Sample]] = {}
for r in rows:
key = f"{r.mode} | {r.indicator}"
grouped.setdefault(key, []).append(r)
lines: List[str] = []
lines.append("")
lines.append("Reading the tables: lower µs/op = faster. The 'vs Wickra' column says")
lines.append("how many times slower (or faster) the other library is compared to Wickra.")
for key, samples in grouped.items():
baseline = next((s for s in samples if s.library == "Wickra"), samples[0])
base = baseline.per_iter_us
lines.append("")
lines.append(key)
lines.append("-" * len(key))
lines.append(
f"{'library':<14} {'µs/op':>14} {'vs Wickra':>22} {'verdict':<10}"
)
winner = min(samples, key=lambda x: x.per_iter_us)
for s in sorted(samples, key=lambda x: x.per_iter_us):
ratio = s.per_iter_us / base if base > 0 else float("nan")
if s.library == "Wickra":
comparison = "(reference)"
elif s.per_iter_us > base:
comparison = f"{ratio:>5.2f}x slower"
else:
comparison = f"{base / s.per_iter_us:>5.2f}x faster"
verdict = "★ winner" if s is winner else ""
lines.append(
f"{s.library:<14} {s.per_iter_us:>14.3f} {comparison:>22} {verdict:<10}"
)
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
parser.add_argument(
"--streaming-window",
type=int,
default=5_000,
help="number of historical prices to seed before the live ticks begin",
)
parser.add_argument(
"--streaming-iterations",
type=int,
default=3,
help="repetitions of the streaming workload (each iteration replays all live ticks)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
prices = gen_prices(args.size)
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
print(f"Comparing against: {', '.join(available) if available else '(no peer libraries installed; install [bench] extra)'}")
print(f"Series length: {args.size} • batch iterations: {args.iterations}")
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
high, low, close, _ = gen_ohlc(args.size)
batch_rows = run_batch(prices, args.iterations)
ohlc_rows = run_ohlc(high, low, close, args.iterations)
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
print(render_table(batch_rows + ohlc_rows + streaming_rows))
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["maturin>=1.7,<2.0"]
build-backend = "maturin"
[project]
name = "wickra"
version = "0.1.0"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "../../README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Rust",
"Topic :: Office/Business :: Financial :: Investment",
"Topic :: Scientific/Engineering :: Mathematics",
]
dependencies = [
"numpy>=1.22",
]
[project.optional-dependencies]
test = [
"pytest>=7",
"numpy>=1.22",
"hypothesis>=6",
]
bench = [
"pytest-benchmark>=4",
"TA-Lib; platform_system != 'Windows'",
"pandas-ta>=0.3.14b",
"talipp>=2",
"finta>=1.3",
"pandas>=2",
"numpy>=1.22",
]
[project.urls]
Homepage = "https://github.com/wickra/wickra"
Repository = "https://github.com/wickra/wickra"
Issues = "https://github.com/wickra/wickra/issues"
[tool.maturin]
manifest-path = "Cargo.toml"
python-source = "python"
module-name = "wickra._wickra"
features = ["pyo3/extension-module"]
strip = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = ["error"]
+82
View File
@@ -0,0 +1,82 @@
"""Wickra: streaming-first technical indicators.
Every indicator is available both in streaming mode (call ``update(value)`` per
new data point) and batch mode (call ``batch(numpy_array)`` over a full series).
Warmup positions in batch output are returned as ``NaN`` so the shape always
matches the input.
Example::
import numpy as np
import wickra as ta
prices = np.linspace(100, 200, 1000)
rsi = ta.RSI(14)
values = rsi.batch(prices) # numpy array, NaN during warmup
# Or streaming:
rsi = ta.RSI(14)
for p in prices:
v = rsi.update(p) # None during warmup, then float
"""
from __future__ import annotations
from ._wickra import (
__version__,
ADX,
ATR,
Aroon,
AwesomeOscillator,
BollingerBands,
CCI,
DEMA,
Donchian,
EMA,
HMA,
KAMA,
Keltner,
MACD,
MFI,
OBV,
PSAR,
ROC,
RSI,
SMA,
Stochastic,
TEMA,
TRIX,
VWAP,
WilliamsR,
WMA,
)
__all__ = [
"__version__",
"SMA",
"EMA",
"WMA",
"RSI",
"MACD",
"BollingerBands",
"ATR",
"Stochastic",
"OBV",
"DEMA",
"TEMA",
"HMA",
"KAMA",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"MFI",
"TRIX",
"PSAR",
"Keltner",
"Donchian",
"VWAP",
"AwesomeOscillator",
"Aroon",
]
+137
View File
@@ -0,0 +1,137 @@
"""Type stubs for the Wickra public API."""
from __future__ import annotations
from typing import Any, Mapping, Optional, Tuple, Union
import numpy as np
from numpy.typing import NDArray
__version__: str
CandleLike = Union[
Tuple[float, float, float, float, float, int],
Mapping[str, Any],
]
class SMA:
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 EMA:
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 alpha(self) -> float: ...
@property
def value(self) -> Optional[float]: ...
class WMA:
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 RSI:
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 MACD:
def __init__(self, fast: int = 12, slow: int = 26, signal: int = 9) -> None: ...
def update(self, value: float) -> Optional[Tuple[float, float, float]]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]:
"""Returns shape ``(n, 3)`` with columns ``[macd, signal, histogram]``. NaN during warmup."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int, int]: ...
class BollingerBands:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[Tuple[float, float, float, float]]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]:
"""Returns shape ``(n, 4)`` with columns ``[upper, middle, lower, stddev]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def multiplier(self) -> float: ...
class ATR:
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 Stochastic:
def __init__(self, k_period: int = 14, d_period: int = 3) -> 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 ``[k, d]``."""
...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
class OBV:
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]: ...
File diff suppressed because it is too large Load Diff
View File
+36
View File
@@ -0,0 +1,36 @@
"""Shared pytest fixtures for the Wickra Python test suite."""
from __future__ import annotations
import numpy as np
import pytest
@pytest.fixture
def linear_prices() -> np.ndarray:
"""Strictly increasing prices: 1, 2, 3, ..., 50."""
return np.arange(1.0, 51.0, dtype=np.float64)
@pytest.fixture
def constant_prices() -> np.ndarray:
"""50 prices of 100.0."""
return np.full(50, 100.0, dtype=np.float64)
@pytest.fixture
def sine_prices() -> np.ndarray:
"""Smooth sine-wave prices used to stress the indicators a little."""
t = np.arange(200, dtype=np.float64)
return 50.0 + 10.0 * np.sin(t * 0.13) + 4.0 * np.cos(t * 0.41)
@pytest.fixture
def ohlc_series() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Synthetic high / low / close triple."""
t = np.arange(200, dtype=np.float64)
close = 100.0 + np.sin(t * 0.15) * 8.0 + np.cos(t * 0.32) * 3.0
spread = 0.5 + np.abs(np.sin(t * 0.07))
high = close + spread
low = close - spread
return high, low, close
+114
View File
@@ -0,0 +1,114 @@
"""Reference-value tests that pin numerical behaviour from the Python side."""
from __future__ import annotations
import math
import numpy as np
import pytest
import wickra as ta
def test_sma_constant_series():
out = ta.SMA(5).batch(np.full(20, 42.0, dtype=np.float64))
# First 4 are warmup -> NaN; rest equal 42.
assert np.all(np.isnan(out[:4]))
assert np.allclose(out[4:], 42.0)
def test_sma_known_window():
# SMA(3) of [2, 4, 6, 8, 10] -> [_, _, 4, 6, 8]
out = ta.SMA(3).batch(np.array([2.0, 4.0, 6.0, 8.0, 10.0]))
assert math.isnan(out[0]) and math.isnan(out[1])
np.testing.assert_allclose(out[2:], [4.0, 6.0, 8.0])
def test_ema_seed_equals_simple_mean_of_first_window():
# EMA(5) seed = mean([10, 20, 30, 40, 50]) = 30
out = ta.EMA(5).batch(np.array([10.0, 20.0, 30.0, 40.0, 50.0]))
assert math.isnan(out[0])
assert math.isclose(out[4], 30.0, abs_tol=1e-12)
def test_wma_known_window():
# WMA(4) of [1, 2, 3, 4] = (1*1 + 2*2 + 3*3 + 4*4)/10 = 3
out = ta.WMA(4).batch(np.array([1.0, 2.0, 3.0, 4.0]))
assert math.isnan(out[0]) and math.isnan(out[1]) and math.isnan(out[2])
assert math.isclose(out[3], 3.0, abs_tol=1e-12)
def test_rsi_pure_uptrend_is_100():
out = ta.RSI(14).batch(np.arange(1.0, 21.0, dtype=np.float64))
np.testing.assert_allclose(out[14:], 100.0)
def test_rsi_pure_downtrend_is_0():
out = ta.RSI(14).batch(np.arange(20.0, 0.0, -1.0))
np.testing.assert_allclose(out[14:], 0.0)
def test_rsi_flat_series_is_50():
out = ta.RSI(14).batch(np.full(30, 100.0))
np.testing.assert_allclose(out[14:], 50.0)
def test_rsi_wilder_textbook_first_value():
"""Wilder's original 14-period example, ~70.46 at the first emit."""
prices = np.array(
[
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08,
45.89, 46.03, 45.61, 46.28, 46.28,
],
dtype=np.float64,
)
out = ta.RSI(14).batch(prices)
assert math.isclose(out[14], 70.464, abs_tol=0.05)
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.
last = out[-1]
assert math.isclose(last[0], 0.0, abs_tol=1e-9)
assert math.isclose(last[1], 0.0, abs_tol=1e-9)
assert math.isclose(last[2], 0.0, abs_tol=1e-9)
def test_bollinger_constant_series_zero_width():
out = ta.BollingerBands(20, 2.0).batch(np.full(50, 100.0))
row = out[-1]
np.testing.assert_allclose(row, [100.0, 100.0, 100.0, 0.0], atol=1e-12)
def test_bollinger_upper_middle_lower_ordering():
out = ta.BollingerBands(20, 2.0).batch(np.linspace(50.0, 150.0, 100))
ready = out[~np.isnan(out[:, 0])]
assert np.all(ready[:, 0] >= ready[:, 1])
assert np.all(ready[:, 1] >= ready[:, 2])
assert np.all(ready[:, 3] >= 0.0)
def test_atr_constant_range_constant_output():
high = np.full(30, 11.0)
low = np.full(30, 9.0)
close = np.full(30, 10.0)
out = ta.ATR(14).batch(high, low, close)
# Once seeded, ATR equals the constant TR of 2.
np.testing.assert_allclose(out[13:], 2.0, atol=1e-12)
def test_stochastic_extremes():
# Close at the top of a 3-period range -> %K = 100.
high = np.array([10.0, 11.0, 12.0])
low = np.array([8.0, 9.0, 10.0])
close = np.array([9.0, 10.0, 12.0])
out = ta.Stochastic(3, 1).batch(high, low, close)
assert math.isclose(out[2, 0], 100.0, abs_tol=1e-12)
def test_obv_cumulative_known_sequence():
close = np.array([10.0, 11.0, 10.5, 10.5, 12.0])
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])
+88
View File
@@ -0,0 +1,88 @@
"""Tests for the indicator lifecycle methods: reset, is_ready, warmup_period, repr."""
from __future__ import annotations
import numpy as np
import pytest
import wickra as ta
SCALAR_INDICATORS = [
(ta.SMA, (14,)),
(ta.EMA, (14,)),
(ta.WMA, (14,)),
(ta.RSI, (14,)),
(ta.MACD, ()),
(ta.BollingerBands, ()),
]
@pytest.mark.parametrize("cls, args", SCALAR_INDICATORS)
def test_is_ready_transitions_after_warmup(cls, args):
ind = cls(*args)
assert not ind.is_ready()
series = np.linspace(1.0, 200.0, 200)
ind.batch(series)
assert ind.is_ready()
@pytest.mark.parametrize("cls, args", SCALAR_INDICATORS)
def test_reset_returns_to_initial_state(cls, args):
ind = cls(*args)
ind.batch(np.linspace(1.0, 200.0, 200))
assert ind.is_ready()
ind.reset()
assert not ind.is_ready()
@pytest.mark.parametrize(
"cls, args, period",
[
(ta.SMA, (14,), 14),
(ta.EMA, (14,), 14),
(ta.WMA, (14,), 14),
(ta.RSI, (14,), 15),
(ta.BollingerBands, (20, 2.0), 20),
],
)
def test_warmup_period(cls, args, period):
assert cls(*args).warmup_period() == period
def test_repr_contains_class_and_parameters():
assert "SMA" in repr(ta.SMA(14))
assert "14" in repr(ta.SMA(14))
assert "BollingerBands" in repr(ta.BollingerBands(20, 2.0))
def test_constructor_rejects_zero_period():
with pytest.raises(ValueError):
ta.SMA(0)
with pytest.raises(ValueError):
ta.RSI(0)
def test_macd_rejects_fast_geq_slow():
with pytest.raises(ValueError):
ta.MACD(fast=26, slow=12, signal=9)
def test_bollinger_rejects_non_positive_multiplier():
with pytest.raises(ValueError):
ta.BollingerBands(20, 0.0)
with pytest.raises(ValueError):
ta.BollingerBands(20, -1.0)
def test_candle_dict_input_supported():
atr = ta.ATR(2)
atr.update({"open": 10.0, "high": 11.0, "low": 9.0, "close": 10.5, "volume": 1.0})
v = atr.update({"open": 10.5, "high": 12.0, "low": 10.0, "close": 11.0, "volume": 1.0})
assert v is not None
def test_candle_tuple_input_supported():
atr = ta.ATR(2)
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
+57
View File
@@ -0,0 +1,57 @@
"""Smoke tests: every public class can be constructed and emits the right shape."""
from __future__ import annotations
import numpy as np
import pytest
import wickra as ta
def test_version_is_a_nonempty_string():
assert isinstance(ta.__version__, str)
assert ta.__version__
@pytest.mark.parametrize(
"cls, args",
[
(ta.SMA, (14,)),
(ta.EMA, (14,)),
(ta.WMA, (14,)),
(ta.RSI, (14,)),
],
)
def test_scalar_batch_returns_same_length(cls, args, sine_prices):
out = cls(*args).batch(sine_prices)
assert out.shape == sine_prices.shape
assert out.dtype == np.float64
def test_macd_batch_returns_n_by_3(sine_prices):
out = ta.MACD().batch(sine_prices)
assert out.shape == (sine_prices.size, 3)
def test_bollinger_batch_returns_n_by_4(sine_prices):
out = ta.BollingerBands().batch(sine_prices)
assert out.shape == (sine_prices.size, 4)
def test_atr_batch_shape(ohlc_series):
high, low, close = ohlc_series
out = ta.ATR(14).batch(high, low, close)
assert out.shape == close.shape
def test_stochastic_batch_shape(ohlc_series):
high, low, close = ohlc_series
out = ta.Stochastic(14, 3).batch(high, low, close)
assert out.shape == (close.size, 2)
def test_obv_batch_shape(ohlc_series):
_, _, close = ohlc_series
volume = np.ones_like(close)
out = ta.OBV().batch(close, volume)
assert out.shape == close.shape
@@ -0,0 +1,117 @@
"""For every indicator, batch(prices) must equal repeated update(price).
This is the central correctness contract of Wickra: the two APIs share one
implementation, so they cannot disagree. These tests verify it from Python
across the entire warmup → steady-state transition.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
import wickra as ta
def _equal_with_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
"""NumPy ``==`` treats NaN as not-equal; emulate ``equal_nan`` for floats."""
if a.shape != b.shape:
return False
both_nan = np.isnan(a) & np.isnan(b)
diff_ok = np.where(both_nan, 0.0, np.abs(a - b))
return bool(np.all(diff_ok <= tol))
@pytest.mark.parametrize(
"cls, args",
[
(ta.SMA, (14,)),
(ta.EMA, (14,)),
(ta.WMA, (14,)),
(ta.RSI, (14,)),
],
)
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
batch = cls(*args).batch(sine_prices)
streamer = cls(*args)
streamed = np.array(
[streamer.update(float(p)) if streamer is not None else None for p in sine_prices],
dtype=object,
)
# Map None -> NaN to compare against batch.
streamed = np.array(
[math.nan if v is None else float(v) for v in streamed], dtype=np.float64
)
assert _equal_with_nan(batch, streamed)
def test_macd_streaming_matches_batch(sine_prices):
batch = ta.MACD().batch(sine_prices)
streamer = ta.MACD()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
if v is None:
rows.append([math.nan, math.nan, math.nan])
else:
rows.append(list(v))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_bollinger_streaming_matches_batch(sine_prices):
batch = ta.BollingerBands().batch(sine_prices)
streamer = ta.BollingerBands()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
if v is None:
rows.append([math.nan, math.nan, math.nan, math.nan])
else:
rows.append(list(v))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_atr_streaming_matches_batch(ohlc_series):
high, low, close = ohlc_series
batch = ta.ATR(14).batch(high, low, close)
streamer = ta.ATR(14)
rows = []
for h, l, c in zip(high, low, close):
rows.append(streamer.update((float(c), float(h), float(l), float(c), 0.0, 0)))
streamed = np.array([math.nan if v is None else v for v in rows], dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_stochastic_streaming_matches_batch(ohlc_series):
high, low, close = ohlc_series
batch = ta.Stochastic(14, 3).batch(high, low, close)
streamer = ta.Stochastic(14, 3)
rows = []
for h, l, c in zip(high, low, close):
v = streamer.update((float(c), float(h), float(l), float(c), 0.0, 0))
rows.append([math.nan, math.nan] if v is None else list(v))
streamed = np.array(rows, dtype=np.float64)
assert _equal_with_nan(batch, streamed)
def test_obv_streaming_matches_batch(ohlc_series):
_, _, close = ohlc_series
volume = np.ones_like(close)
batch = ta.OBV().batch(close, volume)
streamer = ta.OBV()
rows = []
for c, v in zip(close, volume):
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)
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "wickra-wasm"
description = "WASM bindings for the Wickra streaming-first technical indicators library."
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[lints]
workspace = true
[dependencies]
# WASM target cannot use rayon (no threads in the browser by default), so we
# depend on the local path directly with default features disabled. This also
# strips the parallel batch helpers we don't ship to JavaScript.
wickra-core = { path = "../../crates/wickra-core", default-features = false }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
console_error_panic_hook = { version = "0.1", optional = true }
[features]
default = []
panic-hook = ["dep:console_error_panic_hook"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O3', '--enable-bulk-memory']
+47
View File
@@ -0,0 +1,47 @@
# wickra-wasm
WebAssembly bindings for the Wickra streaming-first technical indicators library.
## Build
You need [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) and the
`wasm32-unknown-unknown` Rust target:
```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```
Then from the repository root:
```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
```js
import init, { SMA, RSI, MACD, version } from "./pkg/wickra_wasm.js";
await init();
console.log("wickra:", version());
// Streaming
const rsi = new RSI(14);
for (const price of livePrices) {
const v = rsi.update(price);
if (v !== undefined && v > 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`.
+137
View File
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Wickra WASM demo</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 880px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
h1 { margin-bottom: .25rem; }
.meta { color: #666; margin-top: 0; }
canvas { width: 100%; height: 380px; border: 1px solid #ddd; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; margin-top: 1rem; }
.card { border: 1px solid #ddd; padding: .75rem; border-radius: .5rem; background: #fafafa; }
.card h3 { margin: 0 0 .25rem 0; font-size: 1rem; }
.card span { font-variant-numeric: tabular-nums; font-weight: 600; }
button { padding: .5rem 1rem; margin-right: .5rem; }
</style>
</head>
<body>
<h1>Wickra in the browser</h1>
<p class="meta">Indicators running entirely client-side via WebAssembly. No network round-trips.</p>
<canvas id="chart"></canvas>
<div class="grid">
<div class="card"><h3>SMA(20)</h3><span id="sma"></span></div>
<div class="card"><h3>EMA(20)</h3><span id="ema"></span></div>
<div class="card"><h3>RSI(14)</h3><span id="rsi"></span></div>
<div class="card"><h3>MACD</h3><span id="macd"></span></div>
<div class="card"><h3>Bollinger</h3><span id="bb"></span></div>
<div class="card"><h3>ATR(14)</h3><span id="atr"></span></div>
</div>
<p style="margin-top:1rem;">
<button id="step">Step one tick</button>
<button id="play">Auto-play</button>
<button id="reset">Reset</button>
</p>
<p class="meta" id="status">Loading WASM module…</p>
<script type="module">
import init, {
version, installPanicHook,
SMA, EMA, RSI, MACD, BollingerBands, ATR,
} from "../pkg/wickra_wasm.js";
const canvas = document.getElementById("chart");
const ctx = canvas.getContext("2d");
function resize() {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
}
const fmt = (v) => Number.isFinite(v) ? v.toFixed(3) : "—";
const state = {
i: 0,
prices: [],
highs: [],
lows: [],
sma: null, ema: null, rsi: null, macd: null, bb: null, atr: null,
};
function rebuild() {
state.i = 0;
state.prices = [];
state.highs = [];
state.lows = [];
state.sma = new SMA(20);
state.ema = new EMA(20);
state.rsi = new RSI(14);
state.macd = new MACD(12, 26, 9);
state.bb = new BollingerBands(20, 2);
state.atr = new ATR(14);
document.getElementById("status").textContent = `Ready — wickra ${version()}`;
render();
}
function step() {
const t = state.i;
const price = 100 + Math.sin(t * 0.07) * 10 + Math.cos(t * 0.19) * 4 + (Math.random() - 0.5) * 0.5;
const high = price + 0.5;
const low = price - 0.5;
state.prices.push(price);
state.highs.push(high);
state.lows.push(low);
const sma = state.sma.update(price);
const ema = state.ema.update(price);
const rsi = state.rsi.update(price);
const macd = state.macd.update(price);
const bb = state.bb.update(price);
const atr = state.atr.update(high, low, price);
document.getElementById("sma").textContent = fmt(sma);
document.getElementById("ema").textContent = fmt(ema);
document.getElementById("rsi").textContent = fmt(rsi);
document.getElementById("macd").textContent = macd ? `${fmt(macd.macd)} / ${fmt(macd.signal)}` : "—";
document.getElementById("bb").textContent = bb ? `${fmt(bb.upper)} | ${fmt(bb.middle)} | ${fmt(bb.lower)}` : "—";
document.getElementById("atr").textContent = fmt(atr);
state.i += 1;
render();
}
function render() {
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
if (state.prices.length < 2) return;
const xs = state.prices.map((_, i) => (i / (state.prices.length - 1)) * w);
const min = Math.min(...state.prices);
const max = Math.max(...state.prices);
const span = max - min || 1;
const ys = state.prices.map((p) => h - ((p - min) / span) * (h - 20) - 10);
ctx.strokeStyle = "#2a78c5"; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < xs.length; i++) ctx.lineTo(xs[i], ys[i]);
ctx.stroke();
}
let playing = null;
document.getElementById("step").onclick = step;
document.getElementById("play").onclick = () => {
if (playing) { clearInterval(playing); playing = null; return; }
playing = setInterval(step, 100);
};
document.getElementById("reset").onclick = rebuild;
window.addEventListener("resize", () => { resize(); render(); });
init().then(() => {
installPanicHook();
resize();
rebuild();
});
</script>
</body>
</html>
+621
View File
@@ -0,0 +1,621 @@
//! WASM bindings for Wickra. Exposes every indicator with `Float64Array` I/O so
//! the API is essentially the same in the browser as it is in Python and Rust.
//!
//! Build with:
//! ```text
//! wasm-pack build bindings/wasm --target web --release
//! ```
#![allow(clippy::needless_pass_by_value)]
#![allow(missing_debug_implementations)] // wasm_bindgen wrappers expose JS objects, no need for Debug
use js_sys::{Float64Array, Object, Reflect};
use wasm_bindgen::prelude::*;
use wickra_core as wc;
use wickra_core::{BatchExt, Indicator};
fn map_err(e: wc::Error) -> JsError {
JsError::new(&e.to_string())
}
fn flatten(values: Vec<Option<f64>>) -> Vec<f64> {
values.into_iter().map(|v| v.unwrap_or(f64::NAN)).collect()
}
/// Optional helper: install `console.error` panic hook in the browser.
#[wasm_bindgen(js_name = installPanicHook)]
pub fn install_panic_hook() {
#[cfg(feature = "panic-hook")]
console_error_panic_hook::set_once();
}
/// Library version (matches the Cargo package version).
#[wasm_bindgen(js_name = version)]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
// ---------- Scalar-input indicators ----------
macro_rules! wasm_scalar_indicator {
($name:ident, $py_name:literal, $rust_ty:ty, $($arg:ident: $arg_ty:ty),*) => {
#[wasm_bindgen(js_name = $py_name)]
pub struct $name {
inner: $rust_ty,
}
#[wasm_bindgen(js_class = $py_name)]
impl $name {
#[wasm_bindgen(constructor)]
pub fn new($($arg: $arg_ty),*) -> Result<$name, JsError> {
Ok($name {
inner: <$rust_ty>::new($($arg),*).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let out = flatten(self.inner.batch(prices));
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) { self.inner.reset(); }
#[wasm_bindgen(js_name = isReady)] pub fn is_ready(&self) -> bool { self.inner.is_ready() }
#[wasm_bindgen(js_name = warmupPeriod)] pub fn warmup_period(&self) -> usize { self.inner.warmup_period() }
}
};
}
wasm_scalar_indicator!(WasmSma, "SMA", wc::Sma, period: usize);
wasm_scalar_indicator!(WasmEma, "EMA", wc::Ema, period: usize);
wasm_scalar_indicator!(WasmWma, "WMA", wc::Wma, period: usize);
wasm_scalar_indicator!(WasmRsi, "RSI", wc::Rsi, period: usize);
wasm_scalar_indicator!(WasmDema, "DEMA", wc::Dema, period: usize);
wasm_scalar_indicator!(WasmTema, "TEMA", wc::Tema, period: usize);
wasm_scalar_indicator!(WasmHma, "HMA", wc::Hma, period: usize);
wasm_scalar_indicator!(WasmRoc, "ROC", wc::Roc, period: usize);
wasm_scalar_indicator!(WasmTrix, "TRIX", wc::Trix, period: usize);
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
pub struct WasmKama {
inner: wc::Kama,
}
#[wasm_bindgen(js_class = KAMA)]
impl WasmKama {
#[wasm_bindgen(constructor)]
pub fn new(er_period: usize, fast: usize, slow: usize) -> Result<WasmKama, JsError> {
Ok(Self {
inner: wc::Kama::new(er_period, fast, slow).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let out = flatten(self.inner.batch(prices));
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
}
// ---------- MACD ----------
#[wasm_bindgen(js_name = MACD)]
pub struct WasmMacd {
inner: wc::MacdIndicator,
}
#[wasm_bindgen(js_class = MACD)]
impl WasmMacd {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<WasmMacd, JsError> {
Ok(Self {
inner: wc::MacdIndicator::new(fast, slow, signal).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok();
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns a flat `Float64Array` of length `3 * n`: `[macd0, sig0, hist0, macd1, sig1, hist1, ...]`.
/// Use `result[3*i + 0/1/2]` to read each column. Warmup positions are NaN.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
}
// ---------- Bollinger ----------
#[wasm_bindgen(js_name = BollingerBands)]
pub struct WasmBb {
inner: wc::BollingerBands,
}
#[wasm_bindgen(js_class = BollingerBands)]
impl WasmBb {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, multiplier: f64) -> Result<WasmBb, JsError> {
Ok(Self {
inner: wc::BollingerBands::new(period, multiplier).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Reflect::set(&obj, &"stddev".into(), &o.stddev.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns `[u0, m0, l0, sd0, u1, m1, l1, sd1, ...]`, length `4 * n`.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 4];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 4] = o.upper;
out[i * 4 + 1] = o.middle;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.stddev;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
// ---------- Candle-input indicators ----------
fn make_candle(h: f64, l: f64, c: f64, v: f64) -> Result<wc::Candle, JsError> {
wc::Candle::new(c, h, l, c, v, 0).map_err(map_err)
}
#[wasm_bindgen(js_name = ATR)]
pub struct WasmAtr {
inner: wc::Atr,
}
#[wasm_bindgen(js_class = ATR)]
impl WasmAtr {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAtr, JsError> {
Ok(Self {
inner: wc::Atr::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
}
#[wasm_bindgen(js_class = Stochastic)]
impl WasmStoch {
#[wasm_bindgen(constructor)]
pub fn new(k_period: usize, d_period: usize) -> Result<WasmStoch, JsError> {
Ok(Self {
inner: wc::Stochastic::new(k_period, d_period).map_err(map_err)?,
})
}
/// Returns `[k0, d0, k1, d1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.k;
out[i * 2 + 1] = o.d;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = OBV)]
pub struct WasmObv {
inner: wc::Obv,
}
impl Default for WasmObv {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = OBV)]
impl WasmObv {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmObv {
Self {
inner: wc::Obv::new(),
}
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ADX)]
pub struct WasmAdx {
inner: wc::Adx,
}
#[wasm_bindgen(js_class = ADX)]
impl WasmAdx {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAdx, JsError> {
Ok(Self {
inner: wc::Adx::new(period).map_err(map_err)?,
})
}
/// Returns `[plusDi, minusDi, adx]` × n, length `3n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.plus_di;
out[i * 3 + 1] = o.minus_di;
out[i * 3 + 2] = o.adx;
}
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = WilliamsR)]
pub struct WasmWilliamsR {
inner: wc::WilliamsR,
}
#[wasm_bindgen(js_class = WilliamsR)]
impl WasmWilliamsR {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmWilliamsR, JsError> {
Ok(Self {
inner: wc::WilliamsR::new(period).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = CCI)]
pub struct WasmCci {
inner: wc::Cci,
}
#[wasm_bindgen(js_class = CCI)]
impl WasmCci {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmCci, JsError> {
Ok(Self {
inner: wc::Cci::new(period).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = MFI)]
pub struct WasmMfi {
inner: wc::Mfi,
}
#[wasm_bindgen(js_class = MFI)]
impl WasmMfi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMfi, JsError> {
Ok(Self {
inner: wc::Mfi::new(period).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = PSAR)]
pub struct WasmPsar {
inner: wc::Psar,
}
#[wasm_bindgen(js_class = PSAR)]
impl WasmPsar {
#[wasm_bindgen(constructor)]
pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Result<WasmPsar, JsError> {
Ok(Self {
inner: wc::Psar::new(af_start, af_step, af_max).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = Keltner)]
pub struct WasmKeltner {
inner: wc::Keltner,
}
#[wasm_bindgen(js_class = Keltner)]
impl WasmKeltner {
#[wasm_bindgen(constructor)]
pub fn new(
ema_period: usize,
atr_period: usize,
multiplier: f64,
) -> Result<WasmKeltner, JsError> {
Ok(Self {
inner: wc::Keltner::new(ema_period, atr_period, multiplier).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = Donchian)]
pub struct WasmDonchian {
inner: wc::Donchian,
}
#[wasm_bindgen(js_class = Donchian)]
impl WasmDonchian {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmDonchian, JsError> {
Ok(Self {
inner: wc::Donchian::new(period).map_err(map_err)?,
})
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let c = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = VWAP)]
pub struct WasmVwap {
inner: wc::Vwap,
}
impl Default for WasmVwap {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = VWAP)]
impl WasmVwap {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmVwap {
Self {
inner: wc::Vwap::new(),
}
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = AwesomeOscillator)]
pub struct WasmAo {
inner: wc::AwesomeOscillator,
}
#[wasm_bindgen(js_class = AwesomeOscillator)]
impl WasmAo {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize) -> Result<WasmAo, JsError> {
Ok(Self {
inner: wc::AwesomeOscillator::new(fast, slow).map_err(map_err)?,
})
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], low[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
}
#[wasm_bindgen(js_name = Aroon)]
pub struct WasmAroon {
inner: wc::Aroon,
}
#[wasm_bindgen(js_class = Aroon)]
impl WasmAroon {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmAroon, JsError> {
Ok(Self {
inner: wc::Aroon::new(period).map_err(map_err)?,
})
}
/// Returns `[up0, down0, up1, down1, ...]`, length `2n`.
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.up;
out[i * 2 + 1] = o.down;
}
}
Ok(Float64Array::from(out.as_slice()))
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "wickra-core"
description = "Core streaming-first technical indicators engine for the Wickra library"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[features]
default = ["parallel"]
parallel = ["dep:rayon"]
[dev-dependencies]
approx = { workspace = true }
proptest = { workspace = true }
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 1f6ec76a79c756aa0bba27231cbc15b38b06ea04e60c14686051486a401b67e4 # shrinks to period = 2, prices = [355.3914886788121, 0.0]
+30
View File
@@ -0,0 +1,30 @@
//! Error types used across `wickra-core`.
use thiserror::Error;
/// Errors that can occur when constructing or operating on an indicator.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum Error {
/// A period (window length) must be at least one.
#[error("period must be greater than zero")]
PeriodZero,
/// A specific minimum period requirement was not met (e.g. MACD needs slow > fast).
#[error("invalid period: {message}")]
InvalidPeriod { message: &'static str },
/// A non-finite value (NaN or infinity) was passed where a finite price was expected.
#[error("input value must be finite (got NaN or infinity)")]
NonFiniteInput,
/// A candle whose components do not form a valid bar (e.g. high < low) was provided.
#[error("invalid candle: {message}")]
InvalidCandle { message: &'static str },
/// A multiplier or factor must be strictly positive.
#[error("multiplier must be greater than zero")]
NonPositiveMultiplier,
}
/// Convenience alias for `Result<T, wickra_core::Error>`.
pub type Result<T> = core::result::Result<T, Error>;
+298
View File
@@ -0,0 +1,298 @@
//! Average Directional Index (ADX) with +DI / -DI components.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// ADX output: the three Wilder lines.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdxOutput {
/// Plus Directional Indicator.
pub plus_di: f64,
/// Minus Directional Indicator.
pub minus_di: f64,
/// Average Directional Index (smoothed |DX|).
pub adx: f64,
}
/// Wilder's Average Directional Index.
///
/// Uses Wilder smoothing throughout. First `period` candles seed the directional
/// 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.
#[allow(clippy::struct_field_names)] // adx_value pairs with adx (the output line) — renaming hurts clarity
#[derive(Debug, Clone)]
pub struct Adx {
period: usize,
prev: Option<Candle>,
// Wilder-smoothed sums during seeding.
tr_seed: f64,
plus_dm_seed: f64,
minus_dm_seed: f64,
seed_count: usize,
// Smoothed running values after seeding.
tr_smooth: Option<f64>,
plus_dm_smooth: Option<f64>,
minus_dm_smooth: Option<f64>,
// ADX seeding.
dx_buf: Vec<f64>,
adx_value: Option<f64>,
last_plus_di: f64,
last_minus_di: f64,
}
impl Adx {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
tr_seed: 0.0,
plus_dm_seed: 0.0,
minus_dm_seed: 0.0,
seed_count: 0,
tr_smooth: None,
plus_dm_smooth: None,
minus_dm_smooth: None,
dx_buf: Vec::with_capacity(period),
adx_value: None,
last_plus_di: 0.0,
last_minus_di: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
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 };
let minus_dm = if down > up && down > 0.0 { down } else { 0.0 };
(plus_dm, minus_dm)
}
impl Indicator for Adx {
type Input = Candle;
type Output = AdxOutput;
fn update(&mut self, candle: Candle) -> Option<AdxOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let tr = candle.true_range(Some(prev.close));
let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
let n = self.period as f64;
let (tr_v, plus_v, minus_v) = if let (Some(t), Some(p), Some(m)) =
(self.tr_smooth, self.plus_dm_smooth, self.minus_dm_smooth)
{
let t_new = t - t / n + tr;
let p_new = p - p / n + plus_dm;
let m_new = m - m / n + minus_dm;
self.tr_smooth = Some(t_new);
self.plus_dm_smooth = Some(p_new);
self.minus_dm_smooth = Some(m_new);
(t_new, p_new, m_new)
} else {
self.tr_seed += tr;
self.plus_dm_seed += plus_dm;
self.minus_dm_seed += minus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.tr_smooth = Some(self.tr_seed);
self.plus_dm_smooth = Some(self.plus_dm_seed);
self.minus_dm_smooth = Some(self.minus_dm_seed);
(self.tr_seed, self.plus_dm_seed, self.minus_dm_seed)
};
let plus_di = if tr_v == 0.0 {
0.0
} else {
100.0 * plus_v / tr_v
};
let minus_di = if tr_v == 0.0 {
0.0
} else {
100.0 * minus_v / tr_v
};
self.last_plus_di = plus_di;
self.last_minus_di = minus_di;
let dx_den = plus_di + minus_di;
let dx = if dx_den == 0.0 {
0.0
} else {
100.0 * (plus_di - minus_di).abs() / dx_den
};
if let Some(prev_adx) = self.adx_value {
let new_adx = (prev_adx * (n - 1.0) + dx) / n;
self.adx_value = Some(new_adx);
return Some(AdxOutput {
plus_di,
minus_di,
adx: new_adx,
});
}
self.dx_buf.push(dx);
if self.dx_buf.len() == self.period {
let seed = self.dx_buf.iter().sum::<f64>() / n;
self.adx_value = Some(seed);
return Some(AdxOutput {
plus_di,
minus_di,
adx: seed,
});
}
None
}
fn reset(&mut self) {
self.prev = None;
self.tr_seed = 0.0;
self.plus_dm_seed = 0.0;
self.minus_dm_seed = 0.0;
self.seed_count = 0;
self.tr_smooth = None;
self.plus_dm_smooth = None;
self.minus_dm_smooth = None;
self.dx_buf.clear();
self.adx_value = None;
self.last_plus_di = 0.0;
self.last_minus_di = 0.0;
}
fn warmup_period(&self) -> usize {
2 * self.period
}
fn is_ready(&self) -> bool {
self.adx_value.is_some()
}
fn name(&self) -> &'static str {
"ADX"
}
}
#[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 pure_uptrend_yields_plus_di_dominant() {
// Strict uptrend: highs increase, lows increase, ADX should trend up,
// +DI should dominate -DI.
let candles: Vec<Candle> = (0..50)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut adx = Adx::new(14).unwrap();
let last = adx
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("emits");
assert!(
last.plus_di > last.minus_di,
"+DI {} should exceed -DI {}",
last.plus_di,
last.minus_di
);
assert!(last.adx > 0.0);
}
#[test]
fn pure_downtrend_yields_minus_di_dominant() {
let candles: Vec<Candle> = (0..50)
.rev()
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut adx = Adx::new(14).unwrap();
let last = adx
.batch(&candles)
.into_iter()
.flatten()
.last()
.expect("emits");
assert!(last.minus_di > last.plus_di);
}
#[test]
fn rejects_zero_period() {
assert!(Adx::new(0).is_err());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut a = Adx::new(14).unwrap();
let mut b = Adx::new(14).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..40).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut adx = Adx::new(14).unwrap();
adx.batch(&candles);
adx.reset();
assert!(!adx.is_ready());
}
#[test]
fn outputs_remain_finite() {
let candles: Vec<Candle> = (0..200)
.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 adx = Adx::new(14).unwrap();
for v in adx.batch(&candles).into_iter().flatten() {
assert!(v.plus_di.is_finite() && v.minus_di.is_finite() && v.adx.is_finite());
}
// Sanity: ADX is bounded by 100.
let last = adx.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last.adx <= 100.0 + 1e-6);
assert_relative_eq!(0.0_f64.max(last.adx), last.adx, epsilon = 1e-9);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Aroon Up / Down indicator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Aroon output: up and down strengths in [0, 100].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AroonOutput {
/// Time since the highest high, expressed as a percentage of the window.
pub up: f64,
/// Time since the lowest low, same convention.
pub down: f64,
}
/// Aroon indicator: tracks how many bars since the highest high and lowest low
/// inside a `period + 1`-bar window. Returned as a percentage.
#[derive(Debug, Clone)]
pub struct Aroon {
period: usize,
candles: VecDeque<Candle>,
}
impl Aroon {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period + 1),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Aroon {
type Input = Candle;
type Output = AroonOutput;
fn update(&mut self, candle: Candle) -> Option<AroonOutput> {
if self.candles.len() == self.period + 1 {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period + 1 {
return None;
}
// Find the index (0 = oldest) of the highest high and lowest low.
let (mut hh_idx, mut ll_idx) = (0_usize, 0_usize);
let (mut hh, mut ll) = (f64::NEG_INFINITY, f64::INFINITY);
for (i, c) in self.candles.iter().enumerate() {
if c.high >= hh {
hh = c.high;
hh_idx = i;
}
if c.low <= ll {
ll = c.low;
ll_idx = i;
}
}
let n = self.period as f64;
let up = 100.0 * hh_idx as f64 / n;
let down = 100.0 * ll_idx as f64 / n;
Some(AroonOutput { up, down })
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period + 1
}
fn name(&self) -> &'static str {
"Aroon"
}
}
#[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 pure_uptrend_aroon_up_100() {
let candles: Vec<Candle> = (1..=15)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = Aroon::new(14).unwrap();
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.up, 100.0, epsilon = 1e-9);
// The lowest low is at the oldest position (index 0).
assert_relative_eq!(last.down, 0.0, epsilon = 1e-9);
}
#[test]
fn pure_downtrend_aroon_down_100() {
let candles: Vec<Candle> = (1..=15)
.rev()
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = Aroon::new(14).unwrap();
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.down, 100.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let m = 50.0 + (f64::from(i) * 0.3).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = Aroon::new(14).unwrap();
let mut b = Aroon::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn outputs_in_range() {
let candles: Vec<Candle> = (0..200)
.map(|i| {
let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = Aroon::new(14).unwrap();
for o in a.batch(&candles).into_iter().flatten() {
assert!((0.0..=100.0).contains(&o.up));
assert!((0.0..=100.0).contains(&o.down));
}
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Average True Range (Wilder).
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average True Range with Wilder smoothing.
///
/// 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.
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
prev_close: Option<f64>,
seed_buf: Vec<f64>,
avg: Option<f64>,
}
impl Atr {
/// Construct an ATR with the given Wilder period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_buf: Vec::with_capacity(period),
avg: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
}
}
impl Indicator for Atr {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
return Some(seed);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.avg.is_some()
}
fn name(&self) -> &'static str {
"ATR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
// ts/open/volume don't affect ATR; use safe placeholders.
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn warmup_emits_on_period_th_candle() {
let candles = vec![
c(2.0, 1.0, 1.5),
c(3.0, 2.0, 2.5),
c(4.0, 3.0, 3.5),
c(5.0, 4.0, 4.5),
c(6.0, 5.0, 5.5),
];
let mut atr = Atr::new(3).unwrap();
let out = atr.batch(&candles);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
assert!(out[3].is_some());
}
#[test]
fn constant_range_yields_constant_atr() {
// Every candle has H=11, L=9, C=10 -> TR=2 (no gaps).
let candles: Vec<Candle> = (0..30).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut atr = Atr::new(14).unwrap();
let out = atr.batch(&candles);
for v in out.iter().skip(13).flatten() {
assert_relative_eq!(*v, 2.0, epsilon = 1e-12);
}
}
#[test]
fn gap_up_uses_high_minus_prev_close() {
// Previous close 5, current candle H=10 L=9 C=9.5 -> TR = max(1, 5, 4) = 5.
let candles = vec![
c(6.0, 4.0, 5.0), // prev close = 5
c(10.0, 9.0, 9.5), // TR = 5
];
let mut atr = Atr::new(2).unwrap();
let out = atr.batch(&candles);
// Seed window covers TR_1 and TR_2. TR_1 = H1-L1 = 2 (no prev close). TR_2 = 5.
// Seed = (2+5)/2 = 3.5
assert_relative_eq!(out[1].unwrap(), 3.5, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let mid = f64::from(i) + 10.0;
c(mid + 0.5, mid - 0.5, mid)
})
.collect();
let mut a = Atr::new(14).unwrap();
let mut b = Atr::new(14).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(|_| c(11.0, 9.0, 10.0)).collect();
let mut atr = Atr::new(5).unwrap();
atr.batch(&candles);
assert!(atr.is_ready());
atr.reset();
assert!(!atr.is_ready());
assert_eq!(atr.update(candles[0]), None);
}
#[test]
fn never_negative() {
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut atr = Atr::new(14).unwrap();
for v in atr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "ATR must be non-negative: {v}");
}
}
}
@@ -0,0 +1,117 @@
//! Awesome Oscillator (Bill Williams).
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Awesome Oscillator: `SMA(median_price, 5) - SMA(median_price, 34)`.
#[derive(Debug, Clone)]
pub struct AwesomeOscillator {
fast: Sma,
slow: Sma,
fast_period: usize,
slow_period: usize,
}
impl AwesomeOscillator {
/// # Errors
/// Returns [`Error::PeriodZero`] for zero periods or [`Error::InvalidPeriod`] when 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: "AO fast period must be strictly less than slow",
});
}
Ok(Self {
fast: Sma::new(fast)?,
slow: Sma::new(slow)?,
fast_period: fast,
slow_period: slow,
})
}
/// Classic Bill Williams configuration: (5, 34).
pub fn classic() -> Self {
Self::new(5, 34).expect("classic AO periods are valid")
}
/// Configured `(fast, slow)` periods.
pub const fn periods(&self) -> (usize, usize) {
(self.fast_period, self.slow_period)
}
}
impl Indicator for AwesomeOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let median = candle.median_price();
let f = self.fast.update(median);
let s = self.slow.update(median);
match (f, s) {
(Some(a), Some(b)) => Some(a - b),
_ => None,
}
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
}
fn warmup_period(&self) -> usize {
self.slow_period
}
fn is_ready(&self) -> bool {
self.slow.is_ready()
}
fn name(&self) -> &'static str {
"AwesomeOscillator"
}
}
#[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 constant_series_yields_zero() {
let candles: Vec<Candle> = (0..80).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut ao = AwesomeOscillator::classic();
let last = ao.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn rejects_fast_geq_slow() {
assert!(AwesomeOscillator::new(34, 5).is_err());
assert!(AwesomeOscillator::new(5, 5).is_err());
assert!(AwesomeOscillator::new(0, 5).is_err());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = AwesomeOscillator::classic();
let mut b = AwesomeOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,243 @@
//! Bollinger Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Bollinger Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BollingerOutput {
/// Upper band: `middle + multiplier * stddev`.
pub upper: f64,
/// Middle band: SMA over the window.
pub middle: f64,
/// Lower band: `middle multiplier * stddev`.
pub lower: f64,
/// Sample standard deviation (denominator `period`, population stddev) used to build
/// the bands. Reported separately because some callers compute their own bands.
pub stddev: f64,
}
/// Bollinger Bands with SMA middle band and population standard deviation envelopes.
///
/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
/// publication uses population (not sample) standard deviation, which matches every
/// reference implementation (TA-Lib, pandas-ta, etc.).
#[derive(Debug, Clone)]
pub struct BollingerBands {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl BollingerBands {
/// Construct a new Bollinger Bands indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] for `period == 0` and
/// [`Error::NonPositiveMultiplier`] for `multiplier <= 0`.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Classic configuration: `period = 20`, `multiplier = 2.0`.
pub fn classic() -> Self {
Self::new(20, 2.0).expect("classic Bollinger parameters are valid")
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// Population variance: E[x^2] - (E[x])^2. Clamp small negative values that arise
// from catastrophic cancellation on near-constant inputs.
let var = (self.sum_sq / n - mean * mean).max(0.0);
let stddev = var.sqrt();
Some(BollingerOutput {
upper: mean + self.multiplier * stddev,
middle: mean,
lower: mean - self.multiplier * stddev,
stddev,
})
}
}
impl Indicator for BollingerBands {
type Input = f64;
type Output = BollingerOutput;
fn update(&mut self, input: f64) -> Option<BollingerOutput> {
if !input.is_finite() {
return self.current();
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.current()
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BollingerBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn naive(prices: &[f64], period: usize, mult: f64) -> Option<BollingerOutput> {
if prices.len() < period {
return None;
}
let w = &prices[prices.len() - period..];
let mean = w.iter().sum::<f64>() / period as f64;
let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
let s = var.sqrt();
Some(BollingerOutput {
upper: mean + mult * s,
middle: mean,
lower: mean - mult * s,
stddev: s,
})
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
BollingerBands::new(0, 2.0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
BollingerBands::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
BollingerBands::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
BollingerBands::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn warmup_returns_none() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
for v in [1.0, 2.0, 3.0, 4.0] {
assert!(bb.update(v).is_none());
}
assert!(bb.update(5.0).is_some());
}
#[test]
fn constant_series_yields_zero_stddev() {
let mut bb = BollingerBands::new(10, 2.0).unwrap();
let out = bb.batch(&[5.0_f64; 30]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 5.0, epsilon = 1e-12);
}
#[test]
fn matches_naive_definition() {
let prices: Vec<f64> = (1..=60)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut bb = BollingerBands::new(20, 2.0).unwrap();
let out = bb.batch(&prices);
for i in 19..prices.len() {
let got = out[i].unwrap();
let want = naive(&prices[..=i], 20, 2.0).unwrap();
assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
assert_relative_eq!(got.lower, want.lower, epsilon = 1e-9);
}
}
#[test]
fn upper_above_middle_above_lower() {
let prices: Vec<f64> = (1..=100).map(f64::from).collect();
let mut bb = BollingerBands::new(20, 2.0).unwrap();
for o in bb.batch(&prices).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = BollingerBands::new(10, 2.0).unwrap();
let mut b = BollingerBands::new(10, 2.0).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Commodity Channel Index (CCI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Commodity Channel Index.
///
/// `CCI = (TP - SMA(TP)) / (0.015 * mean absolute deviation of TP)`, where
/// `TP = (high + low + close) / 3`.
#[derive(Debug, Clone)]
pub struct Cci {
period: usize,
factor: f64,
window: VecDeque<f64>,
sum: f64,
}
impl Cci {
/// Construct a new CCI with the canonical 0.015 scaling factor.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Self::with_factor(period, 0.015)
}
/// Construct a CCI with a custom scaling factor (the standard literature
/// uses 0.015 to put roughly 70 % of values inside ±100).
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `factor <= 0`.
pub fn with_factor(period: usize, factor: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !factor.is_finite() || factor <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
factor,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Cci {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tp = candle.typical_price();
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
}
self.window.push_back(tp);
self.sum += tp;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let mad: f64 = self.window.iter().map(|v| (v - mean).abs()).sum::<f64>() / n;
if mad == 0.0 {
return Some(0.0);
}
Some((tp - mean) / (self.factor * mad))
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CCI"
}
}
#[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 flat_candles_yield_zero() {
let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut cci = Cci::new(20).unwrap();
for v in cci.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn rejects_invalid_input() {
assert!(Cci::new(0).is_err());
assert!(Cci::with_factor(20, 0.0).is_err());
assert!(Cci::with_factor(20, -1.0).is_err());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 50.0 + (f64::from(i) * 0.2).sin() * 10.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = Cci::new(20).unwrap();
let mut b = Cci::new(20).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..30).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut cci = Cci::new(20).unwrap();
cci.batch(&candles);
assert!(cci.is_ready());
cci.reset();
assert!(!cci.is_ready());
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Double Exponential Moving Average (DEMA).
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Double Exponential Moving Average: `2 * EMA - EMA(EMA)`.
///
/// Designed by Patrick Mulloy to reduce the lag of a single EMA while keeping
/// the smoothing benefit.
#[derive(Debug, Clone)]
pub struct Dema {
ema1: Ema,
ema2: Ema,
period: usize,
}
impl Dema {
/// # Errors
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
period,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Dema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
Some(2.0 * e1 - e2)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
}
fn warmup_period(&self) -> usize {
// EMA1 seeds at period, then EMA2 needs another (period - 1) values to seed.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.ema2.is_ready()
}
fn name(&self) -> &'static str {
"DEMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_dema() {
let mut dema = Dema::new(5).unwrap();
let out = dema.batch(&[100.0_f64; 60]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn linear_uptrend_dema_above_ema_eventually() {
// On a linear uptrend DEMA should be ahead of (greater than) a plain EMA,
// because the second-order correction removes lag.
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let mut dema = Dema::new(20).unwrap();
let mut ema = Ema::new(20).unwrap();
let dema_out = dema.batch(&prices);
let ema_out = ema.batch(&prices);
// Compare at the last index where both are ready.
let d = dema_out.last().unwrap().unwrap();
let e = ema_out.last().unwrap().unwrap();
assert!(d > e, "DEMA={d} should exceed EMA={e} on uptrend");
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut a = Dema::new(7).unwrap();
let mut b = Dema::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut dema = Dema::new(5).unwrap();
dema.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(dema.is_ready());
dema.reset();
assert!(!dema.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Dema::new(0).is_err());
}
}
@@ -0,0 +1,141 @@
//! Donchian Channels.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Donchian Channels output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DonchianOutput {
/// Highest high over the lookback.
pub upper: f64,
/// Average of upper and lower.
pub middle: f64,
/// Lowest low over the lookback.
pub lower: f64,
}
/// Donchian Channels: rolling highest high / lowest low envelopes.
#[derive(Debug, Clone)]
pub struct Donchian {
period: usize,
candles: VecDeque<Candle>,
}
impl Donchian {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Donchian {
type Input = Candle;
type Output = DonchianOutput;
fn update(&mut self, candle: Candle) -> Option<DonchianOutput> {
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period {
return None;
}
let upper = self
.candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let lower = self
.candles
.iter()
.map(|c| c.low)
.fold(f64::INFINITY, f64::min);
Some(DonchianOutput {
upper,
middle: (upper + lower) / 2.0,
lower,
})
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period
}
fn name(&self) -> &'static str {
"DonchianChannels"
}
}
#[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 flat_market_yields_equal_bands() {
let candles: Vec<Candle> = (0..20).map(|_| c(11.0, 9.0, 10.0)).collect();
let mut d = Donchian::new(5).unwrap();
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, 11.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 9.0, epsilon = 1e-12);
assert_relative_eq!(last.middle, 10.0, epsilon = 1e-12);
}
#[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 = Donchian::new(10).unwrap();
let mut b = Donchian::new(10).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut d = Donchian::new(10).unwrap();
for o in d.batch(&candles).into_iter().flatten() {
assert!(o.upper >= o.middle);
assert!(o.middle >= o.lower);
}
}
#[test]
fn rejects_zero_period() {
assert!(Donchian::new(0).is_err());
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Exponential Moving Average.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Exponential Moving Average with smoothing factor `alpha = 2 / (period + 1)`.
///
/// The first value is seeded with the simple mean of the first `period` inputs
/// (the classical TA-Lib convention). From then on each new input contributes
/// `alpha * input + (1 - alpha) * previous`.
#[derive(Debug, Clone)]
pub struct Ema {
period: usize,
alpha: f64,
state: Option<f64>,
warmup_buf: Vec<f64>,
}
impl Ema {
/// Construct an EMA with the given period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
state: None,
warmup_buf: Vec::with_capacity(period),
})
}
/// Construct an EMA with a custom smoothing factor `alpha in (0, 1]`.
///
/// The reported `period` is derived from `alpha` via `2/alpha - 1` and rounded;
/// `warmup_period()` falls back to `1` because the implementation seeds from the
/// very first input.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `alpha` is not in `(0.0, 1.0]` or non-finite.
pub fn with_alpha(alpha: f64) -> Result<Self> {
if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
return Err(Error::InvalidPeriod {
message: "alpha must be in (0.0, 1.0]",
});
}
Ok(Self {
period: 1,
alpha,
state: None,
warmup_buf: Vec::with_capacity(1),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing factor.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.state
}
/// Internal helper that feeds a value without finiteness validation. The caller
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
if let Some(prev) = self.state {
let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev);
self.state = Some(new);
return Some(new);
}
self.warmup_buf.push(input);
if self.warmup_buf.len() == self.period {
let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
self.state = Some(seed);
return Some(seed);
}
None
}
}
impl Indicator for Ema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.state;
}
self.step_unchecked(input)
}
fn reset(&mut self) {
self.state = None;
self.warmup_buf.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.state.is_some()
}
fn name(&self) -> &'static str {
"EMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Ema::new(0), Err(Error::PeriodZero)));
}
#[test]
fn warmup_returns_none_until_seed() {
let mut ema = Ema::new(3).unwrap();
assert_eq!(ema.update(1.0), None);
assert_eq!(ema.update(2.0), None);
assert_eq!(ema.update(3.0), Some(2.0)); // seed = SMA([1,2,3]) = 2
}
#[test]
fn first_value_equals_sma_seed() {
let mut ema = Ema::new(5).unwrap();
let inputs = [10.0, 20.0, 30.0, 40.0, 50.0];
let mut last = None;
for v in inputs {
last = ema.update(v);
}
assert_relative_eq!(last.unwrap(), 30.0, epsilon = 1e-12);
}
#[test]
fn alpha_matches_period_formula() {
let ema = Ema::new(10).unwrap();
assert_relative_eq!(ema.alpha(), 2.0 / 11.0, epsilon = 1e-15);
}
#[test]
fn step_after_seed_uses_alpha_formula() {
// period=3 => alpha = 0.5; seed = mean([1,2,3]) = 2; next input 10
// expected = 0.5*10 + 0.5*2 = 6
let mut ema = Ema::new(3).unwrap();
ema.batch(&[1.0, 2.0, 3.0]);
assert_relative_eq!(ema.update(10.0).unwrap(), 6.0, epsilon = 1e-12);
}
#[test]
fn constant_series_converges_to_constant() {
let mut ema = Ema::new(10).unwrap();
let out = ema.batch(&[42.0_f64; 100]);
for x in out.iter().skip(9) {
assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-9);
}
}
#[test]
fn with_alpha_validates_range() {
assert!(Ema::with_alpha(0.5).is_ok());
assert!(Ema::with_alpha(1.0).is_ok());
assert!(matches!(
Ema::with_alpha(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ema::with_alpha(1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Ema::with_alpha(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn reset_clears_state() {
let mut ema = Ema::new(3).unwrap();
ema.batch(&[1.0, 2.0, 3.0]);
assert!(ema.is_ready());
ema.reset();
assert!(!ema.is_ready());
assert_eq!(ema.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let mut a = Ema::new(5).unwrap();
let mut b = Ema::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut ema = Ema::new(3).unwrap();
ema.batch(&[1.0, 2.0, 3.0]);
let before = ema.value();
assert_eq!(ema.update(f64::NAN), before);
assert_eq!(ema.update(f64::INFINITY), before);
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Hull Moving Average (HMA).
use crate::error::{Error, Result};
use crate::indicators::wma::Wma;
use crate::traits::Indicator;
/// Hull Moving Average: `WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`.
///
/// Designed by Alan Hull as a lag-free moving average that is also responsive.
/// The square root of the period is rounded to the nearest integer (minimum 1).
#[derive(Debug, Clone)]
pub struct Hma {
period: usize,
half_wma: Wma,
full_wma: Wma,
smooth_wma: Wma,
}
impl Hma {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let half = (period / 2).max(1);
let smooth = (period as f64).sqrt().round() as usize;
let smooth = smooth.max(1);
Ok(Self {
period,
half_wma: Wma::new(half)?,
full_wma: Wma::new(period)?,
smooth_wma: Wma::new(smooth)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Hma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let h = self.half_wma.update(input)?;
let f = self.full_wma.update(input)?;
let diff = 2.0 * h - f;
self.smooth_wma.update(diff)
}
fn reset(&mut self) {
self.half_wma.reset();
self.full_wma.reset();
self.smooth_wma.reset();
}
fn warmup_period(&self) -> usize {
let sm = (self.period as f64).sqrt().round() as usize;
self.period + sm.max(1) - 1
}
fn is_ready(&self) -> bool {
self.smooth_wma.is_ready()
}
fn name(&self) -> &'static str {
"HMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_hma() {
let mut hma = Hma::new(9).unwrap();
let out = hma.batch(&[10.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
let mut a = Hma::new(9).unwrap();
let mut b = Hma::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut hma = Hma::new(9).unwrap();
hma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(hma.is_ready());
hma.reset();
assert!(!hma.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Hma::new(0).is_err());
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Kaufman's Adaptive Moving Average (KAMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Kaufman's Adaptive Moving Average.
///
/// KAMA adapts its smoothing constant to volatility: efficient (trending) markets
/// get a fast smoothing constant, choppy markets get a slow one. Parameters are
/// the efficiency-ratio lookback (`er_period`, default 10), the fast EMA period
/// (`fast`, default 2) and the slow EMA period (`slow`, default 30).
#[derive(Debug, Clone)]
pub struct Kama {
er_period: usize,
fast_sc: f64,
slow_sc: f64,
window: VecDeque<f64>,
state: Option<f64>,
}
impl Kama {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::InvalidPeriod`] for bad parameters.
pub fn new(er_period: usize, fast: usize, slow: usize) -> Result<Self> {
if er_period == 0 || fast == 0 || slow == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "KAMA fast period must be strictly less than slow",
});
}
let fast_sc = 2.0 / (fast as f64 + 1.0);
let slow_sc = 2.0 / (slow as f64 + 1.0);
Ok(Self {
er_period,
fast_sc,
slow_sc,
window: VecDeque::with_capacity(er_period + 1),
state: None,
})
}
/// Classic Kaufman parameters: (10, 2, 30).
pub fn classic() -> Self {
Self::new(10, 2, 30).expect("classic KAMA parameters are valid")
}
/// Configured `(er_period, fast, slow)` periods.
pub fn periods(&self) -> (usize, f64, f64) {
(self.er_period, self.fast_sc, self.slow_sc)
}
}
impl Indicator for Kama {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.state;
}
if self.window.len() == self.er_period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.er_period + 1 {
return None;
}
let first = *self.window.front().expect("non-empty");
let last = *self.window.back().expect("non-empty");
let direction = (last - first).abs();
let volatility: f64 = self
.window
.iter()
.zip(self.window.iter().skip(1))
.map(|(a, b)| (b - a).abs())
.sum();
let er = if volatility == 0.0 {
0.0
} else {
direction / volatility
};
let sc = (er * (self.fast_sc - self.slow_sc) + self.slow_sc).powi(2);
let prev = self.state.unwrap_or(first);
let new = prev + sc * (input - prev);
self.state = Some(new);
Some(new)
}
fn reset(&mut self) {
self.window.clear();
self.state = None;
}
fn warmup_period(&self) -> usize {
self.er_period + 1
}
fn is_ready(&self) -> bool {
self.state.is_some()
}
fn name(&self) -> &'static str {
"KAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_kama() {
let mut k = Kama::classic();
let out = k.batch(&[100.0_f64; 100]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn rejects_invalid_periods() {
assert!(Kama::new(0, 2, 30).is_err());
assert!(Kama::new(10, 30, 2).is_err()); // fast >= slow
assert!(Kama::new(10, 2, 2).is_err()); // fast == slow
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
.collect();
let mut a = Kama::classic();
let mut b = Kama::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut k = Kama::classic();
k.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
}
}
@@ -0,0 +1,142 @@
//! Keltner Channels.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Keltner Channels output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeltnerOutput {
/// Upper band = middle + multiplier * ATR.
pub upper: f64,
/// Middle band = EMA of typical price.
pub middle: f64,
/// Lower band = middle - multiplier * ATR.
pub lower: f64,
}
/// Keltner Channels: an EMA centerline with bands sized by ATR.
#[derive(Debug, Clone)]
pub struct Keltner {
ema: Ema,
atr: Atr,
multiplier: f64,
ema_period: usize,
atr_period: usize,
}
impl Keltner {
/// # Errors
/// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on invalid inputs.
pub fn new(ema_period: usize, atr_period: usize, multiplier: f64) -> Result<Self> {
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
ema: Ema::new(ema_period)?,
atr: Atr::new(atr_period)?,
multiplier,
ema_period,
atr_period,
})
}
/// Classic configuration: EMA(20), ATR(10), 2.0x multiplier.
pub fn classic() -> Self {
Self::new(20, 10, 2.0).expect("classic Keltner parameters are valid")
}
/// Configured `(ema_period, atr_period, multiplier)`.
pub const fn periods(&self) -> (usize, usize, f64) {
(self.ema_period, self.atr_period, self.multiplier)
}
}
impl Indicator for Keltner {
type Input = Candle;
type Output = KeltnerOutput;
fn update(&mut self, candle: Candle) -> Option<KeltnerOutput> {
let mid = self.ema.update(candle.typical_price())?;
let atr = self.atr.update(candle)?;
Some(KeltnerOutput {
upper: mid + self.multiplier * atr,
middle: mid,
lower: mid - self.multiplier * atr,
})
}
fn reset(&mut self) {
self.ema.reset();
self.atr.reset();
}
fn warmup_period(&self) -> usize {
self.ema_period.max(self.atr_period)
}
fn is_ready(&self) -> bool {
self.ema.is_ready() && self.atr.is_ready()
}
fn name(&self) -> &'static str {
"KeltnerChannels"
}
}
#[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 flat_market_collapses_bands() {
let candles: Vec<Candle> = (0..50).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut k = Keltner::new(20, 10, 2.0).unwrap();
let last = k.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last.upper, last.middle, epsilon = 1e-9);
assert_relative_eq!(last.lower, last.middle, epsilon = 1e-9);
}
#[test]
fn upper_above_middle_above_lower() {
let candles: Vec<Candle> = (0..100)
.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 k = Keltner::classic();
for o in k.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..50)
.map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
let mut a = Keltner::classic();
let mut b = Keltner::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn rejects_invalid_input() {
assert!(Keltner::new(0, 10, 2.0).is_err());
assert!(Keltner::new(20, 10, 0.0).is_err());
assert!(Keltner::new(20, 10, -1.0).is_err());
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Moving Average Convergence Divergence (MACD).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// MACD output: the three classic series at a given step.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MacdOutput {
/// Fast EMA slow EMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// MACD = EMA(fast) EMA(slow), with a signal EMA on top.
///
/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`. The signal EMA
/// is seeded from the first `signal` raw MACD values, so the first full
/// [`MacdOutput`] is emitted after `slow + signal 1` inputs (assuming the
/// slow EMA seeded by then).
#[derive(Debug, Clone)]
pub struct MacdIndicator {
fast: Ema,
slow: Ema,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<MacdOutput>,
}
impl MacdIndicator {
/// Construct a MACD with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Ema::new(fast)?,
slow: Ema::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Default `(12, 26, 9)` configuration, matching every classical chart package.
pub fn classic() -> Self {
Self::new(12, 26, 9).expect("classic MACD periods are valid")
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<MacdOutput> {
self.last
}
}
impl Indicator for MacdIndicator {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, input: f64) -> Option<MacdOutput> {
if !input.is_finite() {
return self.last;
}
let fast = self.fast.update(input);
let slow = self.slow.update(input);
match (fast, slow) {
(Some(f), Some(s)) => {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = MacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
Some(out)
}
_ => None,
}
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
// Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`.
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MACD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_fast_geq_slow() {
assert!(matches!(
MacdIndicator::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
MacdIndicator::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(
MacdIndicator::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdIndicator::new(12, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdIndicator::new(12, 26, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut macd = MacdIndicator::classic();
let out = macd.batch(&prices);
let warmup = macd.warmup_period();
// Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need
// the signal EMA's seeding. Our warmup_period is the index at which the first
// signal value appears: slow + signal - 1.
for x in out.iter().take(warmup - 1) {
assert!(x.is_none(), "expected None within warmup");
}
assert!(
out[warmup - 1].is_some(),
"expected first emission at warmup_period - 1 ({warmup} idx)"
);
}
#[test]
fn histogram_equals_macd_minus_signal() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut macd = MacdIndicator::classic();
for v in macd.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
}
}
#[test]
fn constant_series_yields_zero_macd_eventually() {
let mut macd = MacdIndicator::classic();
let out = macd.batch(&[100.0_f64; 200]);
// Both EMAs converge to 100, so MACD must approach 0.
let last = out.iter().rev().flatten().next().expect("emits a value");
assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9);
assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9);
assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9);
}
#[test]
fn rising_series_macd_positive_then_signal_catches_up() {
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let mut macd = MacdIndicator::classic();
let out = macd.batch(&prices);
let last = out.iter().rev().flatten().next().unwrap();
assert!(last.macd > 0.0, "rising series must yield positive MACD");
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100)
.map(|i| (f64::from(i) * 0.4).cos() * 10.0)
.collect();
let mut a = MacdIndicator::classic();
let mut b = MacdIndicator::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut macd = MacdIndicator::classic();
macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(macd.is_ready());
macd.reset();
assert!(!macd.is_ready());
assert_eq!(macd.update(1.0), None);
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Money Flow Index (MFI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Money Flow Index: a volume-weighted version of RSI.
///
/// `MFI = 100 - 100 / (1 + positive_money_flow / negative_money_flow)` where
/// money flow is `typical_price * volume`, classified positive when TP increases
/// and negative when it decreases.
#[derive(Debug, Clone)]
pub struct Mfi {
period: usize,
prev_tp: Option<f64>,
pos_window: VecDeque<f64>,
neg_window: VecDeque<f64>,
pos_sum: f64,
neg_sum: f64,
}
impl Mfi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_tp: None,
pos_window: VecDeque::with_capacity(period),
neg_window: VecDeque::with_capacity(period),
pos_sum: 0.0,
neg_sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Mfi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tp = candle.typical_price();
let mf = tp * candle.volume;
let (pos_flow, neg_flow) = match self.prev_tp {
None => (0.0, 0.0),
Some(prev) => {
if tp > prev {
(mf, 0.0)
} else if tp < prev {
(0.0, mf)
} else {
(0.0, 0.0)
}
}
};
if self.pos_window.len() == self.period {
self.pos_sum -= self.pos_window.pop_front().expect("non-empty");
self.neg_sum -= self.neg_window.pop_front().expect("non-empty");
}
self.pos_window.push_back(pos_flow);
self.neg_window.push_back(neg_flow);
self.pos_sum += pos_flow;
self.neg_sum += neg_flow;
self.prev_tp = Some(tp);
// Need period+1 candles total (the first one only gives prev_tp).
if self.prev_tp.is_none() || self.pos_window.len() < self.period {
return None;
}
// Need at least one comparison-based flow inside the window, otherwise we
// are still on the very first candle.
if self.pos_sum == 0.0 && self.neg_sum == 0.0 {
return Some(50.0);
}
if self.neg_sum == 0.0 {
return Some(100.0);
}
let mr = self.pos_sum / self.neg_sum;
Some(100.0 - 100.0 / (1.0 + mr))
}
fn reset(&mut self) {
self.prev_tp = None;
self.pos_window.clear();
self.neg_window.clear();
self.pos_sum = 0.0;
self.neg_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.pos_window.len() == self.period
}
fn name(&self) -> &'static str {
"MFI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(price: f64, volume: f64) -> Candle {
Candle::new(price, price, price, price, volume, 0).unwrap()
}
#[test]
fn pure_uptrend_yields_high_mfi() {
let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 100.0)).collect();
let mut mfi = Mfi::new(14).unwrap();
let last = mfi.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn pure_downtrend_yields_low_mfi() {
let candles: Vec<Candle> = (1..30).rev().map(|i| c(f64::from(i), 100.0)).collect();
let mut mfi = Mfi::new(14).unwrap();
let last = mfi.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40).map(|i| c(f64::from(i) + 10.0, 50.0)).collect();
let mut a = Mfi::new(14).unwrap();
let mut b = Mfi::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 100.0)).collect();
let mut mfi = Mfi::new(14).unwrap();
mfi.batch(&candles);
assert!(mfi.is_ready());
mfi.reset();
assert!(!mfi.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Mfi::new(0).is_err());
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Built-in indicators. Every indicator implements [`crate::Indicator`].
//!
//! Modules are organised internally by category (trend, momentum, volatility,
//! volume) but every public name is also re-exported flat from this module and
//! from the crate root for convenience.
mod adx;
mod aroon;
mod atr;
mod awesome_oscillator;
mod bollinger;
mod cci;
mod dema;
mod donchian;
mod ema;
mod hma;
mod kama;
mod keltner;
mod macd;
mod mfi;
mod obv;
mod psar;
mod roc;
mod rsi;
mod sma;
mod stochastic;
mod tema;
mod trix;
mod vwap;
mod williams_r;
mod wma;
pub use adx::{Adx, AdxOutput};
pub use aroon::{Aroon, AroonOutput};
pub use atr::Atr;
pub use awesome_oscillator::AwesomeOscillator;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use cci::Cci;
pub use dema::Dema;
pub use donchian::{Donchian, DonchianOutput};
pub use ema::Ema;
pub use hma::Hma;
pub use kama::Kama;
pub use keltner::{Keltner, KeltnerOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use mfi::Mfi;
pub use obv::Obv;
pub use psar::Psar;
pub use roc::Roc;
pub use rsi::Rsi;
pub use sma::Sma;
pub use stochastic::{Stochastic, StochasticOutput};
pub use tema::Tema;
pub use trix::Trix;
pub use vwap::{RollingVwap, Vwap};
pub use williams_r::WilliamsR;
pub use wma::Wma;
+159
View File
@@ -0,0 +1,159 @@
//! On-Balance Volume.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// On-Balance Volume: a cumulative signed-volume series.
///
/// Each candle adds `+volume`, `-volume`, or `0` depending on whether its close
/// is above, below, or equal to the previous close. The first value (after the
/// first candle) is conventionally `0`.
#[derive(Debug, Clone, Default)]
pub struct Obv {
prev_close: Option<f64>,
total: f64,
has_emitted: bool,
}
impl Obv {
/// Construct a new OBV instance 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 candle has been ingested.
pub const fn value(&self) -> Option<f64> {
if self.has_emitted {
Some(self.total)
} else {
None
}
}
}
impl Indicator for Obv {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// The first candle establishes the baseline at 0; subsequent candles
// add/subtract their volume based on close direction. Equal closes do nothing.
if let Some(prev) = self.prev_close {
if candle.close > prev {
self.total += candle.volume;
} else if candle.close < prev {
self.total -= candle.volume;
}
}
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 {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"OBV"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(close: f64, volume: f64) -> Candle {
Candle::new(close, close, close, close, volume, 0).unwrap()
}
#[test]
fn first_candle_baseline_zero() {
let mut obv = Obv::new();
assert_relative_eq!(obv.update(c(10.0, 100.0)).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn up_close_adds_volume() {
let mut obv = Obv::new();
obv.update(c(10.0, 100.0)); // baseline 0
let v = obv.update(c(11.0, 50.0)).unwrap();
assert_relative_eq!(v, 50.0, epsilon = 1e-12);
}
#[test]
fn down_close_subtracts_volume() {
let mut obv = Obv::new();
obv.update(c(10.0, 100.0));
let v = obv.update(c(9.0, 50.0)).unwrap();
assert_relative_eq!(v, -50.0, epsilon = 1e-12);
}
#[test]
fn equal_close_does_nothing() {
let mut obv = Obv::new();
obv.update(c(10.0, 100.0));
let v = obv.update(c(10.0, 50.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn cumulative_sequence() {
let candles = vec![
c(10.0, 100.0), // baseline
c(11.0, 20.0), // +20
c(10.5, 30.0), // -30
c(10.5, 40.0), // unchanged
c(12.0, 10.0), // +10
];
let mut obv = Obv::new();
let out = obv.batch(&candles);
assert_relative_eq!(out[0].unwrap(), 0.0, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 20.0, epsilon = 1e-12);
assert_relative_eq!(out[2].unwrap(), -10.0, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), -10.0, epsilon = 1e-12);
assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let cl = 10.0 + (f64::from(i) * 0.5).sin();
c(cl, 1.0)
})
.collect();
let mut a = Obv::new();
let mut b = Obv::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut obv = Obv::new();
obv.batch(&[c(10.0, 50.0), c(11.0, 30.0)]);
assert!(obv.is_ready());
obv.reset();
assert!(!obv.is_ready());
assert_eq!(obv.value(), None);
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Parabolic SAR (Wilder).
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Trade direction in the SAR state machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trend {
Up,
Down,
}
/// Parabolic Stop And Reverse.
///
/// Implementation follows Wilder's original recursion: each step computes a new
/// SAR from the previous SAR, extreme point (EP) and acceleration factor (AF);
/// the trend flips when price crosses the SAR.
#[derive(Debug, Clone)]
pub struct Psar {
af_start: f64,
af_step: f64,
af_max: f64,
initialised: bool,
prev_high: f64,
prev_low: f64,
trend: Trend,
sar: f64,
ep: f64,
af: f64,
}
impl Psar {
/// Construct PSAR with explicit acceleration parameters.
///
/// # Errors
/// Returns [`Error::NonPositiveMultiplier`] / [`Error::InvalidPeriod`] for invalid params.
pub fn new(af_start: f64, af_step: f64, af_max: f64) -> Result<Self> {
if !af_start.is_finite() || !af_step.is_finite() || !af_max.is_finite() {
return Err(Error::NonPositiveMultiplier);
}
if af_start <= 0.0 || af_step <= 0.0 || af_max <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if af_start > af_max {
return Err(Error::InvalidPeriod {
message: "af_start must be <= af_max",
});
}
Ok(Self {
af_start,
af_step,
af_max,
initialised: false,
prev_high: 0.0,
prev_low: 0.0,
trend: Trend::Up,
sar: 0.0,
ep: 0.0,
af: af_start,
})
}
/// Wilder's defaults: `(0.02, 0.02, 0.20)`.
pub fn classic() -> Self {
Self::new(0.02, 0.02, 0.20).expect("classic PSAR params are valid")
}
}
impl Indicator for Psar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if !self.initialised {
// Seed: the first emitted SAR comes on the second candle. Initial trend
// is chosen by whether the second close is above or below the first.
self.prev_high = candle.high;
self.prev_low = candle.low;
self.sar = candle.low;
self.ep = candle.high;
self.trend = Trend::Up;
self.af = self.af_start;
self.initialised = true;
return None;
}
// Predicted SAR for this period (before clamping to prior two extremes).
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
// Wilder rule: SAR cannot penetrate today's or yesterday's range.
let prev_h = self.prev_high;
let prev_l = self.prev_low;
new_sar = match self.trend {
Trend::Up => new_sar.min(prev_l).min(candle.low),
Trend::Down => new_sar.max(prev_h).max(candle.high),
};
let mut output_sar = new_sar;
// Check for trend reversal.
let reversed = match self.trend {
Trend::Up => candle.low <= new_sar,
Trend::Down => candle.high >= new_sar,
};
if reversed {
// Flip trend, reset AF and EP, place SAR at prior EP.
output_sar = self.ep;
self.trend = match self.trend {
Trend::Up => Trend::Down,
Trend::Down => Trend::Up,
};
self.ep = match self.trend {
Trend::Up => candle.high,
Trend::Down => candle.low,
};
self.af = self.af_start;
} else {
// Update EP and AF if a new extreme has been reached.
match self.trend {
Trend::Up => {
if candle.high > self.ep {
self.ep = candle.high;
self.af = (self.af + self.af_step).min(self.af_max);
}
}
Trend::Down => {
if candle.low < self.ep {
self.ep = candle.low;
self.af = (self.af + self.af_step).min(self.af_max);
}
}
}
}
self.sar = output_sar;
self.prev_high = candle.high;
self.prev_low = candle.low;
Some(output_sar)
}
fn reset(&mut self) {
self.initialised = false;
self.af = self.af_start;
self.sar = 0.0;
self.ep = 0.0;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.initialised
}
fn name(&self) -> &'static str {
"PSAR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn first_candle_returns_none() {
let mut psar = Psar::classic();
assert_eq!(psar.update(c(11.0, 9.0, 10.0)), None);
}
#[test]
fn pure_uptrend_sar_below_lows() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut psar = Psar::classic();
for (i, sar) in psar.batch(&candles).into_iter().enumerate() {
if let Some(s) = sar {
assert!(
s <= candles[i].low + 1e-9,
"SAR {s} should be <= low {} at i={i}",
candles[i].low
);
}
}
}
#[test]
fn pure_downtrend_sar_above_highs() {
let candles: Vec<Candle> = (0..40)
.rev()
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut psar = Psar::classic();
let outs = psar.batch(&candles);
// After the trend establishes downward, SAR should sit above highs.
for (i, sar) in outs.into_iter().enumerate().skip(5) {
if let Some(s) = sar {
assert!(s >= candles[i].high - 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = Psar::classic();
let mut b = Psar::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn rejects_invalid_params() {
assert!(Psar::new(0.0, 0.02, 0.20).is_err());
assert!(Psar::new(0.02, 0.0, 0.20).is_err());
assert!(Psar::new(0.30, 0.02, 0.20).is_err());
assert!(Psar::new(f64::NAN, 0.02, 0.20).is_err());
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Rate of Change (ROC).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change as a percentage: `(close - close[period]) / close[period] * 100`.
#[derive(Debug, Clone)]
pub struct Roc {
period: usize,
window: VecDeque<f64>,
}
impl Roc {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Roc {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
if prev == 0.0 {
return Some(0.0);
}
Some((input - prev) / prev * 100.0)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROC"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_zero() {
let mut roc = Roc::new(5).unwrap();
let out = roc.batch(&[10.0_f64; 20]);
for v in out.iter().skip(5).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn known_value() {
// ROC(3) where prev = 100, now = 110 -> 10%
let mut roc = Roc::new(3).unwrap();
let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 2.0).collect();
let mut a = Roc::new(5).unwrap();
let mut b = Roc::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut roc = Roc::new(5).unwrap();
roc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert!(roc.is_ready());
roc.reset();
assert!(!roc.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Roc::new(0).is_err());
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Relative Strength Index using Wilder's smoothing.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Relative Strength Index (Wilder, 1978).
///
/// Uses Wilder's smoothing (an EMA with `alpha = 1 / period`). The first output
/// is produced after `period + 1` inputs: the seed averages the first `period`
/// gains and losses, and the first emitted RSI corresponds to the input at
/// index `period`.
#[derive(Debug, Clone)]
pub struct Rsi {
period: usize,
prev_close: Option<f64>,
// Wilder seeds with the simple average of the first `period` gains/losses,
// then transitions to recursive smoothing.
seed_buf_gains: Vec<f64>,
seed_buf_losses: Vec<f64>,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last_value: Option<f64>,
}
impl Rsi {
/// Construct an RSI with the given Wilder period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_buf_gains: Vec::with_capacity(period),
seed_buf_losses: Vec::with_capacity(period),
avg_gain: None,
avg_loss: None,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
if avg_loss == 0.0 {
if avg_gain == 0.0 {
// No movement at all -> RSI undefined; standard convention returns 50.
50.0
} else {
100.0
}
} else {
let rs = avg_gain / avg_loss;
100.0 - 100.0 / (1.0 + rs)
}
}
}
impl Indicator for Rsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(input);
return None;
};
self.prev_close = Some(input);
let diff = input - prev;
let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = if diff < 0.0 { -diff } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last_value = Some(v);
return Some(v);
}
self.seed_buf_gains.push(gain);
self.seed_buf_losses.push(loss);
if self.seed_buf_gains.len() == self.period {
let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64;
let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last_value = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf_gains.clear();
self.seed_buf_losses.clear();
self.avg_gain = None;
self.avg_loss = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"RSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Rsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn warmup_period_is_period_plus_one() {
let rsi = Rsi::new(14).unwrap();
assert_eq!(rsi.warmup_period(), 15);
}
#[test]
fn first_emission_at_index_period() {
// RSI(14) needs 14 diffs => 15 inputs before first value.
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let mut rsi = Rsi::new(14).unwrap();
let out = rsi.batch(&prices);
// indices 0..14 -> None, index 14 -> first Some
for x in &out[..14] {
assert!(x.is_none());
}
assert!(out[14].is_some());
}
#[test]
fn pure_uptrend_yields_rsi_100() {
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let mut rsi = Rsi::new(14).unwrap();
let out = rsi.batch(&prices);
// All diffs are positive => avg_loss == 0 => RSI == 100
for v in out.iter().filter_map(|x| x.as_ref()) {
assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
}
}
#[test]
fn pure_downtrend_yields_rsi_0() {
let prices: Vec<f64> = (1..=20).rev().map(f64::from).collect();
let mut rsi = Rsi::new(14).unwrap();
let out = rsi.batch(&prices);
for v in out.iter().filter_map(|x| x.as_ref()) {
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn flat_series_yields_rsi_50() {
let prices = [10.0_f64; 30];
let mut rsi = Rsi::new(14).unwrap();
let out = rsi.batch(&prices);
for v in out.iter().filter_map(|x| x.as_ref()) {
assert_relative_eq!(*v, 50.0, epsilon = 1e-12);
}
}
#[test]
fn classic_wilder_textbook_values() {
// Wilder's original example from "New Concepts in Technical Trading Systems",
// 14-period RSI. We compute the first value at index 14 and compare to the
// value Wilder publishes (~70.46).
// Source: classic textbook table, reproduced in many references (e.g. Investopedia).
let prices = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03,
45.61, 46.28, 46.28,
];
let mut rsi = Rsi::new(14).unwrap();
let out = rsi.batch(&prices);
let first = out[14].expect("first RSI emitted at index period");
assert_relative_eq!(first, 70.464, epsilon = 0.05);
}
#[test]
fn rsi_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 rsi = Rsi::new(14).unwrap();
for x in rsi.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&x), "RSI out of range: {x}");
}
}
#[test]
fn reset_clears_state() {
let mut rsi = Rsi::new(5).unwrap();
rsi.batch(&[1.0, 2.0, 3.0, 2.0, 4.0, 5.0, 6.0]);
assert!(rsi.is_ready());
rsi.reset();
assert!(!rsi.is_ready());
assert_eq!(rsi.update(1.0), None);
}
#[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 = Rsi::new(7).unwrap();
let mut b = Rsi::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Simple Moving Average.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Simple Moving Average over a fixed window.
///
/// Maintains a rolling sum so each update is O(1). Output equals
/// `sum(last `period` prices) / period` once the window is full; `None` before.
#[derive(Debug, Clone)]
pub struct Sma {
period: usize,
window: VecDeque<f64>,
sum: f64,
}
impl Sma {
/// Construct a new SMA with the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period {
Some(self.sum / self.period as f64)
} else {
None
}
}
}
impl Indicator for Sma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
// Drop the oldest from the sum to keep numerical drift bounded by recomputing
// the sum after each pop; a single subtract works in O(1) and is acceptable
// here because we use f64 throughout.
let old = self.window.pop_front().expect("window non-empty");
self.sum -= old;
}
self.window.push_back(input);
self.sum += input;
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Sma::new(0), Err(Error::PeriodZero)));
}
#[test]
fn warmup_returns_none() {
let mut sma = Sma::new(3).unwrap();
assert_eq!(sma.update(1.0), None);
assert_eq!(sma.update(2.0), None);
assert_eq!(sma.update(3.0), Some(2.0));
}
#[test]
fn rolls_window_after_full() {
let mut sma = Sma::new(3).unwrap();
let out: Vec<_> = [1.0, 2.0, 3.0, 4.0, 5.0]
.iter()
.map(|p| sma.update(*p))
.collect();
assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]);
}
#[test]
fn period_one_is_pass_through() {
let mut sma = Sma::new(1).unwrap();
assert_eq!(sma.update(5.0), Some(5.0));
assert_eq!(sma.update(10.0), Some(10.0));
}
#[test]
fn ignores_non_finite_input_but_keeps_state() {
let mut sma = Sma::new(3).unwrap();
sma.update(1.0);
sma.update(2.0);
sma.update(3.0);
assert_eq!(sma.update(f64::NAN), Some(2.0));
assert_eq!(sma.update(f64::INFINITY), Some(2.0));
// Non-finite inputs were not pushed; window still holds 1,2,3.
assert_eq!(sma.update(6.0), Some((2.0 + 3.0 + 6.0) / 3.0));
}
#[test]
fn reset_clears_state() {
let mut sma = Sma::new(3).unwrap();
sma.batch(&[1.0, 2.0, 3.0]);
assert!(sma.is_ready());
sma.reset();
assert!(!sma.is_ready());
assert_eq!(sma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let mut a = Sma::new(5).unwrap();
let batch = a.batch(&prices);
let mut b = Sma::new(5).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn known_reference_values() {
// SMA(3) of [2, 4, 6, 8, 10] -> [_, _, 4, 6, 8]
let mut sma = Sma::new(3).unwrap();
let out = sma.batch(&[2.0, 4.0, 6.0, 8.0, 10.0]);
assert_eq!(out[2], Some(4.0));
assert_eq!(out[3], Some(6.0));
assert_eq!(out[4], Some(8.0));
}
#[test]
fn constant_series_yields_constant_sma() {
let mut sma = Sma::new(5).unwrap();
let v = sma.batch(&[7.0; 10]);
for x in v.iter().skip(4) {
assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12);
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn sma_matches_naive_definition(
period in 1usize..20,
prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..200),
) {
let mut sma = Sma::new(period).unwrap();
let stream: Vec<_> = prices.iter().map(|p| sma.update(*p)).collect();
for (i, got) in stream.iter().enumerate() {
if i + 1 < period {
proptest::prop_assert!(got.is_none());
} else {
let window = &prices[i + 1 - period..=i];
let expected = window.iter().sum::<f64>() / period as f64;
let actual = got.expect("ready");
proptest::prop_assert!(
(actual - expected).abs() < 1e-9,
"i={i} actual={actual} expected={expected}"
);
}
}
}
}
}
@@ -0,0 +1,315 @@
//! Stochastic Oscillator (%K and %D).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stochastic Oscillator output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StochasticOutput {
/// Raw %K: `100 * (close - LL) / (HH - LL)` over the lookback.
pub k: f64,
/// %D: SMA of %K over the smoothing period.
pub d: f64,
}
/// Fast Stochastic Oscillator.
///
/// Maintains rolling highest-high and lowest-low over the lookback period via a
/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series.
#[derive(Debug, Clone)]
pub struct Stochastic {
k_period: usize,
d_period: usize,
candles: VecDeque<Candle>,
// Monotonic deques over candle indices in the rolling window.
hh_idx: VecDeque<usize>, // indices of candidates for highest high (front = current max)
ll_idx: VecDeque<usize>, // indices of candidates for lowest low (front = current min)
// Absolute count of candles ever ingested. Used so monotonic-deque indices stay unique.
count: usize,
d_sma: Sma,
last_k: Option<f64>,
}
impl Stochastic {
/// Construct a stochastic with %K lookback and %D smoothing periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero.
pub fn new(k_period: usize, d_period: usize) -> Result<Self> {
if k_period == 0 || d_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
k_period,
d_period,
candles: VecDeque::with_capacity(k_period),
hh_idx: VecDeque::with_capacity(k_period),
ll_idx: VecDeque::with_capacity(k_period),
count: 0,
d_sma: Sma::new(d_period)?,
last_k: None,
})
}
/// Classic fast stochastic: `%K = 14`, `%D = 3`.
pub fn classic() -> Self {
Self::new(14, 3).expect("classic stochastic periods are valid")
}
/// Configured `(k_period, d_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.k_period, self.d_period)
}
fn push_window(&mut self, candle: Candle) {
let idx = self.count;
self.count += 1;
// Drop deque entries that are outside the window.
let oldest_keep_idx = idx.saturating_sub(self.k_period - 1);
while let Some(&front) = self.hh_idx.front() {
if front < oldest_keep_idx {
self.hh_idx.pop_front();
} else {
break;
}
}
while let Some(&front) = self.ll_idx.front() {
if front < oldest_keep_idx {
self.ll_idx.pop_front();
} else {
break;
}
}
// Maintain monotonic-decreasing deque for highs.
while let Some(&back) = self.hh_idx.back() {
let back_off = back - idx.saturating_sub(self.candles.len());
if self.candles[back_off].high <= candle.high {
self.hh_idx.pop_back();
} else {
break;
}
}
self.hh_idx.push_back(idx);
// Maintain monotonic-increasing deque for lows.
while let Some(&back) = self.ll_idx.back() {
let back_off = back - idx.saturating_sub(self.candles.len());
if self.candles[back_off].low >= candle.low {
self.ll_idx.pop_back();
} else {
break;
}
}
self.ll_idx.push_back(idx);
if self.candles.len() == self.k_period {
self.candles.pop_front();
}
self.candles.push_back(candle);
}
fn current_extremes(&self) -> (f64, f64) {
let base = self.count - self.candles.len();
let hi = self.candles[self.hh_idx[0] - base].high;
let lo = self.candles[self.ll_idx[0] - base].low;
(hi, lo)
}
}
impl Indicator for Stochastic {
type Input = Candle;
type Output = StochasticOutput;
fn update(&mut self, candle: Candle) -> Option<StochasticOutput> {
self.push_window(candle);
if self.candles.len() < self.k_period {
return None;
}
let (hh, ll) = self.current_extremes();
let range = hh - ll;
let k = if range == 0.0 {
// Flat range; convention: 50 (neutral, like RSI on flat input).
50.0
} else {
100.0 * (candle.close - ll) / range
};
self.last_k = Some(k);
let d = self.d_sma.update(k)?;
Some(StochasticOutput { k, d })
}
fn reset(&mut self) {
self.candles.clear();
self.hh_idx.clear();
self.ll_idx.clear();
self.count = 0;
self.d_sma.reset();
self.last_k = None;
}
fn warmup_period(&self) -> usize {
self.k_period + self.d_period - 1
}
fn is_ready(&self) -> bool {
self.d_sma.is_ready()
}
fn name(&self) -> &'static str {
"Stochastic"
}
}
#[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()
}
/// Naive %K computation for cross-checks.
fn naive_k(candles: &[Candle], k_period: usize) -> Vec<Option<f64>> {
candles
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < k_period {
None
} else {
let w = &candles[i + 1 - k_period..=i];
let hh = w.iter().map(|x| x.high).fold(f64::NEG_INFINITY, f64::max);
let ll = w.iter().map(|x| x.low).fold(f64::INFINITY, f64::min);
let range = hh - ll;
let cl = candles[i].close;
Some(if range == 0.0 {
50.0
} else {
100.0 * (cl - ll) / range
})
}
})
.collect()
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(Stochastic::new(0, 3), Err(Error::PeriodZero)));
assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
}
#[test]
fn close_at_high_yields_k_100() {
let candles = vec![
c(10.0, 8.0, 9.0),
c(11.0, 9.0, 10.0),
c(12.0, 10.0, 12.0), // close == high == HH
];
let mut s = Stochastic::new(3, 1).unwrap();
let out = s.batch(&candles);
assert_relative_eq!(out[2].unwrap().k, 100.0, epsilon = 1e-12);
}
#[test]
fn close_at_low_yields_k_0() {
let candles = vec![
c(10.0, 8.0, 9.0),
c(11.0, 9.0, 10.0),
c(12.0, 8.0, 8.0), // close == LL
];
let mut s = Stochastic::new(3, 1).unwrap();
let out = s.batch(&candles);
assert_relative_eq!(out[2].unwrap().k, 0.0, epsilon = 1e-12);
}
#[test]
fn flat_range_yields_k_50() {
let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut s = Stochastic::new(14, 3).unwrap();
for o in s.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
}
}
#[test]
fn k_matches_naive() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 50.0 + (f64::from(i) * 0.4).sin() * 10.0;
c(mid + 2.0, mid - 2.0, mid + (f64::from(i) * 0.7).cos())
})
.collect();
let mut s = Stochastic::new(14, 3).unwrap();
let out = s.batch(&candles);
let naive = naive_k(&candles, 14);
for (i, got) in out.iter().enumerate() {
if let Some(o) = got {
let n = naive[i].expect("naive ready");
assert_relative_eq!(o.k, n, epsilon = 1e-9);
}
}
}
#[test]
fn d_is_sma_of_k() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 50.0 + f64::from(i).sin() * 5.0;
c(mid + 1.5, mid - 1.5, mid)
})
.collect();
let mut s = Stochastic::new(14, 3).unwrap();
let out = s.batch(&candles);
// The naive %K series gives us the ground-truth values that %D should average.
let naive_ks = naive_k(&candles, 14);
// The first emitted %D corresponds to the SMA of the first three valid %K values
// (i.e. those at indices 13, 14, 15). At that point %D becomes ready, and the
// first `Some(_)` output appears at index 15.
let first_emit_idx = out
.iter()
.position(Option::is_some)
.expect("d eventually emits");
let first_d = out[first_emit_idx].unwrap().d;
let k_window = &naive_ks[first_emit_idx - 2..=first_emit_idx];
let want = k_window
.iter()
.map(|v| v.expect("naive K ready inside window"))
.sum::<f64>()
/ 3.0;
assert_relative_eq!(first_d, want, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let mid = 100.0 + f64::from(i) * 0.5;
c(mid + 2.0, mid - 2.0, mid)
})
.collect();
let mut a = Stochastic::new(14, 3).unwrap();
let mut b = Stochastic::new(14, 3).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut s = Stochastic::new(5, 3).unwrap();
let candles: Vec<Candle> = (0..10).map(|i| c(10.0 + f64::from(i), 5.0, 7.0)).collect();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(candles[0]), None);
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Triple Exponential Moving Average (TEMA).
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Triple Exponential Moving Average: `3 * EMA1 - 3 * EMA2 + EMA3`,
/// where `EMA2 = EMA(EMA1)` and `EMA3 = EMA(EMA2)`.
///
/// Reduces lag further than DEMA at the cost of more responsiveness to noise.
#[derive(Debug, Clone)]
pub struct Tema {
ema1: Ema,
ema2: Ema,
ema3: Ema,
period: usize,
}
impl Tema {
/// # Errors
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
ema3: Ema::new(period)?,
period,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Tema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
let e3 = self.ema3.update(e2)?;
Some(3.0 * e1 - 3.0 * e2 + e3)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
self.ema3.reset();
}
fn warmup_period(&self) -> usize {
3 * self.period - 2
}
fn is_ready(&self) -> bool {
self.ema3.is_ready()
}
fn name(&self) -> &'static str {
"TEMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_tema() {
let mut tema = Tema::new(5).unwrap();
let out = tema.batch(&[42.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = Tema::new(5).unwrap();
let mut b = Tema::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut tema = Tema::new(5).unwrap();
tema.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(tema.is_ready());
tema.reset();
assert!(!tema.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Tema::new(0).is_err());
}
}
+131
View File
@@ -0,0 +1,131 @@
//! TRIX: triple-smoothed EMA percent rate of change.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// TRIX: the 1-period percent rate of change of a triple-smoothed EMA.
///
/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where
/// `TR_t = EMA(EMA(EMA(price)))`.
#[derive(Debug, Clone)]
pub struct Trix {
ema1: Ema,
ema2: Ema,
ema3: Ema,
prev_tr: Option<f64>,
period: usize,
}
impl Trix {
/// # Errors
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
ema3: Ema::new(period)?,
prev_tr: None,
period,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Trix {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
let e3 = self.ema3.update(e2)?;
match self.prev_tr {
Some(prev) if prev != 0.0 => {
let trix = 100.0 * (e3 - prev) / prev;
self.prev_tr = Some(e3);
Some(trix)
}
Some(_) => {
self.prev_tr = Some(e3);
Some(0.0)
}
None => {
self.prev_tr = Some(e3);
None
}
}
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
self.ema3.reset();
self.prev_tr = None;
}
fn warmup_period(&self) -> usize {
// Triple EMA seeds at 3*period-2; plus one extra for the rate of change.
3 * self.period - 1
}
fn is_ready(&self) -> bool {
self.prev_tr.is_some() && self.ema3.is_ready()
}
fn name(&self) -> &'static str {
"TRIX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_zero_trix() {
let mut trix = Trix::new(5).unwrap();
let out = trix.batch(&[100.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
}
#[test]
fn rising_series_eventually_positive_trix() {
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let mut trix = Trix::new(5).unwrap();
let last = trix.batch(&prices).into_iter().flatten().last().unwrap();
assert!(last > 0.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 1.3).collect();
let mut a = Trix::new(7).unwrap();
let mut b = Trix::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut trix = Trix::new(5).unwrap();
trix.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(trix.is_ready());
trix.reset();
assert!(!trix.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Trix::new(0).is_err());
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Volume-Weighted Average Price (VWAP).
//!
//! Two variants are offered: a cumulative `Vwap` that runs forever (the
//! intraday convention), and a rolling-window `RollingVwap` for streaming bots
//! that need a finite-memory price benchmark.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Cumulative session VWAP. Call [`Indicator::reset`] at the start of each
/// session (e.g. trading-day boundary) to restart the accumulation.
#[derive(Debug, Clone, Default)]
pub struct Vwap {
sum_pv: f64,
sum_v: f64,
has_emitted: bool,
}
impl Vwap {
/// Construct a fresh cumulative VWAP.
pub const fn new() -> Self {
Self {
sum_pv: 0.0,
sum_v: 0.0,
has_emitted: false,
}
}
/// Current VWAP if at least one candle with non-zero volume has been observed.
pub fn value(&self) -> Option<f64> {
if self.sum_v == 0.0 {
None
} else {
Some(self.sum_pv / self.sum_v)
}
}
}
impl Indicator for Vwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"VWAP"
}
}
/// Rolling-window VWAP: a finite-memory variant for bots that don't want
/// unbounded accumulation.
#[derive(Debug, Clone)]
pub struct RollingVwap {
period: usize,
window: VecDeque<(f64, f64)>, // (typical_price * volume, volume)
sum_pv: f64,
sum_v: f64,
}
impl RollingVwap {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_pv: 0.0,
sum_v: 0.0,
})
}
/// Configured rolling window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingVwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let pv = candle.typical_price() * candle.volume;
if self.window.len() == self.period {
let (old_pv, old_v) = self.window.pop_front().expect("non-empty");
self.sum_pv -= old_pv;
self.sum_v -= old_v;
}
self.window.push_back((pv, candle.volume));
self.sum_pv += pv;
self.sum_v += candle.volume;
if self.window.len() < self.period || self.sum_v == 0.0 {
return None;
}
Some(self.sum_pv / self.sum_v)
}
fn reset(&mut self) {
self.window.clear();
self.sum_pv = 0.0;
self.sum_v = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period && self.sum_v > 0.0
}
fn name(&self) -> &'static str {
"RollingVWAP"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(price: f64, volume: f64) -> Candle {
Candle::new(price, price, price, price, volume, 0).unwrap()
}
#[test]
fn cumulative_vwap_equal_volumes_equals_mean() {
let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0)];
let mut v = Vwap::new();
let out = v.batch(&candles);
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
}
#[test]
fn cumulative_vwap_weighted() {
// Two candles: 10@1 and 20@3 -> (10*1 + 20*3) / (1+3) = 70/4 = 17.5
let candles = vec![c(10.0, 1.0), c(20.0, 3.0)];
let mut v = Vwap::new();
let out = v.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12);
}
#[test]
fn rolling_vwap_window_slides() {
let candles = vec![c(10.0, 1.0), c(20.0, 1.0), c(30.0, 1.0), c(40.0, 1.0)];
let mut v = RollingVwap::new(3).unwrap();
let out = v.batch(&candles);
assert!(out[1].is_none());
// index 2 -> (10+20+30)/3 = 20
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12);
// index 3 -> (20+30+40)/3 = 30
assert_relative_eq!(out[3].unwrap(), 30.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming_cumulative() {
let candles: Vec<Candle> = (1..20).map(|i| c(f64::from(i), 1.0)).collect();
let mut a = Vwap::new();
let mut b = Vwap::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn batch_equals_streaming_rolling() {
let candles: Vec<Candle> = (1..30)
.map(|i| c(f64::from(i), f64::from(i % 5 + 1)))
.collect();
let mut a = RollingVwap::new(10).unwrap();
let mut b = RollingVwap::new(10).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn rolling_rejects_zero_period() {
assert!(RollingVwap::new(0).is_err());
}
}
@@ -0,0 +1,141 @@
//! Williams %R.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the lookback window.
///
/// Values lie in `[-100, 0]` and approximate the mirror image of the fast
/// Stochastic %K.
#[derive(Debug, Clone)]
pub struct WilliamsR {
period: usize,
candles: VecDeque<Candle>,
}
impl WilliamsR {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for WilliamsR {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period {
return None;
}
let hh = self
.candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let ll = self
.candles
.iter()
.map(|c| c.low)
.fold(f64::INFINITY, f64::min);
let range = hh - ll;
if range == 0.0 {
return Some(-50.0);
}
Some(-100.0 * (hh - candle.close) / range)
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period
}
fn name(&self) -> &'static str {
"WilliamsR"
}
}
#[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 close_at_high_yields_zero() {
let candles = vec![c(10.0, 8.0, 9.0), c(11.0, 9.0, 10.0), c(12.0, 10.0, 12.0)];
let mut w = WilliamsR::new(3).unwrap();
let out = w.batch(&candles);
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn close_at_low_yields_minus_100() {
let candles = vec![c(12.0, 10.0, 11.0), c(11.0, 9.0, 10.0), c(10.0, 8.0, 8.0)];
let mut w = WilliamsR::new(3).unwrap();
let out = w.batch(&candles);
assert_relative_eq!(out[2].unwrap(), -100.0, epsilon = 1e-12);
}
#[test]
fn within_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let m = 50.0 + (f64::from(i) * 0.3).sin() * 5.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut w = WilliamsR::new(14).unwrap();
for v in w.batch(&candles).into_iter().flatten() {
assert!((-100.0..=0.0).contains(&v), "%R out of range: {v}");
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let mut a = WilliamsR::new(5).unwrap();
let mut b = WilliamsR::new(5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn rejects_zero_period() {
assert!(WilliamsR::new(0).is_err());
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Weighted Moving Average (linear weights).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Weighted Moving Average with linear weights `1, 2, ..., period`.
///
/// Output is `sum(weight_i * price_i) / sum(weights)`. Maintained incrementally in
/// O(1) by keeping the rolling sum of values and the rolling weighted sum.
#[derive(Debug, Clone)]
pub struct Wma {
period: usize,
window: VecDeque<f64>,
weight_sum: f64, // sum_i (weight_i * value_i)
value_sum: f64, // sum_i (value_i)
weights_total: f64,
}
impl Wma {
/// Construct a new WMA with the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let n = period as f64;
let weights_total = n * (n + 1.0) / 2.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
weight_sum: 0.0,
value_sum: 0.0,
weights_total,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period {
Some(self.weight_sum / self.weights_total)
} else {
None
}
}
}
impl Indicator for Wma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() < self.period {
// Warmup. Just accumulate; compute weight_sum once when the window first
// becomes full to avoid having to track changing weights during warmup.
self.window.push_back(input);
self.value_sum += input;
if self.window.len() == self.period {
self.weight_sum = self
.window
.iter()
.enumerate()
.map(|(i, v)| (i as f64 + 1.0) * v)
.sum();
}
return self.value();
}
// Steady state: slide the window. With weights [1, 2, ..., period],
// new_weight_sum = old_weight_sum - old_value_sum + period * new_input
// because every retained element's weight drops by one and the newcomer
// enters at weight = period. Order matters: subtract `value_sum` BEFORE
// updating it.
let oldest = self.window.pop_front().expect("window non-empty");
self.weight_sum = self.weight_sum - self.value_sum + self.period as f64 * input;
self.value_sum = self.value_sum - oldest + input;
self.window.push_back(input);
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.weight_sum = 0.0;
self.value_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"WMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit weighted average over a window.
fn wma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let weights_total = (period as f64) * (period as f64 + 1.0) / 2.0;
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let s: f64 = window
.iter()
.enumerate()
.map(|(j, p)| (j as f64 + 1.0) * p)
.sum();
Some(s / weights_total)
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Wma::new(0), Err(Error::PeriodZero)));
}
#[test]
fn warmup_returns_none() {
let mut wma = Wma::new(3).unwrap();
assert_eq!(wma.update(1.0), None);
assert_eq!(wma.update(2.0), None);
// WMA(3) of [1,2,3]: oldest = 1 (weight 1), middle = 2 (weight 2), newest = 3 (weight 3)
// -> (1*1 + 2*2 + 3*3) / (1+2+3) = 14/6
assert_relative_eq!(wma.update(3.0).unwrap(), 14.0 / 6.0, epsilon = 1e-12);
}
#[test]
fn known_values_period_4() {
// WMA(4) weights 1,2,3,4 (total 10); inputs [1,2,3,4]:
// (1*1 + 2*2 + 3*3 + 4*4) / 10 = (1+4+9+16)/10 = 30/10 = 3.0
let mut wma = Wma::new(4).unwrap();
let v = wma.batch(&[1.0, 2.0, 3.0, 4.0]);
assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn matches_naive_over_random_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect();
let mut wma = Wma::new(7).unwrap();
let got = wma.batch(&prices);
let want = wma_naive(&prices, 7);
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => assert_relative_eq!(*a, *b, epsilon = 1e-9),
_ => panic!("warmup mismatch"),
}
}
}
#[test]
fn period_one_is_pass_through() {
let mut wma = Wma::new(1).unwrap();
assert_relative_eq!(wma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(wma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut wma = Wma::new(4).unwrap();
wma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(wma.is_ready());
wma.reset();
assert!(!wma.is_ready());
assert_eq!(wma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5).collect();
let mut a = Wma::new(5).unwrap();
let mut b = Wma::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(-500.0_f64..500.0, 0..120),
) {
let mut wma = Wma::new(period).unwrap();
let got = wma.batch(&prices);
let want = wma_naive(&prices, 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-7,
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
//! `wickra-core`: streaming-first technical indicators.
//!
//! The core engine of Wickra. Every indicator is implemented as a state machine
//! that consumes inputs one at a time via [`Indicator::update`] in constant time.
//! Batch evaluation is provided as a blanket extension trait so the same code
//! path serves both online (tick-by-tick) and offline (historical) workloads.
//!
//! # Design
//!
//! - **Streaming-first.** State is held by the indicator instance, so a new value
//! only re-computes deltas, not the whole series.
//! - **Batch is free.** [`BatchExt::batch`] is a blanket implementation that
//! simply replays `update` over a slice. Writing one implementation gives both
//! APIs.
//! - **Composable.** Indicators implement [`Indicator<Input = f64, Output = f64>`]
//! wherever they conceptually take a price, so they can be chained via
//! [`Chain`].
//! - **No `unsafe`.** The crate forbids `unsafe_code` in the workspace lints.
//!
//! # Quick start
//!
//! ```
//! use wickra_core::{BatchExt, Indicator, Sma};
//!
//! // Streaming:
//! let mut sma = Sma::new(3).unwrap();
//! assert_eq!(sma.update(1.0), None);
//! assert_eq!(sma.update(2.0), None);
//! assert_eq!(sma.update(3.0), Some(2.0));
//!
//! // Batch (replays `update` internally):
//! let mut sma = Sma::new(3).unwrap();
//! let out = sma.batch(&[1.0, 2.0, 3.0, 4.0]);
//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0)]);
//! ```
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
mod error;
mod ohlcv;
mod traits;
pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput,
Cci, Dema, Donchian, DonchianOutput, Ema, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator,
MacdOutput, Mfi, Obv, Psar, Roc, RollingVwap, Rsi, Sma, Stochastic, StochasticOutput, Tema,
Trix, Vwap, WilliamsR, Wma,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
+285
View File
@@ -0,0 +1,285 @@
//! OHLCV value types: candles and ticks.
use crate::error::{Error, Result};
/// A single OHLCV bar.
///
/// Timestamps are unitless `i64` values so callers can use whatever epoch resolution
/// they prefer (milliseconds, microseconds, seconds…). Wickra never inspects them
/// numerically beyond passing them through.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Candle {
/// Bar open price.
pub open: f64,
/// Bar high price.
pub high: f64,
/// Bar low price.
pub low: f64,
/// Bar close price.
pub close: f64,
/// Bar volume.
pub volume: f64,
/// Bar timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl Candle {
/// Construct a new candle, validating the OHLC relationships and finiteness.
///
/// # Errors
///
/// Returns [`Error::InvalidCandle`] if any of these invariants are violated:
/// - `high >= max(open, close, low)`
/// - `low <= min(open, close, high)`
/// - all of `open`, `high`, `low`, `close`, `volume` are finite
/// - `volume >= 0`
pub fn new(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Self> {
if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
return Err(Error::InvalidCandle {
message: "open, high, low, close must all be finite",
});
}
if !volume.is_finite() {
return Err(Error::InvalidCandle {
message: "volume must be finite",
});
}
if volume < 0.0 {
return Err(Error::InvalidCandle {
message: "volume must be non-negative",
});
}
if high < low {
return Err(Error::InvalidCandle {
message: "high must be >= low",
});
}
if high < open || high < close {
return Err(Error::InvalidCandle {
message: "high must be >= open and >= close",
});
}
if low > open || low > close {
return Err(Error::InvalidCandle {
message: "low must be <= open and <= close",
});
}
Ok(Self {
open,
high,
low,
close,
volume,
timestamp,
})
}
/// Construct a candle without validation. The caller asserts that all OHLC
/// invariants hold and that no field is NaN or infinite.
pub const fn new_unchecked(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Self {
Self {
open,
high,
low,
close,
volume,
timestamp,
}
}
/// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc.
#[inline]
pub fn typical_price(&self) -> f64 {
(self.high + self.low + self.close) / 3.0
}
/// The mid price `(high + low) / 2`.
#[inline]
pub fn median_price(&self) -> f64 {
(self.high + self.low) / 2.0
}
/// The weighted close `(high + low + 2*close) / 4`.
#[inline]
pub fn weighted_close(&self) -> f64 {
(self.high + self.low + 2.0 * self.close) / 4.0
}
/// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
/// If no previous close is supplied, falls back to `high - low`.
#[inline]
pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
let hl = self.high - self.low;
match prev_close {
Some(prev) => {
let hp = (self.high - prev).abs();
let lp = (self.low - prev).abs();
hl.max(hp).max(lp)
}
None => hl,
}
}
}
/// A single trade tick.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tick {
/// Trade price.
pub price: f64,
/// Trade size.
pub volume: f64,
/// Trade timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl Tick {
/// Construct a new tick, validating finiteness and non-negativity of volume.
///
/// # Errors
///
/// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite,
/// or [`Error::InvalidCandle`] for `volume < 0`.
pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
if !price.is_finite() || !volume.is_finite() {
return Err(Error::NonFiniteInput);
}
if volume < 0.0 {
return Err(Error::InvalidCandle {
message: "tick volume must be non-negative",
});
}
Ok(Self {
price,
volume,
timestamp,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candle_new_accepts_valid_ohlc() {
let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
assert_eq!(c.open, 10.0);
assert_eq!(c.high, 11.0);
assert_eq!(c.low, 9.0);
assert_eq!(c.close, 10.5);
assert_eq!(c.volume, 100.0);
assert_eq!(c.timestamp, 1);
}
#[test]
fn candle_new_rejects_high_below_low() {
let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_high_below_close() {
let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_low_above_open() {
let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_negative_volume() {
let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_nan_price() {
let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_typical_price() {
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
}
#[test]
fn candle_median_price() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.median_price(), 10.0);
}
#[test]
fn candle_weighted_close() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
}
#[test]
fn candle_true_range_without_prev() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(None), 4.0);
}
#[test]
fn candle_true_range_with_gap_up() {
// Previous close 6, today's range 8-12: gap covered by |H-prev|=6
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(6.0)), 6.0);
}
#[test]
fn candle_true_range_with_gap_down() {
// Previous close 14, today's range 8-12: gap covered by |L-prev|=6
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(14.0)), 6.0);
}
#[test]
fn tick_new_accepts_valid() {
let t = Tick::new(100.5, 0.5, 42).unwrap();
assert_eq!(t.price, 100.5);
assert_eq!(t.volume, 0.5);
assert_eq!(t.timestamp, 42);
}
#[test]
fn tick_new_rejects_nan() {
assert!(matches!(
Tick::new(f64::NAN, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_inf() {
assert!(matches!(
Tick::new(f64::INFINITY, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_negative_volume() {
let err = Tick::new(100.0, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
}
+287
View File
@@ -0,0 +1,287 @@
//! Core traits: the [`Indicator`] state machine and the [`BatchExt`] blanket extension.
/// A streaming technical indicator.
///
/// Every indicator in Wickra implements this trait. The contract is:
///
/// - [`update`](Indicator::update) is called once per input point and must be O(1) in
/// the input length. Pre-existing buffered state may be touched, but no full
/// recomputation over the entire series is permitted.
/// - The returned `Option<Output>` is `None` while the indicator is still in its
/// *warmup* phase (insufficient inputs to produce a defined value), and `Some`
/// once it is ready.
/// - [`reset`](Indicator::reset) clears all state, returning the indicator to the
/// exact configuration it had immediately after construction.
///
/// Implementors that consume scalar prices use `Input = f64` so they automatically
/// gain access to chaining via [`Chain`].
pub trait Indicator {
/// Type of one input data point (typically `f64` for a price, or `Candle` / `Tick`).
type Input;
/// Type of one output value.
type Output;
/// Feed one new data point into the indicator and return the freshly computed
/// output, or `None` if the indicator is still warming up.
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
/// Reset all internal state, leaving the indicator equivalent to a freshly
/// constructed instance with the same parameters.
fn reset(&mut self);
/// Number of inputs required before the first non-`None` output can be produced.
fn warmup_period(&self) -> usize;
/// Whether the indicator has emitted at least one value since the last reset.
fn is_ready(&self) -> bool;
/// Stable, human-readable indicator name. Used by chaining and diagnostics.
fn name(&self) -> &'static str;
}
/// Blanket extension that adds batch evaluation to every [`Indicator`].
///
/// The naive `batch` simply replays `update` over a slice, which is always correct
/// because `update` is the only state transition. Concrete indicators may override
/// `batch` if they have a faster vectorized path; the default keeps the contract
/// `batch == repeated update`.
pub trait BatchExt: Indicator {
/// Run the indicator over a slice of inputs in order, returning one output (or
/// `None` during warmup) per input.
fn batch(&mut self, inputs: &[Self::Input]) -> Vec<Option<Self::Output>>
where
Self::Input: Clone,
{
let mut out = Vec::with_capacity(inputs.len());
for x in inputs {
out.push(self.update(x.clone()));
}
out
}
/// Run an independent copy of the indicator over each input series in parallel.
///
/// Each asset is processed by its own fresh instance built via `make`, so state
/// never leaks across assets. Requires the `parallel` feature (enabled by
/// default), which pulls in `rayon`.
#[cfg(feature = "parallel")]
fn batch_parallel<F>(
inputs_per_asset: &[Vec<Self::Input>],
make: F,
) -> Vec<Vec<Option<Self::Output>>>
where
Self: Sized + Send,
Self::Input: Sync + Clone,
Self::Output: Send,
F: Fn() -> Self + Sync + Send,
{
use rayon::prelude::*;
inputs_per_asset
.par_iter()
.map(|series| {
let mut ind = make();
ind.batch(series)
})
.collect()
}
}
impl<T: Indicator> BatchExt for T {}
/// Chain two indicators so the output of the first becomes the input of the second.
///
/// Both indicators must agree on `f64` as the bridging type, which is the common
/// case for price-in/value-out indicators. The chain itself is an indicator, so
/// chains can be nested arbitrarily.
///
/// # Example
///
/// ```
/// use wickra_core::{Chain, Ema, Indicator, Rsi};
///
/// // RSI(7) on top of EMA(14). EMA seeds at input 14, then RSI needs 7+1 more
/// // valid inputs to emit, so the chain becomes ready at input 21.
/// let mut chain = Chain::new(Ema::new(14).unwrap(), Rsi::new(7).unwrap());
/// for i in 1..=21 {
/// chain.update(f64::from(i));
/// }
/// assert!(chain.is_ready());
/// ```
#[derive(Debug, Clone)]
pub struct Chain<A, B>
where
A: Indicator<Input = f64, Output = f64>,
B: Indicator<Input = f64>,
{
first: A,
second: B,
}
impl<A, B> Chain<A, B>
where
A: Indicator<Input = f64, Output = f64>,
B: Indicator<Input = f64>,
{
/// Construct a chain whose inputs flow through `first` and then `second`.
pub const fn new(first: A, second: B) -> Self {
Self { first, second }
}
/// Add a third stage on top.
pub fn then<C>(self, third: C) -> Chain<Self, C>
where
C: Indicator<Input = f64>,
Self: Indicator<Input = f64, Output = f64>,
{
Chain::new(self, third)
}
/// Borrow the upstream indicator.
pub const fn first(&self) -> &A {
&self.first
}
/// Borrow the downstream indicator.
pub const fn second(&self) -> &B {
&self.second
}
}
impl<A, B> Indicator for Chain<A, B>
where
A: Indicator<Input = f64, Output = f64>,
B: Indicator<Input = f64>,
{
type Input = f64;
type Output = B::Output;
fn update(&mut self, input: f64) -> Option<Self::Output> {
self.first.update(input).and_then(|v| self.second.update(v))
}
fn reset(&mut self) {
self.first.reset();
self.second.reset();
}
fn warmup_period(&self) -> usize {
// Conservative upper bound: both stages must warm up.
self.first.warmup_period() + self.second.warmup_period()
}
fn is_ready(&self) -> bool {
self.first.is_ready() && self.second.is_ready()
}
fn name(&self) -> &'static str {
"Chain"
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A trivial test indicator: identity (passes input through).
#[derive(Debug, Default)]
struct Identity {
seen: bool,
}
impl Indicator for Identity {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
self.seen = true;
Some(input)
}
fn reset(&mut self) {
self.seen = false;
}
fn warmup_period(&self) -> usize {
0
}
fn is_ready(&self) -> bool {
self.seen
}
fn name(&self) -> &'static str {
"Identity"
}
}
/// Another trivial test indicator: scales input by 2.
#[derive(Debug, Default)]
struct Doubler {
seen: bool,
}
impl Indicator for Doubler {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
self.seen = true;
Some(input * 2.0)
}
fn reset(&mut self) {
self.seen = false;
}
fn warmup_period(&self) -> usize {
0
}
fn is_ready(&self) -> bool {
self.seen
}
fn name(&self) -> &'static str {
"Doubler"
}
}
#[test]
fn batch_replays_update() {
let mut id = Identity::default();
let out = id.batch(&[1.0, 2.0, 3.0]);
assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
}
#[test]
fn chain_pipes_first_into_second() {
let mut c = Chain::new(Doubler::default(), Doubler::default());
// 5 -> 10 -> 20
assert_eq!(c.update(5.0), Some(20.0));
}
#[test]
fn chain_is_ready_only_after_both_stages_emit() {
let mut c = Chain::new(Doubler::default(), Doubler::default());
assert!(!c.is_ready());
c.update(1.0);
assert!(c.is_ready());
}
#[test]
fn chain_reset_propagates() {
let mut c = Chain::new(Doubler::default(), Doubler::default());
c.update(1.0);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
}
#[test]
fn chain_three_levels_via_then() {
let c = Chain::new(Doubler::default(), Doubler::default()).then(Doubler::default());
let mut c = c;
// 1 -> 2 -> 4 -> 8
assert_eq!(c.update(1.0), Some(8.0));
}
#[cfg(feature = "parallel")]
#[test]
fn batch_parallel_runs_independent_instances() {
let series: Vec<Vec<f64>> = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
let out = Doubler::batch_parallel(&series, Doubler::default);
assert_eq!(out.len(), 2);
assert_eq!(out[0], vec![Some(2.0), Some(4.0), Some(6.0)]);
assert_eq!(out[1], vec![Some(8.0), Some(10.0), Some(12.0)]);
}
}
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "wickra-data"
description = "Data sources for Wickra: CSV readers, tick-to-candle aggregator, and live exchange feeds."
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
wickra-core = { workspace = true }
thiserror = { workspace = true }
csv = "1.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Async / live feeds are opt-in: only pulled when a `live-*` feature is requested.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "time", "io-util"], optional = true }
tokio-tungstenite = { version = "0.24", optional = true, features = ["native-tls"] }
futures-util = { version = "0.3", optional = true }
url = { version = "2", optional = true }
[features]
default = []
# Each exchange is gated so users only pay for the WS stack they actually want.
live-binance = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:url"]
[dev-dependencies]
approx = { workspace = true }
tempfile = "3"
wickra = { path = "../wickra" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
[[example]]
name = "live_binance"
path = "../../examples/rust/live_binance.rs"
required-features = ["live-binance"]
+226
View File
@@ -0,0 +1,226 @@
//! Roll trade ticks up into candles of an arbitrary timeframe.
use crate::error::{Error, Result};
use wickra_core::{Candle, Tick};
/// A candle bucket size measured in the same unit as the tick timestamps.
///
/// Wickra is unit-agnostic about timestamps: choose whichever makes sense for
/// your source (milliseconds for Binance trade events, microseconds for IB,
/// seconds for daily bars).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timeframe {
bucket: i64,
}
impl Timeframe {
/// Construct a timeframe with the given bucket size in the chosen unit.
///
/// # Errors
/// Returns [`Error::InvalidTimeframe`] if `bucket <= 0`.
pub fn new(bucket: i64) -> Result<Self> {
if bucket <= 0 {
return Err(Error::InvalidTimeframe(format!(
"bucket size must be positive, got {bucket}"
)));
}
Ok(Self { bucket })
}
/// Convenience: build a millisecond timeframe.
pub fn millis(ms: i64) -> Result<Self> {
Self::new(ms)
}
/// Convenience: build a seconds-resolution timeframe.
pub fn seconds(s: i64) -> Result<Self> {
Self::new(s)
}
/// One-minute timeframe in milliseconds (`60_000`).
pub fn one_minute_ms() -> Self {
Self::new(60_000).expect("60_000 > 0")
}
/// Bucket size.
pub const fn bucket(self) -> i64 {
self.bucket
}
/// Floor a raw timestamp to this timeframe's bucket boundary.
pub fn floor(self, ts: i64) -> i64 {
ts - ts.rem_euclid(self.bucket)
}
}
/// Incrementally builds candles out of arriving ticks.
///
/// Each call to [`TickAggregator::push`] returns `Some(Candle)` if a previously
/// open bar just closed (i.e. the new tick belongs to a new bucket). Use
/// [`TickAggregator::flush`] at the end of a stream to capture the final open
/// bar.
#[derive(Debug, Clone)]
pub struct TickAggregator {
timeframe: Timeframe,
open_bar: Option<OpenBar>,
}
#[derive(Debug, Clone, Copy)]
struct OpenBar {
bucket_start: i64,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
}
impl OpenBar {
fn from_tick(t: Tick, bucket_start: i64) -> Self {
Self {
bucket_start,
open: t.price,
high: t.price,
low: t.price,
close: t.price,
volume: t.volume,
}
}
fn absorb(&mut self, t: Tick) {
if t.price > self.high {
self.high = t.price;
}
if t.price < self.low {
self.low = t.price;
}
self.close = t.price;
self.volume += t.volume;
}
fn into_candle(self) -> Candle {
Candle::new_unchecked(
self.open,
self.high,
self.low,
self.close,
self.volume,
self.bucket_start,
)
}
}
impl TickAggregator {
/// Construct a new aggregator for the given timeframe.
pub fn new(timeframe: Timeframe) -> Self {
Self {
timeframe,
open_bar: None,
}
}
/// Push a tick. Returns `Some(Candle)` if a bar boundary was crossed and a
/// previously open bar just closed.
///
/// # Errors
/// Returns an error if `tick.timestamp` is strictly less than the start of
/// the currently open bar (out-of-order ticks are not supported).
pub fn push(&mut self, tick: Tick) -> Result<Option<Candle>> {
let bucket = self.timeframe.floor(tick.timestamp);
if let Some(mut bar) = self.open_bar {
if bucket < bar.bucket_start {
return Err(Error::Malformed(format!(
"tick timestamp {} is older than the open bar start {}",
tick.timestamp, bar.bucket_start
)));
}
if bucket > bar.bucket_start {
// Close the previous bar and start a new one with this tick.
self.open_bar = Some(OpenBar::from_tick(tick, bucket));
return Ok(Some(bar.into_candle()));
}
bar.absorb(tick);
self.open_bar = Some(bar);
return Ok(None);
}
self.open_bar = Some(OpenBar::from_tick(tick, bucket));
Ok(None)
}
/// Drain the currently open bar (if any) and return it. Useful at the end of
/// a backtest or when shutting down a live aggregator.
pub fn flush(&mut self) -> Option<Candle> {
self.open_bar.take().map(OpenBar::into_candle)
}
/// Configured timeframe.
pub const fn timeframe(&self) -> Timeframe {
self.timeframe
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t(price: f64, ts: i64) -> Tick {
Tick::new(price, 1.0, ts).unwrap()
}
#[test]
fn timeframe_rejects_non_positive() {
assert!(Timeframe::new(0).is_err());
assert!(Timeframe::new(-1).is_err());
}
#[test]
fn floors_to_bucket_boundary() {
let tf = Timeframe::new(100).unwrap();
assert_eq!(tf.floor(0), 0);
assert_eq!(tf.floor(99), 0);
assert_eq!(tf.floor(100), 100);
assert_eq!(tf.floor(150), 100);
assert_eq!(tf.floor(250), 200);
}
#[test]
fn aggregates_ticks_into_one_candle_within_bucket() {
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap());
assert_eq!(agg.push(t(10.0, 0)).unwrap(), None);
assert_eq!(agg.push(t(12.0, 15)).unwrap(), None);
assert_eq!(agg.push(t(8.0, 30)).unwrap(), None);
assert_eq!(agg.push(t(11.0, 50)).unwrap(), None);
let bar = agg.flush().expect("open bar");
assert_eq!(bar.open, 10.0);
assert_eq!(bar.high, 12.0);
assert_eq!(bar.low, 8.0);
assert_eq!(bar.close, 11.0);
assert!((bar.volume - 4.0).abs() < 1e-12);
assert_eq!(bar.timestamp, 0);
}
#[test]
fn emits_candle_on_bucket_crossing() {
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap());
agg.push(t(10.0, 0)).unwrap();
agg.push(t(12.0, 30)).unwrap();
let closed = agg.push(t(15.0, 60)).unwrap().expect("emits");
assert_eq!(closed.open, 10.0);
assert_eq!(closed.high, 12.0);
assert_eq!(closed.low, 10.0);
assert_eq!(closed.close, 12.0);
// The new tick at ts=60 opens the next bar.
let still_open = agg.flush().unwrap();
assert_eq!(still_open.open, 15.0);
assert_eq!(still_open.timestamp, 60);
}
#[test]
fn rejects_out_of_order_ticks() {
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap());
agg.push(t(10.0, 100)).unwrap();
let err = agg.push(t(11.0, 30)).unwrap_err();
assert!(matches!(err, Error::Malformed(_)));
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Stream OHLCV candles out of a CSV file.
//!
//! The reader is generic over the column layout, but ships with a sensible
//! default ("timestamp,open,high,low,close,volume") that matches the standard
//! Binance / Yahoo Finance / kaggle dataset format.
use std::path::Path;
use serde::Deserialize;
use crate::error::{Error, Result};
use wickra_core::Candle;
/// Default OHLCV CSV row layout.
///
/// The timestamp is parsed as an `i64`; if your file ships an RFC3339 / ISO8601
/// string instead, use [`CandleReader::with_timestamp_parser`].
#[derive(Debug, Clone, Deserialize)]
pub struct DefaultRow {
pub timestamp: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
}
impl DefaultRow {
fn into_candle(self) -> Result<Candle> {
Candle::new(
self.open,
self.high,
self.low,
self.close,
self.volume,
self.timestamp,
)
.map_err(Error::from)
}
}
/// Streaming OHLCV CSV reader.
#[derive(Debug)]
pub struct CandleReader<R: std::io::Read> {
reader: csv::Reader<R>,
}
impl CandleReader<std::fs::File> {
/// Open a CSV file at `path`. The first line is treated as a header by default.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let reader = csv::ReaderBuilder::new()
.has_headers(true)
.from_path(path)?;
Ok(Self { reader })
}
}
impl<R: std::io::Read> CandleReader<R> {
/// Build a reader from any [`std::io::Read`] source.
pub fn from_reader(inner: R) -> Self {
Self {
reader: csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(inner),
}
}
/// Replace the underlying reader; useful for testing.
pub fn from_csv_reader(reader: csv::Reader<R>) -> Self {
Self { reader }
}
/// Iterator over decoded candles.
pub fn candles(&mut self) -> impl Iterator<Item = Result<Candle>> + '_ {
self.reader.deserialize::<DefaultRow>().map(|row_res| {
let row = row_res?;
row.into_candle()
})
}
/// Read the entire stream into a `Vec<Candle>`. Convenient for backtests.
pub fn read_all(&mut self) -> Result<Vec<Candle>> {
self.candles().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn reads_well_formed_csv() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "timestamp,open,high,low,close,volume").unwrap();
writeln!(tmp, "1,10.0,11.0,9.0,10.5,100").unwrap();
writeln!(tmp, "2,10.5,11.5,10.0,11.0,150").unwrap();
writeln!(tmp, "3,11.0,12.0,10.5,11.5,200").unwrap();
tmp.flush().unwrap();
let mut r = CandleReader::open(tmp.path()).unwrap();
let candles = r.read_all().unwrap();
assert_eq!(candles.len(), 3);
assert_eq!(candles[0].open, 10.0);
assert_eq!(candles[2].close, 11.5);
assert_eq!(candles[1].timestamp, 2);
}
#[test]
fn rejects_invalid_ohlc() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "timestamp,open,high,low,close,volume").unwrap();
// high < low → core validation rejects it.
writeln!(tmp, "1,10.0,8.0,9.0,9.5,100").unwrap();
tmp.flush().unwrap();
let mut r = CandleReader::open(tmp.path()).unwrap();
let candles: Result<Vec<Candle>> = r.candles().collect();
assert!(candles.is_err());
}
#[test]
fn from_reader_works_on_in_memory_data() {
let data = "timestamp,open,high,low,close,volume\n1,1,2,0,1,10\n2,1,2,0,1,10\n";
let mut r = CandleReader::from_reader(data.as_bytes());
let v = r.read_all().unwrap();
assert_eq!(v.len(), 2);
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Error types specific to the data sources.
use thiserror::Error;
/// Errors produced by the data layer.
#[derive(Debug, Error)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("CSV error: {0}")]
Csv(#[from] csv::Error),
#[error("invalid timeframe: {0}")]
InvalidTimeframe(String),
#[error("indicator-core error: {0}")]
Core(#[from] wickra_core::Error),
#[error("malformed payload: {0}")]
Malformed(String),
#[cfg(feature = "live-binance")]
#[error("websocket error: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[cfg(feature = "live-binance")]
#[error("JSON decode error: {0}")]
Json(#[from] serde_json::Error),
}
/// Convenience alias for `Result<T, wickra_data::Error>`.
pub type Result<T> = core::result::Result<T, Error>;
+24
View File
@@ -0,0 +1,24 @@
//! `wickra-data`: offline and online data sources for the Wickra indicator engine.
//!
//! - [`csv`]: stream OHLCV bars out of CSV files without buffering the whole
//! history in memory.
//! - [`aggregator`]: roll trade ticks up into candles of arbitrary timeframes.
//! - [`resample`]: convert a stream of candles from one timeframe to a coarser one.
//! - [`live`] (feature `live-binance`): connect to exchange websockets and yield
//! typed events compatible with the rest of the crate.
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
// `tokio_tungstenite::Error` is large by itself (~200 B). Boxing every Err
// variant per clippy::result_large_err just shifts allocation pressure into
// the hot path. We accept the size because errors are rare in this crate.
#![allow(clippy::result_large_err)]
pub mod aggregator;
pub mod csv;
pub mod error;
pub mod resample;
#[cfg(feature = "live-binance")]
pub mod live;
pub use error::{Error, Result};
+4
View File
@@ -0,0 +1,4 @@
//! Live exchange feeds. Each adapter is feature-gated; the Binance adapter
//! lives behind the `live-binance` feature.
pub mod binance;
+293
View File
@@ -0,0 +1,293 @@
//! Binance spot WebSocket kline feed.
//!
//! Subscribes to Binance's `<symbol>@kline_<interval>` stream and emits a
//! [`KlineEvent`] every time the server pushes a new tick. The event tells you
//! whether the current candle is still open or has just closed.
//!
//! Example (requires the `live-binance` feature):
//!
//! ```no_run
//! use wickra_data::live::binance::{BinanceKlineStream, Interval};
//! # async fn run() -> wickra_data::Result<()> {
//! let mut stream = BinanceKlineStream::connect(&["BTCUSDT".to_string()], Interval::OneMinute).await?;
//! while let Some(event) = stream.next_event().await? {
//! if event.is_closed {
//! println!("closed {} @ {}", event.symbol, event.candle.close);
//! }
//! }
//! # Ok(()) }
//! ```
use futures_util::SinkExt;
use futures_util::StreamExt;
use serde::Deserialize;
use tokio::net::TcpStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use crate::error::{Error, Result};
use wickra_core::Candle;
/// Supported Binance kline intervals. The `as_str` value matches Binance's
/// wire-format strings (`"1m"`, `"5m"`, `"1h"`, etc.).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Interval {
OneSecond,
OneMinute,
ThreeMinutes,
FiveMinutes,
FifteenMinutes,
ThirtyMinutes,
OneHour,
TwoHours,
FourHours,
SixHours,
EightHours,
TwelveHours,
OneDay,
OneWeek,
}
impl Interval {
/// Wire-format string used in the stream name.
pub fn as_str(self) -> &'static str {
match self {
Self::OneSecond => "1s",
Self::OneMinute => "1m",
Self::ThreeMinutes => "3m",
Self::FiveMinutes => "5m",
Self::FifteenMinutes => "15m",
Self::ThirtyMinutes => "30m",
Self::OneHour => "1h",
Self::TwoHours => "2h",
Self::FourHours => "4h",
Self::SixHours => "6h",
Self::EightHours => "8h",
Self::TwelveHours => "12h",
Self::OneDay => "1d",
Self::OneWeek => "1w",
}
}
}
/// One push from the Binance kline stream.
#[derive(Debug, Clone)]
pub struct KlineEvent {
/// Symbol in lowercase form as sent by Binance (e.g. `"btcusdt"`).
pub symbol: String,
/// Interval the candle belongs to.
pub interval: Interval,
/// Candle in its current state (may still be open).
pub candle: Candle,
/// Whether the candle has been closed by the server. Closed events are the
/// only ones safe to use for bar-completion logic.
pub is_closed: bool,
}
/// A live Binance kline stream.
#[derive(Debug)]
pub struct BinanceKlineStream {
socket: WebSocketStream<MaybeTlsStream<TcpStream>>,
/// Interval requested at connect time. Used to tag every event.
interval: Interval,
}
/// Wire-format representation of an incoming Binance kline tick. Public so callers
/// can deserialize it themselves if they prefer.
#[derive(Debug, Clone, Deserialize)]
pub struct RawWsEnvelope {
/// Stream name, e.g. `"btcusdt@kline_1m"`.
pub stream: String,
pub data: RawKlinePayload,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RawKlinePayload {
#[serde(rename = "e")]
pub event_type: String,
#[serde(rename = "E")]
pub event_time: i64,
#[serde(rename = "s")]
pub symbol: String,
#[serde(rename = "k")]
pub kline: RawKline,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RawKline {
#[serde(rename = "t")]
pub open_time: i64,
#[serde(rename = "T")]
pub close_time: i64,
#[serde(rename = "s")]
pub symbol: String,
#[serde(rename = "i")]
pub interval: String,
#[serde(rename = "o")]
pub open: String,
#[serde(rename = "c")]
pub close: String,
#[serde(rename = "h")]
pub high: String,
#[serde(rename = "l")]
pub low: String,
#[serde(rename = "v")]
pub volume: String,
#[serde(rename = "x")]
pub is_closed: bool,
}
impl BinanceKlineStream {
/// Connect to Binance's combined-stream endpoint for one or more symbols.
///
/// Symbols may be passed in either case; they are lowercased to match
/// Binance's stream-name conventions.
pub async fn connect(symbols: &[String], interval: Interval) -> Result<Self> {
if symbols.is_empty() {
return Err(Error::Malformed(
"BinanceKlineStream requires at least one symbol".into(),
));
}
let streams: Vec<String> = symbols
.iter()
.map(|s| format!("{}@kline_{}", s.to_lowercase(), interval.as_str()))
.collect();
let url = format!(
"wss://stream.binance.com:9443/stream?streams={}",
streams.join("/")
);
let url = url::Url::parse(&url).map_err(|e| Error::Malformed(e.to_string()))?;
let (socket, _) = tokio_tungstenite::connect_async(url.as_str()).await?;
Ok(Self { socket, interval })
}
/// Receive the next kline event. Yields `Ok(None)` when the server closes
/// the connection cleanly.
pub async fn next_event(&mut self) -> Result<Option<KlineEvent>> {
loop {
let msg = match self.socket.next().await {
Some(Ok(m)) => m,
Some(Err(e)) => return Err(Error::from(e)),
None => return Ok(None),
};
match msg {
Message::Text(text) => {
let envelope: RawWsEnvelope = serde_json::from_str(&text)?;
return Ok(Some(envelope.into_event(self.interval)?));
}
Message::Binary(bytes) => {
let envelope: RawWsEnvelope = serde_json::from_slice(&bytes)?;
return Ok(Some(envelope.into_event(self.interval)?));
}
Message::Ping(payload) => {
self.socket.send(Message::Pong(payload)).await?;
}
Message::Pong(_) | Message::Frame(_) => {}
Message::Close(_) => return Ok(None),
}
}
}
/// Close the underlying socket cleanly.
pub async fn close(mut self) -> Result<()> {
self.socket.close(None).await?;
Ok(())
}
}
impl RawWsEnvelope {
fn into_event(self, interval: Interval) -> Result<KlineEvent> {
let k = self.data.kline;
let open: f64 = k
.open
.parse()
.map_err(|_| Error::Malformed(format!("bad open '{}'", k.open)))?;
let high: f64 = k
.high
.parse()
.map_err(|_| Error::Malformed(format!("bad high '{}'", k.high)))?;
let low: f64 = k
.low
.parse()
.map_err(|_| Error::Malformed(format!("bad low '{}'", k.low)))?;
let close: f64 = k
.close
.parse()
.map_err(|_| Error::Malformed(format!("bad close '{}'", k.close)))?;
let volume: f64 = k
.volume
.parse()
.map_err(|_| Error::Malformed(format!("bad volume '{}'", k.volume)))?;
let candle = Candle::new(open, high, low, close, volume, k.open_time)?;
Ok(KlineEvent {
symbol: self.data.symbol.to_lowercase(),
interval,
candle,
is_closed: k.is_closed,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_real_binance_payload() {
// Sample event format from Binance's public docs (truncated).
let json = r#"{
"stream": "btcusdt@kline_1m",
"data": {
"e": "kline",
"E": 1700000000000,
"s": "BTCUSDT",
"k": {
"t": 1700000000000,
"T": 1700000059999,
"s": "BTCUSDT",
"i": "1m",
"f": 1,
"L": 100,
"o": "30000.0",
"c": "30050.0",
"h": "30100.0",
"l": "29950.0",
"v": "12.5",
"n": 50,
"x": false,
"q": "375000.0",
"V": "6.25",
"Q": "187500.0",
"B": "0"
}
}
}"#;
let env: RawWsEnvelope = serde_json::from_str(json).unwrap();
let evt = env.into_event(Interval::OneMinute).unwrap();
assert_eq!(evt.symbol, "btcusdt");
assert_eq!(evt.candle.open, 30_000.0);
assert_eq!(evt.candle.close, 30_050.0);
assert!(!evt.is_closed);
assert_eq!(evt.interval, Interval::OneMinute);
}
#[test]
fn rejects_non_parsable_numbers() {
let json = r#"{
"stream": "btcusdt@kline_1m",
"data": {
"e": "kline", "E": 0, "s": "BTCUSDT",
"k": {
"t": 0, "T": 0, "s": "BTCUSDT", "i": "1m",
"f": 0, "L": 0,
"o": "not-a-number", "c": "0", "h": "0", "l": "0",
"v": "0", "n": 0, "x": false, "q": "0", "V": "0", "Q": "0", "B": "0"
}
}
}"#;
let env: RawWsEnvelope = serde_json::from_str(json).unwrap();
let err = env.into_event(Interval::OneMinute).unwrap_err();
assert!(matches!(err, Error::Malformed(_)));
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Resample an existing candle stream from a finer timeframe to a coarser one.
use crate::aggregator::Timeframe;
use crate::error::Result;
use wickra_core::Candle;
/// Roll a stream of candles up to a coarser timeframe.
///
/// Used to derive 5m bars from a 1m feed, or 1h bars from 5m bars, without
/// touching the original tick stream. The output timeframe's bucket must be a
/// strict multiple of the input timeframe's bucket, but this is not enforced
/// — callers are responsible for picking sensible aggregations.
#[derive(Debug, Clone)]
pub struct Resampler {
timeframe: Timeframe,
open: Option<RolledBar>,
}
#[derive(Debug, Clone, Copy)]
struct RolledBar {
bucket_start: i64,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
}
impl RolledBar {
fn from_candle(c: Candle, bucket_start: i64) -> Self {
Self {
bucket_start,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
}
}
fn absorb(&mut self, c: Candle) {
if c.high > self.high {
self.high = c.high;
}
if c.low < self.low {
self.low = c.low;
}
self.close = c.close;
self.volume += c.volume;
}
fn into_candle(self) -> Candle {
Candle::new_unchecked(
self.open,
self.high,
self.low,
self.close,
self.volume,
self.bucket_start,
)
}
}
impl Resampler {
/// Build a resampler targeting the given output timeframe.
pub fn new(timeframe: Timeframe) -> Self {
Self {
timeframe,
open: None,
}
}
/// Push a finer-grained candle. Returns the coarser candle that just closed,
/// if any.
pub fn push(&mut self, candle: Candle) -> Option<Candle> {
let bucket = self.timeframe.floor(candle.timestamp);
match self.open {
Some(mut bar) if bucket == bar.bucket_start => {
bar.absorb(candle);
self.open = Some(bar);
None
}
Some(bar) => {
let closed = bar.into_candle();
self.open = Some(RolledBar::from_candle(candle, bucket));
Some(closed)
}
None => {
self.open = Some(RolledBar::from_candle(candle, bucket));
None
}
}
}
/// Flush the currently open coarser bar, if any.
pub fn flush(&mut self) -> Option<Candle> {
self.open.take().map(RolledBar::into_candle)
}
}
/// Roll an entire iterator of candles into a `Vec` of coarser candles. The final
/// open bar (if any) is appended via [`Resampler::flush`].
pub fn resample_all<I>(timeframe: Timeframe, iter: I) -> Result<Vec<Candle>>
where
I: IntoIterator<Item = Result<Candle>>,
{
let mut r = Resampler::new(timeframe);
let mut out = Vec::new();
for c in iter {
let c = c?;
if let Some(closed) = r.push(c) {
out.push(closed);
}
}
if let Some(last) = r.flush() {
out.push(last);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn c(ts: i64, o: f64, h: f64, l: f64, cl: f64, v: f64) -> Candle {
Candle::new(o, h, l, cl, v, ts).unwrap()
}
#[test]
fn resamples_1m_to_5m() {
let tf = Timeframe::new(5).unwrap();
let one_m = vec![
c(0, 10.0, 11.0, 9.0, 10.5, 10.0),
c(1, 10.5, 12.0, 10.0, 11.5, 12.0),
c(2, 11.5, 13.0, 11.0, 12.5, 15.0),
c(3, 12.5, 12.8, 11.5, 12.0, 8.0),
c(4, 12.0, 12.2, 11.0, 11.5, 6.0),
c(5, 11.5, 11.9, 11.0, 11.5, 4.0),
];
let rolled = resample_all(tf, one_m.into_iter().map(Ok)).unwrap();
// First 5 candles share bucket 0 -> aggregate. Last candle opens bucket 5.
assert_eq!(rolled.len(), 2);
let a = rolled[0];
assert_eq!(a.open, 10.0);
assert_eq!(a.close, 11.5);
assert_eq!(a.high, 13.0);
assert_eq!(a.low, 9.0);
assert!((a.volume - 51.0).abs() < 1e-12);
let b = rolled[1];
assert_eq!(b.open, 11.5);
assert_eq!(b.timestamp, 5);
}
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "wickra"
description = "Streaming-first technical analysis library: incremental indicators, drop-in TA-Lib replacement, multi-language."
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
wickra-core = { workspace = true }
[features]
default = ["parallel"]
parallel = ["wickra-core/parallel"]
[dev-dependencies]
approx = { workspace = true }
criterion = { workspace = true }
proptest = { workspace = true }
wickra-data = { path = "../wickra-data" }
[[bench]]
name = "indicators"
harness = false
[[example]]
name = "backtest"
path = "../../examples/rust/backtest.rs"
required-features = []
+142
View File
@@ -0,0 +1,142 @@
//! Microbenchmarks for every built-in indicator.
//!
//! Run with:
//! ```text
//! cargo bench -p wickra
//! ```
//!
//! Each benchmark feeds a deterministic synthetic price series through both the
//! streaming (`update` loop) and batch APIs of an indicator. Sizes cover small
//! (1 000), medium (10 000), and large (100 000) workloads.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use wickra::{
Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Obv, Rsi, Sma,
Stochastic, Wma,
};
/// Deterministic synthetic price series of length `n`.
fn price_series(n: usize) -> Vec<f64> {
(0..n)
.map(|i| {
let t = i as f64;
100.0 + (t * 0.013).sin() * 12.0 + (t * 0.071).cos() * 4.0 + (t * 0.003).sin() * 30.0
})
.collect()
}
/// Synthetic OHLC candle series.
fn candle_series(n: usize) -> Vec<Candle> {
let closes = price_series(n);
closes
.iter()
.enumerate()
.map(|(i, c)| {
let t = i as f64;
let spread = 0.5 + (t * 0.05).sin().abs();
// Benchmark synthetic data: i originates from a usize counter capped at 100_000,
// well within i64::MAX. The wrap-around lint does not apply here.
#[allow(clippy::cast_possible_wrap)]
let ts = i as i64;
Candle::new_unchecked(*c, c + spread, c - spread, *c, 1_000.0, ts)
})
.collect()
}
fn bench_scalar<I, F>(c: &mut Criterion, name: &str, sizes: &[usize], make: F)
where
F: Fn() -> I,
I: Indicator<Input = f64, Output = f64> + BatchExt,
{
let mut group = c.benchmark_group(name);
for &n in sizes {
let series = price_series(n);
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), &series, |b, prices| {
b.iter(|| {
let mut ind = make();
for p in prices {
black_box(ind.update(*p));
}
});
});
group.bench_with_input(BenchmarkId::new("batch", n), &series, |b, prices| {
b.iter(|| {
let mut ind = make();
black_box(ind.batch(prices));
});
});
}
group.finish();
}
fn bench_macd(c: &mut Criterion, sizes: &[usize]) {
let mut group = c.benchmark_group("macd");
for &n in sizes {
let series = price_series(n);
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), &series, |b, prices| {
b.iter(|| {
let mut ind = MacdIndicator::classic();
for p in prices {
black_box(ind.update(*p));
}
});
});
}
group.finish();
}
fn bench_bollinger(c: &mut Criterion, sizes: &[usize]) {
let mut group = c.benchmark_group("bollinger");
for &n in sizes {
let series = price_series(n);
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), &series, |b, prices| {
b.iter(|| {
let mut ind = BollingerBands::classic();
for p in prices {
black_box(ind.update(*p));
}
});
});
}
group.finish();
}
fn bench_candle_input<I, F, O>(c: &mut Criterion, name: &str, sizes: &[usize], make: F)
where
F: Fn() -> I,
I: Indicator<Input = Candle, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in sizes {
let candles = candle_series(n);
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), &candles, |b, candles| {
b.iter(|| {
let mut ind = make();
for c in candles {
black_box(ind.update(*c));
}
});
});
}
group.finish();
}
fn benches(c: &mut Criterion) {
let sizes = [1_000_usize, 10_000, 100_000];
bench_scalar(c, "sma", &sizes, || Sma::new(14).unwrap());
bench_scalar(c, "ema", &sizes, || Ema::new(14).unwrap());
bench_scalar(c, "wma", &sizes, || Wma::new(14).unwrap());
bench_scalar(c, "rsi", &sizes, || Rsi::new(14).unwrap());
bench_macd(c, &sizes);
bench_bollinger(c, &sizes);
bench_candle_input(c, "atr", &sizes, || Atr::new(14).unwrap());
bench_candle_input(c, "stochastic", &sizes, Stochastic::classic);
bench_candle_input(c, "obv", &sizes, Obv::new);
}
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
criterion_main!(wickra_benches);
+21
View File
@@ -0,0 +1,21 @@
//! Wickra: streaming-first technical analysis.
//!
//! This crate is a thin re-export of [`wickra_core`] so downstream users can depend on
//! a single `wickra` package without thinking about the internal split. Every public
//! item lives in `wickra_core`; only the names re-exported here are part of the stable
//! public API.
//!
//! # Example
//!
//! ```
//! use wickra::{Indicator, Sma};
//!
//! let mut sma = Sma::new(3).unwrap();
//! let prices = [1.0, 2.0, 3.0, 4.0, 5.0];
//! let out: Vec<Option<f64>> = prices.iter().map(|p| sma.update(*p)).collect();
//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]);
//! ```
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
pub use wickra_core::*;
+114
View File
@@ -0,0 +1,114 @@
"""Offline backtest example: compute a basket of indicators over a CSV history.
Run with::
python -m examples.python.backtest path/to/ohlcv.csv
The CSV must have a header row with at least the columns
``timestamp, open, high, low, close, volume``. The script computes a panel of
indicators with the standard parameters used across Wickra's tests and
prints a small summary of the resulting series enough to verify that the
indicators are wired correctly without pulling in pandas or a charting stack.
"""
from __future__ import annotations
import argparse
import csv
import sys
from dataclasses import dataclass
from typing import List
import numpy as np
import wickra as ta
@dataclass
class History:
timestamp: np.ndarray
open: np.ndarray
high: np.ndarray
low: np.ndarray
close: np.ndarray
volume: np.ndarray
def read_history(path: str) -> History:
"""Load an OHLCV CSV into typed NumPy columns."""
rows: List[List[str]] = []
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(
[
row["timestamp"],
row["open"],
row["high"],
row["low"],
row["close"],
row["volume"],
]
)
if not rows:
raise ValueError("CSV is empty")
arr = np.array(rows, dtype=np.float64)
return History(
timestamp=arr[:, 0].astype(np.int64),
open=arr[:, 1],
high=arr[:, 2],
low=arr[:, 3],
close=arr[:, 4],
volume=arr[:, 5],
)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
p.add_argument("path", help="Path to an OHLCV CSV file")
p.add_argument("--rsi", type=int, default=14)
p.add_argument("--ema", type=int, default=20)
p.add_argument("--bb-period", type=int, default=20)
p.add_argument("--bb-mult", type=float, default=2.0)
return p.parse_args()
def summarize(name: str, values: np.ndarray) -> None:
valid = values[~np.isnan(values)]
if valid.size == 0:
print(f" {name:<12} (no valid samples — series too short)")
return
print(
f" {name:<12} mean={valid.mean():>10.4f} min={valid.min():>10.4f} "
f"max={valid.max():>10.4f} last={valid[-1]:>10.4f}"
)
def main() -> int:
args = parse_args()
history = read_history(args.path)
rsi = ta.RSI(args.rsi).batch(history.close)
ema = ta.EMA(args.ema).batch(history.close)
macd = ta.MACD().batch(history.close) # shape (n, 3)
bb = ta.BollingerBands(args.bb_period, args.bb_mult).batch(history.close) # (n, 4)
atr = ta.ATR(14).batch(history.high, history.low, history.close)
adx = ta.ADX(14).batch(history.high, history.low, history.close) # (n, 3)
obv = ta.OBV().batch(history.close, history.volume)
print(f"Backtest summary for {args.path} ({history.close.size} bars)")
summarize(f"RSI({args.rsi})", rsi)
summarize(f"EMA({args.ema})", ema)
summarize("MACD line", macd[:, 0])
summarize("MACD hist", macd[:, 2])
summarize(f"BB upper", bb[:, 0])
summarize(f"BB lower", bb[:, 2])
summarize("ATR(14)", atr)
summarize("ADX(14)", adx[:, 2])
summarize("OBV", obv)
return 0
if __name__ == "__main__":
sys.exit(main())
+146
View File
@@ -0,0 +1,146 @@
"""Live trading skeleton: stream Binance kline ticks → incremental indicators → signals.
This example connects to Binance's public WebSocket feed (no API key needed)
and runs RSI / MACD / Bollinger Bands on the close prices coming in. When the
RSI crosses common overbought / oversold thresholds *and* the MACD histogram
confirms the direction, a `Signal` event is printed. No orders are placed.
Run with::
python -m examples.python.live_trading --symbol BTCUSDT --interval 1m
Dependencies::
pip install websockets
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import signal
import sys
from dataclasses import dataclass
from typing import Optional
import wickra as ta
try:
import websockets
except ImportError:
print("This example needs the `websockets` package: pip install websockets", file=sys.stderr)
raise
BINANCE_WS = "wss://stream.binance.com:9443/stream"
@dataclass
class Snapshot:
rsi: Optional[float]
macd_hist: Optional[float]
bb_upper: Optional[float]
bb_middle: Optional[float]
bb_lower: Optional[float]
close: float
class StrategyState:
"""Holds one streaming instance of each indicator and computes signals."""
def __init__(self) -> None:
self.rsi = ta.RSI(14)
self.macd = ta.MACD()
self.bb = ta.BollingerBands(20, 2.0)
def update(self, close: float) -> Snapshot:
rsi = self.rsi.update(float(close))
macd_v = self.macd.update(float(close))
bb_v = self.bb.update(float(close))
return Snapshot(
rsi=rsi,
macd_hist=macd_v[2] if macd_v else None,
bb_upper=bb_v[0] if bb_v else None,
bb_middle=bb_v[1] if bb_v else None,
bb_lower=bb_v[2] if bb_v else None,
close=float(close),
)
def emit_signal(snap: Snapshot, log: logging.Logger) -> None:
if snap.rsi is None or snap.macd_hist is None or snap.bb_upper is None:
return
if snap.rsi > 70 and snap.macd_hist < 0 and snap.close >= snap.bb_upper:
log.warning(
"SELL candidate: rsi=%.1f hist=%.4f close=%.4f >= bb_upper=%.4f",
snap.rsi, snap.macd_hist, snap.close, snap.bb_upper,
)
elif snap.rsi < 30 and snap.macd_hist > 0 and snap.close <= snap.bb_lower:
log.warning(
"BUY candidate: rsi=%.1f hist=%.4f close=%.4f <= bb_lower=%.4f",
snap.rsi, snap.macd_hist, snap.close, snap.bb_lower,
)
async def run(symbol: str, interval: str) -> None:
stream = f"{symbol.lower()}@kline_{interval}"
url = f"{BINANCE_WS}?streams={stream}"
log = logging.getLogger("wickra-live")
state = StrategyState()
log.info("Connecting to %s", url)
async with websockets.connect(url, ping_interval=20) as ws:
log.info("Connected, listening for %s klines", stream)
async for raw in ws:
envelope = json.loads(raw)
payload = envelope.get("data", {})
k = payload.get("k", {})
close = float(k.get("c"))
is_closed = bool(k.get("x"))
snap = state.update(close)
log.info(
"%s close=%.4f rsi=%s hist=%s bb=%s",
"BAR" if is_closed else "tick",
close,
f"{snap.rsi:.1f}" if snap.rsi else "--",
f"{snap.macd_hist:+.4f}" if snap.macd_hist else "--",
f"{snap.bb_lower:.2f}/{snap.bb_middle:.2f}/{snap.bb_upper:.2f}"
if snap.bb_upper
else "--",
)
emit_signal(snap, log)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
p.add_argument("--symbol", default="BTCUSDT", help="trading pair")
p.add_argument("--interval", default="1m", help="Binance kline interval")
p.add_argument("--verbose", action="store_true")
return p.parse_args()
def main() -> int:
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
loop = asyncio.new_event_loop()
# Translate Ctrl+C into a clean loop stop.
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, loop.stop)
except NotImplementedError:
pass # Windows does not support add_signal_handler for SIGTERM.
try:
loop.run_until_complete(run(args.symbol, args.interval))
except KeyboardInterrupt:
pass
finally:
loop.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Compute indicators on multiple timeframes from a single 1-minute feed.
Wickra exposes resampling on the Rust side (in `wickra-data`); from Python we
roll up bars manually using NumPy because most users already have their data
in a NumPy array and don't need the streaming-resample infrastructure for
offline analysis.
Run with::
python -m examples.python.multi_timeframe path/to/1m.csv
"""
from __future__ import annotations
import argparse
import csv
from typing import Iterable, List, Tuple
import numpy as np
import wickra as ta
def read_csv(path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Read an OHLCV CSV into typed columns."""
ts, o, h, l, c, v = [], [], [], [], [], []
with open(path, newline="") as f:
for row in csv.DictReader(f):
ts.append(int(row["timestamp"]))
o.append(float(row["open"]))
h.append(float(row["high"]))
l.append(float(row["low"]))
c.append(float(row["close"]))
v.append(float(row["volume"]))
return (
np.asarray(ts, dtype=np.int64),
np.asarray(o, dtype=np.float64),
np.asarray(h, dtype=np.float64),
np.asarray(l, dtype=np.float64),
np.asarray(c, dtype=np.float64),
np.asarray(v, dtype=np.float64),
)
def resample(
ts: np.ndarray,
o: np.ndarray,
h: np.ndarray,
l: np.ndarray,
c: np.ndarray,
v: np.ndarray,
bucket: int,
) -> Tuple[np.ndarray, ...]:
"""Aggregate bar-level columns into coarser buckets of size `bucket`."""
bucket_index = (ts // bucket).astype(np.int64)
boundaries = np.diff(bucket_index, prepend=bucket_index[0] - 1) != 0
group_ids = np.cumsum(boundaries) - 1
n_groups = int(group_ids.max()) + 1
new_ts = np.empty(n_groups, dtype=np.int64)
new_o = np.empty(n_groups, dtype=np.float64)
new_h = np.empty(n_groups, dtype=np.float64)
new_l = np.empty(n_groups, dtype=np.float64)
new_c = np.empty(n_groups, dtype=np.float64)
new_v = np.empty(n_groups, dtype=np.float64)
for g in range(n_groups):
mask = group_ids == g
new_ts[g] = ts[mask][0]
new_o[g] = o[mask][0]
new_h[g] = h[mask].max()
new_l[g] = l[mask].min()
new_c[g] = c[mask][-1]
new_v[g] = v[mask].sum()
return new_ts, new_o, new_h, new_l, new_c, new_v
def summarize(label: str, close: np.ndarray, high: np.ndarray, low: np.ndarray) -> None:
rsi = ta.RSI(14).batch(close)
macd = ta.MACD().batch(close)
adx = ta.ADX(14).batch(high, low, close)
last_macd_hist = macd[~np.isnan(macd[:, 2])][-1, 2] if np.any(~np.isnan(macd[:, 2])) else float("nan")
last_adx = adx[~np.isnan(adx[:, 2])][-1, 2] if np.any(~np.isnan(adx[:, 2])) else float("nan")
print(
f" {label:<10} bars={close.size:>5} "
f"last_close={close[-1]:>10.4f} rsi={rsi[~np.isnan(rsi)][-1]:>6.2f} "
f"macd_hist={last_macd_hist:+.4f} adx={last_adx:>6.2f}"
)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
p.add_argument("path", help="Path to a 1-minute OHLCV CSV (timestamps in milliseconds)")
args = p.parse_args()
ts, o, h, l, c, v = read_csv(args.path)
one_minute = 60_000
print(f"Multi-timeframe view of {args.path}")
summarize("1m", c, h, l)
for label, bucket in [("5m", 5), ("15m", 15), ("1h", 60), ("4h", 240), ("1d", 1440)]:
if ts.size < bucket:
continue
rs_ts, rs_o, rs_h, rs_l, rs_c, rs_v = resample(ts, o, h, l, c, v, bucket * one_minute)
summarize(label, rs_c, rs_h, rs_l)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
"""Process indicators for many symbols in parallel.
Python's GIL makes pure-Python parallelism a poor fit, but Wickra's heavy
lifting happens inside the Rust extension which releases the GIL during
batch computation. That means a `ThreadPoolExecutor` actually delivers
multi-core speedup here.
Run with::
python -m examples.python.parallel_assets --assets 1000 --bars 5000
"""
from __future__ import annotations
import argparse
import concurrent.futures
import time
from typing import Tuple
import numpy as np
import wickra as ta
def synthesize_panel(n_assets: int, n_bars: int, seed: int = 0xBADC0FFEE0DDF00D) -> np.ndarray:
"""Build an `(n_assets, n_bars)` matrix of synthetic prices."""
rng = np.random.default_rng(seed & 0xFFFF_FFFF)
drift = rng.standard_normal((n_assets, n_bars)) * 0.4
return 100.0 + np.cumsum(drift, axis=1)
def compute_one(prices: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (RSI, EMA, MACD-line) for one asset."""
rsi = ta.RSI(14).batch(prices)
ema = ta.EMA(20).batch(prices)
macd = ta.MACD().batch(prices)
return rsi, ema, macd[:, 0]
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
p.add_argument("--assets", type=int, default=200)
p.add_argument("--bars", type=int, default=5000)
p.add_argument(
"--workers",
type=int,
default=0,
help="thread pool size; 0 means use os.cpu_count()",
)
args = p.parse_args()
print(f"Generating {args.assets}x{args.bars} synthetic panel…")
panel = synthesize_panel(args.assets, args.bars)
# Serial baseline.
t0 = time.perf_counter()
serial_results = [compute_one(panel[i]) for i in range(args.assets)]
t_serial = time.perf_counter() - t0
print(f"Serial: {t_serial:.3f} s ({args.assets} assets)")
# Parallel.
workers = args.workers or None
t0 = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
parallel_results = list(pool.map(compute_one, panel))
t_parallel = time.perf_counter() - t0
used = workers or 0
print(
f"Parallel: {t_parallel:.3f} s (workers={used or 'os.cpu_count()'}, "
f"speedup ~{t_serial / max(t_parallel, 1e-9):.2f}x)"
)
# Sanity-check that the parallel results match the serial baseline.
for i in range(args.assets):
for s, p_ in zip(serial_results[i], parallel_results[i]):
np.testing.assert_array_equal(s, p_)
print("Parallel results match serial results — OK.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
//! Rust example: backtest a basket of indicators against a CSV file.
//!
//! Build with:
//! ```text
//! cargo run --release --example backtest -- path/to/ohlcv.csv
//! ```
use std::env;
use wickra::{Adx, Atr, BatchExt, BollingerBands, Ema, Indicator, MacdIndicator, Obv, Rsi};
use wickra_data::csv::CandleReader;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let path = args.get(1).ok_or("usage: backtest <ohlcv.csv>")?;
let mut reader = CandleReader::open(path)?;
let candles = reader.read_all()?;
if candles.is_empty() {
return Err("CSV is empty".into());
}
let n = candles.len();
let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
let rsi = Rsi::new(14)?.batch(&closes);
let ema = Ema::new(20)?.batch(&closes);
let bb = BollingerBands::classic().batch(&closes);
let macd = MacdIndicator::classic().batch(&closes);
let mut atr = Atr::new(14)?;
let atr_series: Vec<_> = candles.iter().map(|c| atr.update(*c)).collect();
let mut adx = Adx::new(14)?;
let adx_series: Vec<_> = candles.iter().map(|c| adx.update(*c)).collect();
let mut obv = Obv::new();
let obv_series: Vec<_> = candles.iter().map(|c| obv.update(*c)).collect();
let last_rsi = rsi
.iter()
.rev()
.flatten()
.next()
.copied()
.unwrap_or(f64::NAN);
let last_ema = ema
.iter()
.rev()
.flatten()
.next()
.copied()
.unwrap_or(f64::NAN);
let last_bb = bb.iter().rev().flatten().next().copied();
let last_macd = macd.iter().rev().flatten().next().copied();
let last_atr = atr_series
.iter()
.rev()
.flatten()
.next()
.copied()
.unwrap_or(f64::NAN);
let last_adx = adx_series.iter().rev().flatten().next().copied();
let last_obv = obv_series
.iter()
.rev()
.flatten()
.next()
.copied()
.unwrap_or(f64::NAN);
println!("backtest summary for {path} ({n} bars)");
println!(" RSI(14) = {last_rsi:>9.4}");
println!(" EMA(20) = {last_ema:>9.4}");
if let Some(bb) = last_bb {
println!(
" BB(20,2) upper={:>9.4} middle={:>9.4} lower={:>9.4} sd={:>8.4}",
bb.upper, bb.middle, bb.lower, bb.stddev
);
}
if let Some(m) = last_macd {
println!(
" MACD macd={:>9.4} signal={:>9.4} hist={:>9.4}",
m.macd, m.signal, m.histogram
);
}
println!(" ATR(14) = {last_atr:>9.4}");
if let Some(a) = last_adx {
println!(
" ADX(14) +DI={:>6.2} -DI={:>6.2} ADX={:>6.2}",
a.plus_di, a.minus_di, a.adx
);
}
println!(" OBV = {last_obv:>14.2}");
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
//! Connect to Binance, run incremental RSI on the closed kline events.
//!
//! Build with:
//! ```text
//! cargo run --release --example live_binance --features wickra-data/live-binance -- BTCUSDT
//! ```
//!
//! The example prints a line per bar close. Hit Ctrl+C to stop.
use std::env;
use wickra::{Indicator, Rsi};
use wickra_data::live::binance::{BinanceKlineStream, Interval};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let symbol = args
.get(1)
.cloned()
.unwrap_or_else(|| "BTCUSDT".to_string());
let mut stream =
BinanceKlineStream::connect(std::slice::from_ref(&symbol), Interval::OneMinute).await?;
let mut rsi = Rsi::new(14)?;
println!("Listening for {symbol} 1m closes…");
while let Some(event) = stream.next_event().await? {
if !event.is_closed {
continue;
}
let close = event.candle.close;
let v = rsi.update(close);
if let Some(value) = v {
println!("{} close={:.4} rsi={:.2}", event.symbol, close, value);
} else {
println!("{} close={:.4} rsi=…warmup", event.symbol, close);
}
}
Ok(())
}