From 3be267cb03fb3f252e09f43acce4f3518cba3b56 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Thu, 21 May 2026 17:50:45 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 186 ++ .github/workflows/release.yml | 84 + .gitignore | 56 + Cargo.lock | 2179 +++++++++++++++++ Cargo.toml | 75 + LICENSE | 201 ++ README.md | 216 ++ bindings/node/Cargo.toml | 30 + bindings/node/README.md | 45 + bindings/node/__tests__/smoke.test.js | 82 + bindings/node/build.rs | 5 + bindings/node/index.js | 30 + bindings/node/package.json | 56 + bindings/node/src/lib.rs | 723 ++++++ bindings/python/Cargo.toml | 26 + bindings/python/benchmarks/__init__.py | 0 .../python/benchmarks/compare_libraries.py | 520 ++++ bindings/python/pyproject.toml | 62 + bindings/python/python/wickra/__init__.py | 82 + bindings/python/python/wickra/__init__.pyi | 137 ++ bindings/python/python/wickra/py.typed | 0 bindings/python/src/lib.rs | 1414 +++++++++++ bindings/python/tests/__init__.py | 0 bindings/python/tests/conftest.py | 36 + bindings/python/tests/test_known_values.py | 114 + bindings/python/tests/test_lifecycle.py | 88 + bindings/python/tests/test_smoke.py | 57 + .../python/tests/test_streaming_vs_batch.py | 117 + bindings/wasm/Cargo.toml | 38 + bindings/wasm/README.md | 47 + bindings/wasm/examples/index.html | 137 ++ bindings/wasm/src/lib.rs | 621 +++++ crates/wickra-core/Cargo.toml | 28 + .../proptest-regressions/indicators/wma.txt | 7 + crates/wickra-core/src/error.rs | 30 + crates/wickra-core/src/indicators/adx.rs | 298 +++ crates/wickra-core/src/indicators/aroon.rs | 156 ++ crates/wickra-core/src/indicators/atr.rs | 190 ++ .../src/indicators/awesome_oscillator.rs | 117 + .../wickra-core/src/indicators/bollinger.rs | 243 ++ crates/wickra-core/src/indicators/cci.rs | 150 ++ crates/wickra-core/src/indicators/dema.rs | 117 + crates/wickra-core/src/indicators/donchian.rs | 141 ++ crates/wickra-core/src/indicators/ema.rs | 224 ++ crates/wickra-core/src/indicators/hma.rs | 112 + crates/wickra-core/src/indicators/kama.rs | 157 ++ crates/wickra-core/src/indicators/keltner.rs | 142 ++ crates/wickra-core/src/indicators/macd.rs | 230 ++ crates/wickra-core/src/indicators/mfi.rs | 166 ++ crates/wickra-core/src/indicators/mod.rs | 57 + crates/wickra-core/src/indicators/obv.rs | 159 ++ crates/wickra-core/src/indicators/psar.rs | 241 ++ crates/wickra-core/src/indicators/roc.rs | 120 + crates/wickra-core/src/indicators/rsi.rs | 247 ++ crates/wickra-core/src/indicators/sma.rs | 200 ++ .../wickra-core/src/indicators/stochastic.rs | 315 +++ crates/wickra-core/src/indicators/tema.rs | 107 + crates/wickra-core/src/indicators/trix.rs | 131 + crates/wickra-core/src/indicators/vwap.rs | 213 ++ .../wickra-core/src/indicators/williams_r.rs | 141 ++ crates/wickra-core/src/indicators/wma.rs | 229 ++ crates/wickra-core/src/lib.rs | 53 + crates/wickra-core/src/ohlcv.rs | 285 +++ crates/wickra-core/src/traits.rs | 287 +++ crates/wickra-data/Cargo.toml | 45 + crates/wickra-data/src/aggregator.rs | 226 ++ crates/wickra-data/src/csv.rs | 129 + crates/wickra-data/src/error.rs | 33 + crates/wickra-data/src/lib.rs | 24 + crates/wickra-data/src/live.rs | 4 + crates/wickra-data/src/live/binance.rs | 293 +++ crates/wickra-data/src/resample.rs | 153 ++ crates/wickra/Cargo.toml | 38 + crates/wickra/benches/indicators.rs | 142 ++ crates/wickra/src/lib.rs | 21 + examples/python/backtest.py | 114 + examples/python/live_trading.py | 146 ++ examples/python/multi_timeframe.py | 110 + examples/python/parallel_assets.py | 82 + examples/rust/backtest.rs | 95 + examples/rust/live_binance.rs | 41 + 81 files changed, 14453 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bindings/node/Cargo.toml create mode 100644 bindings/node/README.md create mode 100644 bindings/node/__tests__/smoke.test.js create mode 100644 bindings/node/build.rs create mode 100644 bindings/node/index.js create mode 100644 bindings/node/package.json create mode 100644 bindings/node/src/lib.rs create mode 100644 bindings/python/Cargo.toml create mode 100644 bindings/python/benchmarks/__init__.py create mode 100644 bindings/python/benchmarks/compare_libraries.py create mode 100644 bindings/python/pyproject.toml create mode 100644 bindings/python/python/wickra/__init__.py create mode 100644 bindings/python/python/wickra/__init__.pyi create mode 100644 bindings/python/python/wickra/py.typed create mode 100644 bindings/python/src/lib.rs create mode 100644 bindings/python/tests/__init__.py create mode 100644 bindings/python/tests/conftest.py create mode 100644 bindings/python/tests/test_known_values.py create mode 100644 bindings/python/tests/test_lifecycle.py create mode 100644 bindings/python/tests/test_smoke.py create mode 100644 bindings/python/tests/test_streaming_vs_batch.py create mode 100644 bindings/wasm/Cargo.toml create mode 100644 bindings/wasm/README.md create mode 100644 bindings/wasm/examples/index.html create mode 100644 bindings/wasm/src/lib.rs create mode 100644 crates/wickra-core/Cargo.toml create mode 100644 crates/wickra-core/proptest-regressions/indicators/wma.txt create mode 100644 crates/wickra-core/src/error.rs create mode 100644 crates/wickra-core/src/indicators/adx.rs create mode 100644 crates/wickra-core/src/indicators/aroon.rs create mode 100644 crates/wickra-core/src/indicators/atr.rs create mode 100644 crates/wickra-core/src/indicators/awesome_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/bollinger.rs create mode 100644 crates/wickra-core/src/indicators/cci.rs create mode 100644 crates/wickra-core/src/indicators/dema.rs create mode 100644 crates/wickra-core/src/indicators/donchian.rs create mode 100644 crates/wickra-core/src/indicators/ema.rs create mode 100644 crates/wickra-core/src/indicators/hma.rs create mode 100644 crates/wickra-core/src/indicators/kama.rs create mode 100644 crates/wickra-core/src/indicators/keltner.rs create mode 100644 crates/wickra-core/src/indicators/macd.rs create mode 100644 crates/wickra-core/src/indicators/mfi.rs create mode 100644 crates/wickra-core/src/indicators/mod.rs create mode 100644 crates/wickra-core/src/indicators/obv.rs create mode 100644 crates/wickra-core/src/indicators/psar.rs create mode 100644 crates/wickra-core/src/indicators/roc.rs create mode 100644 crates/wickra-core/src/indicators/rsi.rs create mode 100644 crates/wickra-core/src/indicators/sma.rs create mode 100644 crates/wickra-core/src/indicators/stochastic.rs create mode 100644 crates/wickra-core/src/indicators/tema.rs create mode 100644 crates/wickra-core/src/indicators/trix.rs create mode 100644 crates/wickra-core/src/indicators/vwap.rs create mode 100644 crates/wickra-core/src/indicators/williams_r.rs create mode 100644 crates/wickra-core/src/indicators/wma.rs create mode 100644 crates/wickra-core/src/lib.rs create mode 100644 crates/wickra-core/src/ohlcv.rs create mode 100644 crates/wickra-core/src/traits.rs create mode 100644 crates/wickra-data/Cargo.toml create mode 100644 crates/wickra-data/src/aggregator.rs create mode 100644 crates/wickra-data/src/csv.rs create mode 100644 crates/wickra-data/src/error.rs create mode 100644 crates/wickra-data/src/lib.rs create mode 100644 crates/wickra-data/src/live.rs create mode 100644 crates/wickra-data/src/live/binance.rs create mode 100644 crates/wickra-data/src/resample.rs create mode 100644 crates/wickra/Cargo.toml create mode 100644 crates/wickra/benches/indicators.rs create mode 100644 crates/wickra/src/lib.rs create mode 100644 examples/python/backtest.py create mode 100644 examples/python/live_trading.py create mode 100644 examples/python/multi_timeframe.py create mode 100644 examples/python/parallel_assets.py create mode 100644 examples/rust/backtest.rs create mode 100644 examples/rust/live_binance.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..92309448 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1852e8aa --- /dev/null +++ b/.github/workflows/release.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..da16e3c2 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5cff9121 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2179 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wickra" +version = "0.1.0" +dependencies = [ + "approx", + "criterion", + "proptest", + "wickra-core", + "wickra-data", +] + +[[package]] +name = "wickra-core" +version = "0.1.0" +dependencies = [ + "approx", + "proptest", + "rayon", + "thiserror 2.0.18", +] + +[[package]] +name = "wickra-data" +version = "0.1.0" +dependencies = [ + "approx", + "csv", + "futures-util", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "url", + "wickra", + "wickra-core", +] + +[[package]] +name = "wickra-node" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "wickra-core", +] + +[[package]] +name = "wickra-python" +version = "0.1.0" +dependencies = [ + "numpy", + "pyo3", + "wickra-core", +] + +[[package]] +name = "wickra-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", + "wickra-core", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..2f748f6c --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..db61089e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..a79c801b --- /dev/null +++ b/README.md @@ -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> = 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). diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml new file mode 100644 index 00000000..7a2c0050 --- /dev/null +++ b/bindings/node/Cargo.toml @@ -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" diff --git a/bindings/node/README.md b/bindings/node/README.md new file mode 100644 index 00000000..289a835e --- /dev/null +++ b/bindings/node/README.md @@ -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.-.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. diff --git a/bindings/node/__tests__/smoke.test.js b/bindings/node/__tests__/smoke.test.js new file mode 100644 index 00000000..377fe978 --- /dev/null +++ b/bindings/node/__tests__/smoke.test.js @@ -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); +}); diff --git a/bindings/node/build.rs b/bindings/node/build.rs new file mode 100644 index 00000000..9fc23678 --- /dev/null +++ b/bindings/node/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/bindings/node/index.js b/bindings/node/index.js new file mode 100644 index 00000000..8faeeb46 --- /dev/null +++ b/bindings/node/index.js @@ -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(); diff --git a/bindings/node/package.json b/bindings/node/package.json new file mode 100644 index 00000000..5622c1a8 --- /dev/null +++ b/bindings/node/package.json @@ -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" + } +} diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs new file mode 100644 index 00000000..e53208f5 --- /dev/null +++ b/bindings/node/src/lib.rs @@ -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(r: Result) -> 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>) -> Vec { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + self.inner.update(value).map(|o| MacdValue { + macd: o.macd, + signal: o.signal, + histogram: o.histogram, + }) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + 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) -> Vec { + 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::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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, volume: Vec) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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, low: Vec) -> napi::Result> { + 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, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + 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, low: Vec) -> napi::Result> { + 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, low: Vec) -> napi::Result> { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } +} diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 00000000..6ed98f2d --- /dev/null +++ b/bindings/python/Cargo.toml @@ -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 } diff --git a/bindings/python/benchmarks/__init__.py b/bindings/python/benchmarks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/benchmarks/compare_libraries.py b/bindings/python/benchmarks/compare_libraries.py new file mode 100644 index 00000000..744ea7aa --- /dev/null +++ b/bindings/python/benchmarks/compare_libraries.py @@ -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() diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml new file mode 100644 index 00000000..5545ca1d --- /dev/null +++ b/bindings/python/pyproject.toml @@ -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"] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py new file mode 100644 index 00000000..b9919c0d --- /dev/null +++ b/bindings/python/python/wickra/__init__.py @@ -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", +] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi new file mode 100644 index 00000000..4c3100f3 --- /dev/null +++ b/bindings/python/python/wickra/__init__.pyi @@ -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]: ... diff --git a/bindings/python/python/wickra/py.typed b/bindings/python/python/wickra/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 00000000..2f589999 --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,1414 @@ +//! Python bindings for Wickra. Built with PyO3 and exposed under the `wickra` package. +//! +//! This module is the thin glue between `wickra-core` and Python. Every indicator +//! has both a streaming class and a batch helper that takes a NumPy array. + +#![allow(clippy::needless_pass_by_value)] + +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use wickra_core as wc; +use wickra_core::{BatchExt, Indicator}; + +fn map_err(e: wc::Error) -> PyErr { + match e { + wc::Error::PeriodZero + | wc::Error::InvalidPeriod { .. } + | wc::Error::NonPositiveMultiplier + | wc::Error::NonFiniteInput => PyValueError::new_err(e.to_string()), + wc::Error::InvalidCandle { .. } => PyValueError::new_err(e.to_string()), + } +} + +fn opt_to_nan(v: Option) -> f64 { + v.unwrap_or(f64::NAN) +} + +/// Convert a slice of `Option` to a flat `Vec` with NaNs for warmup. +fn flatten(values: Vec>) -> Vec { + values.into_iter().map(opt_to_nan).collect() +} + +// ============================== SMA ============================== + +#[pyclass(name = "SMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PySma { + inner: wc::Sma, +} + +#[pymethods] +impl PySma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Sma::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let slice = prices.as_slice().expect("contiguous numpy array"); + flatten(self.inner.batch(slice)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("SMA(period={})", self.inner.period()) + } +} + +// ============================== EMA ============================== + +#[pyclass(name = "EMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyEma { + inner: wc::Ema, +} + +#[pymethods] +impl PyEma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Ema::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let slice = prices.as_slice().expect("contiguous numpy array"); + flatten(self.inner.batch(slice)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn alpha(&self) -> f64 { + self.inner.alpha() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("EMA(period={})", self.inner.period()) + } +} + +// ============================== WMA ============================== + +#[pyclass(name = "WMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyWma { + inner: wc::Wma, +} + +#[pymethods] +impl PyWma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Wma::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let slice = prices.as_slice().expect("contiguous numpy array"); + flatten(self.inner.batch(slice)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("WMA(period={})", self.inner.period()) + } +} + +// ============================== RSI ============================== + +#[pyclass(name = "RSI", module = "wickra._wickra")] +#[derive(Clone)] +struct PyRsi { + inner: wc::Rsi, +} + +#[pymethods] +impl PyRsi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rsi::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let slice = prices.as_slice().expect("contiguous numpy array"); + flatten(self.inner.batch(slice)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RSI(period={})", self.inner.period()) + } +} + +// ============================== MACD ============================== + +#[pyclass(name = "MACD", module = "wickra._wickra")] +#[derive(Clone)] +struct PyMacd { + inner: wc::MacdIndicator, +} + +#[pymethods] +impl PyMacd { + #[new] + #[pyo3(signature = (fast=12, slow=26, signal=9))] + fn new(fast: usize, slow: usize, signal: usize) -> PyResult { + Ok(Self { + inner: wc::MacdIndicator::new(fast, slow, signal).map_err(map_err)?, + }) + } + /// Returns `(macd, signal, histogram)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.macd, o.signal, o.histogram)) + } + /// Batch over a numpy array of closes. Returns a 2D array of shape `(n, 3)` + /// with columns `[macd, signal, histogram]`. Warmup rows are NaN. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray2> { + let slice = prices.as_slice().expect("contiguous numpy array"); + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.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; + } + } + numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray_bound(py) + } + #[getter] + fn periods(&self) -> (usize, usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (f, s, sig) = self.inner.periods(); + format!("MACD(fast={f}, slow={s}, signal={sig})") + } +} + +// ============================== Bollinger Bands ============================== + +#[pyclass(name = "BollingerBands", module = "wickra._wickra")] +#[derive(Clone)] +struct PyBb { + inner: wc::BollingerBands, +} + +#[pymethods] +impl PyBb { + #[new] + #[pyo3(signature = (period=20, multiplier=2.0))] + fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::BollingerBands::new(period, multiplier).map_err(map_err)?, + }) + } + /// Returns `(upper, middle, lower, stddev)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.upper, o.middle, o.lower, o.stddev)) + } + /// Batch returns shape `(n, 4)` columns `[upper, middle, lower, stddev]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray2> { + let slice = prices.as_slice().expect("contiguous numpy array"); + let n = slice.len(); + let mut out = vec![f64::NAN; n * 4]; + for (i, p) in slice.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; + } + } + numpy::ndarray::Array2::from_shape_vec((n, 4), out) + .expect("shape consistent") + .into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn multiplier(&self) -> f64 { + self.inner.multiplier() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!( + "BollingerBands(period={}, multiplier={})", + self.inner.period(), + self.inner.multiplier() + ) + } +} + +// ============================== ATR ============================== + +fn extract_candle(d: &Bound<'_, PyAny>) -> PyResult { + // Accept either a dict-like with open/high/low/close/volume/timestamp, + // or a tuple (open, high, low, close, volume, timestamp). + if let Ok(tup) = d.extract::<(f64, f64, f64, f64, f64, i64)>() { + return wc::Candle::new(tup.0, tup.1, tup.2, tup.3, tup.4, tup.5).map_err(map_err); + } + if let Ok(dict) = d.downcast::() { + let g = |k: &str| -> PyResult { + dict.get_item(k)? + .ok_or_else(|| PyValueError::new_err(format!("candle missing key '{k}'")))? + .extract::() + }; + let ts = dict + .get_item("timestamp")? + .map(|v| v.extract::()) + .transpose()? + .unwrap_or(0); + return wc::Candle::new( + g("open")?, + g("high")?, + g("low")?, + g("close")?, + g("volume")?, + ts, + ) + .map_err(map_err); + } + Err(PyTypeError::new_err( + "candle must be a 6-tuple (open, high, low, close, volume, timestamp) or a dict", + )) +} + +#[pyclass(name = "ATR", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAtr { + inner: wc::Atr, +} + +#[pymethods] +impl PyAtr { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Atr::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ATR(period={})", self.inner.period()) + } +} + +// ============================== Stochastic ============================== + +#[pyclass(name = "Stochastic", module = "wickra._wickra")] +#[derive(Clone)] +struct PyStoch { + inner: wc::Stochastic, +} + +#[pymethods] +impl PyStoch { + #[new] + #[pyo3(signature = (k_period=14, d_period=3))] + fn new(k_period: usize, d_period: usize) -> PyResult { + Ok(Self { + inner: wc::Stochastic::new(k_period, d_period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.k, o.d))) + } + /// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for `[k, d]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.k; + out[i * 2 + 1] = o.d; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (k, d) = self.inner.periods(); + format!("Stochastic(k_period={k}, d_period={d})") + } +} + +// ============================== OBV ============================== + +#[pyclass(name = "OBV", module = "wickra._wickra")] +#[derive(Clone)] +struct PyObv { + inner: wc::Obv, +} + +#[pymethods] +impl PyObv { + #[new] + fn new() -> Self { + Self { + inner: wc::Obv::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy close + volume arrays. + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close.as_slice().expect("contiguous"); + let v = volume.as_slice().expect("contiguous"); + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "OBV()".to_string() + } +} + +// ============================== DEMA ============================== + +#[pyclass(name = "DEMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyDema { + inner: wc::Dema, +} + +#[pymethods] +impl PyDema { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Dema::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("DEMA(period={})", self.inner.period()) + } +} + +// ============================== TEMA ============================== + +#[pyclass(name = "TEMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyTema { + inner: wc::Tema, +} + +#[pymethods] +impl PyTema { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Tema::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TEMA(period={})", self.inner.period()) + } +} + +// ============================== HMA ============================== + +#[pyclass(name = "HMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyHma { + inner: wc::Hma, +} + +#[pymethods] +impl PyHma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Hma::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("HMA(period={})", self.inner.period()) + } +} + +// ============================== KAMA ============================== + +#[pyclass(name = "KAMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyKama { + inner: wc::Kama, +} + +#[pymethods] +impl PyKama { + #[new] + #[pyo3(signature = (er_period=10, fast=2, slow=30))] + fn new(er_period: usize, fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::Kama::new(er_period, fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "KAMA".to_string() + } +} + +// ============================== CCI ============================== + +#[pyclass(name = "CCI", module = "wickra._wickra")] +#[derive(Clone)] +struct PyCci { + inner: wc::Cci, +} + +#[pymethods] +impl PyCci { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Cci::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("CCI(period={})", self.inner.period()) + } +} + +// ============================== ROC ============================== + +#[pyclass(name = "ROC", module = "wickra._wickra")] +#[derive(Clone)] +struct PyRoc { + inner: wc::Roc, +} + +#[pymethods] +impl PyRoc { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Roc::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ROC(period={})", self.inner.period()) + } +} + +// ============================== Williams %R ============================== + +#[pyclass(name = "WilliamsR", module = "wickra._wickra")] +#[derive(Clone)] +struct PyWilliamsR { + inner: wc::WilliamsR, +} + +#[pymethods] +impl PyWilliamsR { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::WilliamsR::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== ADX ============================== + +#[pyclass(name = "ADX", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAdx { + inner: wc::Adx, +} + +#[pymethods] +impl PyAdx { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Adx::new(period).map_err(map_err)?, + }) + } + /// Returns `(plus_di, minus_di, adx)` or None during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.plus_di, o.minus_di, o.adx))) + } + /// Batch returns shape `(n, 3)`: `[plus_di, minus_di, adx]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.plus_di; + out[i * 3 + 1] = o.minus_di; + out[i * 3 + 2] = o.adx; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== MFI ============================== + +#[pyclass(name = "MFI", module = "wickra._wickra")] +#[derive(Clone)] +struct PyMfi { + inner: wc::Mfi, +} + +#[pymethods] +impl PyMfi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Mfi::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let v = volume.as_slice().expect("contiguous"); + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== TRIX ============================== + +#[pyclass(name = "TRIX", module = "wickra._wickra")] +#[derive(Clone)] +struct PyTrix { + inner: wc::Trix, +} + +#[pymethods] +impl PyTrix { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Trix::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> Bound<'py, PyArray1> { + let s = prices.as_slice().expect("contiguous"); + flatten(self.inner.batch(s)).into_pyarray_bound(py) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== PSAR ============================== + +#[pyclass(name = "PSAR", module = "wickra._wickra")] +#[derive(Clone)] +struct PyPsar { + inner: wc::Psar, +} + +#[pymethods] +impl PyPsar { + #[new] + #[pyo3(signature = (af_start=0.02, af_step=0.02, af_max=0.20))] + fn new(af_start: f64, af_step: f64, af_max: f64) -> PyResult { + Ok(Self { + inner: wc::Psar::new(af_start, af_step, af_max).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Keltner Channels ============================== + +#[pyclass(name = "Keltner", module = "wickra._wickra")] +#[derive(Clone)] +struct PyKeltner { + inner: wc::Keltner, +} + +#[pymethods] +impl PyKeltner { + #[new] + #[pyo3(signature = (ema_period=20, atr_period=10, multiplier=2.0))] + fn new(ema_period: usize, atr_period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: wc::Keltner::new(ema_period, atr_period, multiplier).map_err(map_err)?, + }) + } + /// Returns `(upper, middle, lower)` or None during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + /// Returns shape `(n, 3)` for `[upper, middle, lower]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Donchian Channels ============================== + +#[pyclass(name = "Donchian", module = "wickra._wickra")] +#[derive(Clone)] +struct PyDonchian { + inner: wc::Donchian, +} + +#[pymethods] +impl PyDonchian { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Donchian::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.upper, o.middle, o.lower))) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.upper; + out[i * 3 + 1] = o.middle; + out[i * 3 + 2] = o.lower; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== VWAP ============================== + +#[pyclass(name = "VWAP", module = "wickra._wickra")] +#[derive(Clone)] +struct PyVwap { + inner: wc::Vwap, +} + +#[pymethods] +impl PyVwap { + #[new] + fn new() -> Self { + Self { + inner: wc::Vwap::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let c = close.as_slice().expect("contiguous"); + let v = volume.as_slice().expect("contiguous"); + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Awesome Oscillator ============================== + +#[pyclass(name = "AwesomeOscillator", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAo { + inner: wc::AwesomeOscillator, +} + +#[pymethods] +impl PyAo { + #[new] + #[pyo3(signature = (fast=5, slow=34))] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::AwesomeOscillator::new(fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Aroon ============================== + +#[pyclass(name = "Aroon", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAroon { + inner: wc::Aroon, +} + +#[pymethods] +impl PyAroon { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Aroon::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.up, o.down))) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high.as_slice().expect("contiguous"); + let l = low.as_slice().expect("contiguous"); + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.up; + out[i * 2 + 1] = o.down; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Module ============================== + +#[pymodule] +fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/bindings/python/tests/__init__.py b/bindings/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py new file mode 100644 index 00000000..8be9fb9d --- /dev/null +++ b/bindings/python/tests/conftest.py @@ -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 diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py new file mode 100644 index 00000000..c481b26c --- /dev/null +++ b/bindings/python/tests/test_known_values.py @@ -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]) diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py new file mode 100644 index 00000000..27a5e523 --- /dev/null +++ b/bindings/python/tests/test_lifecycle.py @@ -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 diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py new file mode 100644 index 00000000..92b64d4b --- /dev/null +++ b/bindings/python/tests/test_smoke.py @@ -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 diff --git a/bindings/python/tests/test_streaming_vs_batch.py b/bindings/python/tests/test_streaming_vs_batch.py new file mode 100644 index 00000000..fb9062b8 --- /dev/null +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -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) diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml new file mode 100644 index 00000000..fc39c75e --- /dev/null +++ b/bindings/wasm/Cargo.toml @@ -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'] diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md new file mode 100644 index 00000000..6efd73f5 --- /dev/null +++ b/bindings/wasm/README.md @@ -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`. diff --git a/bindings/wasm/examples/index.html b/bindings/wasm/examples/index.html new file mode 100644 index 00000000..6d8652ea --- /dev/null +++ b/bindings/wasm/examples/index.html @@ -0,0 +1,137 @@ + + + + + Wickra WASM demo + + + +

Wickra in the browser

+

Indicators running entirely client-side via WebAssembly. No network round-trips.

+ + + +
+

SMA(20)

+

EMA(20)

+

RSI(14)

+

MACD

+

Bollinger

+

ATR(14)

+
+ +

+ + + +

+ +

Loading WASM module…

+ + + + diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs new file mode 100644 index 00000000..fcf7580d --- /dev/null +++ b/bindings/wasm/src/lib.rs @@ -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>) -> Vec { + 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 { + 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 { + Ok(Self { + inner: wc::Kama::new(er_period, fast, slow).map_err(map_err)?, + }) + } + pub fn update(&mut self, value: f64) -> Option { + 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 { + 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 { + 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::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 { + Ok(Self { + inner: wc::Atr::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + Ok(Self { + inner: wc::WilliamsR::new(period).map_err(map_err)?, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + 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 { + Ok(Self { + inner: wc::Cci::new(period).map_err(map_err)?, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + Ok(Self { + inner: wc::Donchian::new(period).map_err(map_err)?, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + 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 { + 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 { + Ok(Self { + inner: wc::AwesomeOscillator::new(fast, slow).map_err(map_err)?, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + 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 { + 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 { + 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())) + } +} diff --git a/crates/wickra-core/Cargo.toml b/crates/wickra-core/Cargo.toml new file mode 100644 index 00000000..e11525bb --- /dev/null +++ b/crates/wickra-core/Cargo.toml @@ -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 } diff --git a/crates/wickra-core/proptest-regressions/indicators/wma.txt b/crates/wickra-core/proptest-regressions/indicators/wma.txt new file mode 100644 index 00000000..ae2d5fb1 --- /dev/null +++ b/crates/wickra-core/proptest-regressions/indicators/wma.txt @@ -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] diff --git a/crates/wickra-core/src/error.rs b/crates/wickra-core/src/error.rs new file mode 100644 index 00000000..50bf13dc --- /dev/null +++ b/crates/wickra-core/src/error.rs @@ -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`. +pub type Result = core::result::Result; diff --git a/crates/wickra-core/src/indicators/adx.rs b/crates/wickra-core/src/indicators/adx.rs new file mode 100644 index 00000000..c825b47d --- /dev/null +++ b/crates/wickra-core/src/indicators/adx.rs @@ -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, + + // 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, + plus_dm_smooth: Option, + minus_dm_smooth: Option, + + // ADX seeding. + dx_buf: Vec, + adx_value: Option, + last_plus_di: f64, + last_minus_di: f64, +} + +impl Adx { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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::() / 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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/aroon.rs b/crates/wickra-core/src/indicators/aroon.rs new file mode 100644 index 00000000..060fefb7 --- /dev/null +++ b/crates/wickra-core/src/indicators/aroon.rs @@ -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, +} + +impl Aroon { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn outputs_in_range() { + let candles: Vec = (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)); + } + } +} diff --git a/crates/wickra-core/src/indicators/atr.rs b/crates/wickra-core/src/indicators/atr.rs new file mode 100644 index 00000000..4ab06e86 --- /dev/null +++ b/crates/wickra-core/src/indicators/atr.rs @@ -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, + seed_buf: Vec, + avg: Option, +} + +impl Atr { + /// Construct an ATR with the given Wilder period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + self.avg + } +} + +impl Indicator for Atr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + 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::() / 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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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 = (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}"); + } + } +} diff --git a/crates/wickra-core/src/indicators/awesome_oscillator.rs b/crates/wickra-core/src/indicators/awesome_oscillator.rs new file mode 100644 index 00000000..d04aa663 --- /dev/null +++ b/crates/wickra-core/src/indicators/awesome_oscillator.rs @@ -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 { + 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 { + 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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs new file mode 100644 index 00000000..37f89bdb --- /dev/null +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -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, + 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 { + 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 { + 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 { + 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 { + if prices.len() < period { + return None; + } + let w = &prices[prices.len() - period..]; + let mean = w.iter().sum::() / period as f64; + let var = w.iter().map(|x| (x - mean).powi(2)).sum::() / 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 = (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 = (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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/cci.rs b/crates/wickra-core/src/indicators/cci.rs new file mode 100644 index 00000000..517c8cdc --- /dev/null +++ b/crates/wickra-core/src/indicators/cci.rs @@ -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, + 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::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 { + 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 { + 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::() / 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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/dema.rs b/crates/wickra-core/src/indicators/dema.rs new file mode 100644 index 00000000..ee72167c --- /dev/null +++ b/crates/wickra-core/src/indicators/dema.rs @@ -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 { + 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 { + 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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut dema = Dema::new(5).unwrap(); + dema.batch(&(1..=50).map(f64::from).collect::>()); + assert!(dema.is_ready()); + dema.reset(); + assert!(!dema.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Dema::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/donchian.rs b/crates/wickra-core/src/indicators/donchian.rs new file mode 100644 index 00000000..8c4438ed --- /dev/null +++ b/crates/wickra-core/src/indicators/donchian.rs @@ -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, +} + +impl Donchian { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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::>() + ); + } + + #[test] + fn upper_above_middle_above_lower() { + let candles: Vec = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/ema.rs b/crates/wickra-core/src/indicators/ema.rs new file mode 100644 index 00000000..b768e95c --- /dev/null +++ b/crates/wickra-core/src/indicators/ema.rs @@ -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, + warmup_buf: Vec, +} + +impl Ema { + /// Construct an EMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 { + 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 { + 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::() / 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 { + 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 = (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::>() + ); + } + + #[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); + } +} diff --git a/crates/wickra-core/src/indicators/hma.rs b/crates/wickra-core/src/indicators/hma.rs new file mode 100644 index 00000000..1cc3003f --- /dev/null +++ b/crates/wickra-core/src/indicators/hma.rs @@ -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 { + 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 { + 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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut hma = Hma::new(9).unwrap(); + hma.batch(&(1..=80).map(f64::from).collect::>()); + assert!(hma.is_ready()); + hma.reset(); + assert!(!hma.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Hma::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/kama.rs b/crates/wickra-core/src/indicators/kama.rs new file mode 100644 index 00000000..087e1866 --- /dev/null +++ b/crates/wickra-core/src/indicators/kama.rs @@ -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, + state: Option, +} + +impl Kama { + /// # Errors + /// Returns [`Error::PeriodZero`] / [`Error::InvalidPeriod`] for bad parameters. + pub fn new(er_period: usize, fast: usize, slow: usize) -> Result { + 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 { + 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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut k = Kama::classic(); + k.batch(&(1..=50).map(f64::from).collect::>()); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/keltner.rs b/crates/wickra-core/src/indicators/keltner.rs new file mode 100644 index 00000000..9cc7cf92 --- /dev/null +++ b/crates/wickra-core/src/indicators/keltner.rs @@ -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 { + 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 { + 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 = (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 = (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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/macd.rs b/crates/wickra-core/src/indicators/macd.rs new file mode 100644 index 00000000..3cabc1b4 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd.rs @@ -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, +} + +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 { + 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 { + self.last + } +} + +impl Indicator for MacdIndicator { + type Input = f64; + type Output = MacdOutput; + + fn update(&mut self, input: f64) -> Option { + 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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut macd = MacdIndicator::classic(); + macd.batch(&(1..=80).map(f64::from).collect::>()); + assert!(macd.is_ready()); + macd.reset(); + assert!(!macd.is_ready()); + assert_eq!(macd.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/mfi.rs b/crates/wickra-core/src/indicators/mfi.rs new file mode 100644 index 00000000..bef01a67 --- /dev/null +++ b/crates/wickra-core/src/indicators/mfi.rs @@ -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, + pos_window: VecDeque, + neg_window: VecDeque, + pos_sum: f64, + neg_sum: f64, +} + +impl Mfi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs new file mode 100644 index 00000000..e22152ae --- /dev/null +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/obv.rs b/crates/wickra-core/src/indicators/obv.rs new file mode 100644 index 00000000..96d722e9 --- /dev/null +++ b/crates/wickra-core/src/indicators/obv.rs @@ -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, + 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 { + 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 { + // 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 = (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::>() + ); + } + + #[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); + } +} diff --git a/crates/wickra-core/src/indicators/psar.rs b/crates/wickra-core/src/indicators/psar.rs new file mode 100644 index 00000000..5608e4de --- /dev/null +++ b/crates/wickra-core/src/indicators/psar.rs @@ -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 { + 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 { + 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 = (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 = (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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/roc.rs b/crates/wickra-core/src/indicators/roc.rs new file mode 100644 index 00000000..82c09ac9 --- /dev/null +++ b/crates/wickra-core/src/indicators/roc.rs @@ -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, +} + +impl Roc { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/rsi.rs b/crates/wickra-core/src/indicators/rsi.rs new file mode 100644 index 00000000..df2861e8 --- /dev/null +++ b/crates/wickra-core/src/indicators/rsi.rs @@ -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, + // Wilder seeds with the simple average of the first `period` gains/losses, + // then transitions to recursive smoothing. + seed_buf_gains: Vec, + seed_buf_losses: Vec, + avg_gain: Option, + avg_loss: Option, + last_value: Option, +} + +impl Rsi { + /// Construct an RSI with the given Wilder period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 { + 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::() / self.period as f64; + let al = self.seed_buf_losses.iter().sum::() / 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 = (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 = (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 = (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 = (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 = (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::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/sma.rs b/crates/wickra-core/src/indicators/sma.rs new file mode 100644 index 00000000..b58f3891 --- /dev/null +++ b/crates/wickra-core/src/indicators/sma.rs @@ -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, + 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 { + 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 { + 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 { + 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 = (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::() / period as f64; + let actual = got.expect("ready"); + proptest::prop_assert!( + (actual - expected).abs() < 1e-9, + "i={i} actual={actual} expected={expected}" + ); + } + } + } + } +} diff --git a/crates/wickra-core/src/indicators/stochastic.rs b/crates/wickra-core/src/indicators/stochastic.rs new file mode 100644 index 00000000..48e98cb1 --- /dev/null +++ b/crates/wickra-core/src/indicators/stochastic.rs @@ -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, + // Monotonic deques over candle indices in the rolling window. + hh_idx: VecDeque, // indices of candidates for highest high (front = current max) + ll_idx: VecDeque, // 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, +} + +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 { + 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 { + 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> { + 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 = (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 = (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 = (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::() + / 3.0; + assert_relative_eq!(first_d, want, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut s = Stochastic::new(5, 3).unwrap(); + let candles: Vec = (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); + } +} diff --git a/crates/wickra-core/src/indicators/tema.rs b/crates/wickra-core/src/indicators/tema.rs new file mode 100644 index 00000000..c4d78fc5 --- /dev/null +++ b/crates/wickra-core/src/indicators/tema.rs @@ -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 { + 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 { + 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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut tema = Tema::new(5).unwrap(); + tema.batch(&(1..=80).map(f64::from).collect::>()); + assert!(tema.is_ready()); + tema.reset(); + assert!(!tema.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Tema::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/trix.rs b/crates/wickra-core/src/indicators/trix.rs new file mode 100644 index 00000000..c1afcb2f --- /dev/null +++ b/crates/wickra-core/src/indicators/trix.rs @@ -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, + period: usize, +} + +impl Trix { + /// # Errors + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut trix = Trix::new(5).unwrap(); + trix.batch(&(1..=80).map(f64::from).collect::>()); + assert!(trix.is_ready()); + trix.reset(); + assert!(!trix.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(Trix::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/vwap.rs b/crates/wickra-core/src/indicators/vwap.rs new file mode 100644 index 00000000..a2e2301a --- /dev/null +++ b/crates/wickra-core/src/indicators/vwap.rs @@ -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 { + 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 { + 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 { + 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 { + 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 = (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::>() + ); + } + + #[test] + fn batch_equals_streaming_rolling() { + let candles: Vec = (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::>() + ); + } + + #[test] + fn rolling_rejects_zero_period() { + assert!(RollingVwap::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/williams_r.rs b/crates/wickra-core/src/indicators/williams_r.rs new file mode 100644 index 00000000..e2f087bf --- /dev/null +++ b/crates/wickra-core/src/indicators/williams_r.rs @@ -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, +} + +impl WilliamsR { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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::>() + ); + } + + #[test] + fn rejects_zero_period() { + assert!(WilliamsR::new(0).is_err()); + } +} diff --git a/crates/wickra-core/src/indicators/wma.rs b/crates/wickra-core/src/indicators/wma.rs new file mode 100644 index 00000000..96a85a8f --- /dev/null +++ b/crates/wickra-core/src/indicators/wma.rs @@ -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, + 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 { + 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 { + 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 { + 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> { + 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 = (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 = (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::>() + ); + } + + 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"), + } + } + } + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs new file mode 100644 index 00000000..9ccdef7f --- /dev/null +++ b/crates/wickra-core/src/lib.rs @@ -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`] +//! 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}; diff --git a/crates/wickra-core/src/ohlcv.rs b/crates/wickra-core/src/ohlcv.rs new file mode 100644 index 00000000..5477b591 --- /dev/null +++ b/crates/wickra-core/src/ohlcv.rs @@ -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 { + 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 { + 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 { + 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 { .. })); + } +} diff --git a/crates/wickra-core/src/traits.rs b/crates/wickra-core/src/traits.rs new file mode 100644 index 00000000..8ea88328 --- /dev/null +++ b/crates/wickra-core/src/traits.rs @@ -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` 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; + + /// 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> + 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( + inputs_per_asset: &[Vec], + make: F, + ) -> Vec>> + 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 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 +where + A: Indicator, + B: Indicator, +{ + first: A, + second: B, +} + +impl Chain +where + A: Indicator, + B: Indicator, +{ + /// 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(self, third: C) -> Chain + where + C: Indicator, + Self: Indicator, + { + 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 Indicator for Chain +where + A: Indicator, + B: Indicator, +{ + type Input = f64; + type Output = B::Output; + + fn update(&mut self, input: f64) -> Option { + 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 { + 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 { + 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![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)]); + } +} diff --git a/crates/wickra-data/Cargo.toml b/crates/wickra-data/Cargo.toml new file mode 100644 index 00000000..b7b233af --- /dev/null +++ b/crates/wickra-data/Cargo.toml @@ -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"] diff --git a/crates/wickra-data/src/aggregator.rs b/crates/wickra-data/src/aggregator.rs new file mode 100644 index 00000000..9766bf9f --- /dev/null +++ b/crates/wickra-data/src/aggregator.rs @@ -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 { + 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::new(ms) + } + + /// Convenience: build a seconds-resolution timeframe. + pub fn seconds(s: i64) -> Result { + 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, +} + +#[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> { + 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 { + 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(_))); + } +} diff --git a/crates/wickra-data/src/csv.rs b/crates/wickra-data/src/csv.rs new file mode 100644 index 00000000..c9f5b212 --- /dev/null +++ b/crates/wickra-data/src/csv.rs @@ -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::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 { + reader: csv::Reader, +} + +impl CandleReader { + /// Open a CSV file at `path`. The first line is treated as a header by default. + pub fn open>(path: P) -> Result { + let reader = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(path)?; + Ok(Self { reader }) + } +} + +impl CandleReader { + /// 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) -> Self { + Self { reader } + } + + /// Iterator over decoded candles. + pub fn candles(&mut self) -> impl Iterator> + '_ { + self.reader.deserialize::().map(|row_res| { + let row = row_res?; + row.into_candle() + }) + } + + /// Read the entire stream into a `Vec`. Convenient for backtests. + pub fn read_all(&mut self) -> Result> { + 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> = 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); + } +} diff --git a/crates/wickra-data/src/error.rs b/crates/wickra-data/src/error.rs new file mode 100644 index 00000000..d6b57267 --- /dev/null +++ b/crates/wickra-data/src/error.rs @@ -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`. +pub type Result = core::result::Result; diff --git a/crates/wickra-data/src/lib.rs b/crates/wickra-data/src/lib.rs new file mode 100644 index 00000000..91b3dc8c --- /dev/null +++ b/crates/wickra-data/src/lib.rs @@ -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}; diff --git a/crates/wickra-data/src/live.rs b/crates/wickra-data/src/live.rs new file mode 100644 index 00000000..b70ac1a4 --- /dev/null +++ b/crates/wickra-data/src/live.rs @@ -0,0 +1,4 @@ +//! Live exchange feeds. Each adapter is feature-gated; the Binance adapter +//! lives behind the `live-binance` feature. + +pub mod binance; diff --git a/crates/wickra-data/src/live/binance.rs b/crates/wickra-data/src/live/binance.rs new file mode 100644 index 00000000..cc174d28 --- /dev/null +++ b/crates/wickra-data/src/live/binance.rs @@ -0,0 +1,293 @@ +//! Binance spot WebSocket kline feed. +//! +//! Subscribes to Binance's `@kline_` 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>, + /// 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 { + if symbols.is_empty() { + return Err(Error::Malformed( + "BinanceKlineStream requires at least one symbol".into(), + )); + } + let streams: Vec = 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> { + 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 { + 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(_))); + } +} diff --git a/crates/wickra-data/src/resample.rs b/crates/wickra-data/src/resample.rs new file mode 100644 index 00000000..a376192b --- /dev/null +++ b/crates/wickra-data/src/resample.rs @@ -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, +} + +#[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 { + 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 { + 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(timeframe: Timeframe, iter: I) -> Result> +where + I: IntoIterator>, +{ + 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); + } +} diff --git a/crates/wickra/Cargo.toml b/crates/wickra/Cargo.toml new file mode 100644 index 00000000..783655c2 --- /dev/null +++ b/crates/wickra/Cargo.toml @@ -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 = [] diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs new file mode 100644 index 00000000..74a2627f --- /dev/null +++ b/crates/wickra/benches/indicators.rs @@ -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 { + (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 { + 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(c: &mut Criterion, name: &str, sizes: &[usize], make: F) +where + F: Fn() -> I, + I: Indicator + 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(c: &mut Criterion, name: &str, sizes: &[usize], make: F) +where + F: Fn() -> I, + I: Indicator, +{ + 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); diff --git a/crates/wickra/src/lib.rs b/crates/wickra/src/lib.rs new file mode 100644 index 00000000..3b020a6d --- /dev/null +++ b/crates/wickra/src/lib.rs @@ -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> = 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::*; diff --git a/examples/python/backtest.py b/examples/python/backtest.py new file mode 100644 index 00000000..d31ea8ac --- /dev/null +++ b/examples/python/backtest.py @@ -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()) diff --git a/examples/python/live_trading.py b/examples/python/live_trading.py new file mode 100644 index 00000000..d3f9b71a --- /dev/null +++ b/examples/python/live_trading.py @@ -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()) diff --git a/examples/python/multi_timeframe.py b/examples/python/multi_timeframe.py new file mode 100644 index 00000000..53ca9fee --- /dev/null +++ b/examples/python/multi_timeframe.py @@ -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()) diff --git a/examples/python/parallel_assets.py b/examples/python/parallel_assets.py new file mode 100644 index 00000000..d6195a1b --- /dev/null +++ b/examples/python/parallel_assets.py @@ -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()) diff --git a/examples/rust/backtest.rs b/examples/rust/backtest.rs new file mode 100644 index 00000000..798e5619 --- /dev/null +++ b/examples/rust/backtest.rs @@ -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> { + let args: Vec = env::args().collect(); + let path = args.get(1).ok_or("usage: backtest ")?; + + 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 = 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(()) +} diff --git a/examples/rust/live_binance.rs b/examples/rust/live_binance.rs new file mode 100644 index 00000000..47f7381d --- /dev/null +++ b/examples/rust/live_binance.rs @@ -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> { + let args: Vec = 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(()) +}